api-surface #1

Merged
luna merged 51 commits from api-surface into mistress 2026-07-05 14:43:13 +00:00
141 changed files with 58439 additions and 795 deletions

View file

@ -1,4 +1,13 @@
# Used by "mix format"
[
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"],
locals_without_parens: [
field: 2,
field: 3,
hydrate: 2,
discord_struct: 1,
include_fields: 1,
endpoint: 3,
endpoint: 4
]
]

14
.gitea/workflows/ci.yml Normal file
View file

@ -0,0 +1,14 @@
name: ci
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: erlef/setup-beam@v1
with:
elixir-version: "1.18"
otp-version: "27"
- run: mix deps.get
- run: mix test
- run: mix dexcord.coverage

View file

@ -112,18 +112,21 @@ 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):
`{event_name, data}` tuple. `data` is decoded exactly once, in the dispatcher,
into a typed model **struct** with typed fields and **integer** snowflake ids -
`%Dexcord.Message{}`, `%Dexcord.Guild{}`, `%Dexcord.Interaction{}`, and friends
(a malformed payload that fails to decode falls back to the raw string-keyed map
so dispatch never stalls):
```elixir
defmodule MyBot.Handler do
use Dexcord.Handler
def handle_event({:MESSAGE_CREATE, msg}) do
IO.puts("#{msg["author"]["username"]}: #{msg["content"]}")
def handle_event({:MESSAGE_CREATE, %Dexcord.Message{} = msg}) do
IO.puts("#{msg.author.username}: #{msg.content}")
end
def handle_event({:PRESENCE_UPDATE, presence}) do
def handle_event({:PRESENCE_UPDATE, %Dexcord.Presence{} = presence}) do
# only fires if cache_presences: true and presences are being cached
end
end
@ -144,28 +147,30 @@ handler's `MESSAGE_CREATE` clause:
defmodule MyBot.Commands do
use Dexcord.Prefix.Router
def handle_command("ping", _args, msg) do
Dexcord.Api.create_message(msg["channel_id"], "pong")
def handle_command("ping", _args, %Dexcord.Message{} = msg) do
Dexcord.Message.reply(msg, "pong")
end
def handle_command("echo", args, msg) do
Dexcord.Api.create_message(msg["channel_id"], Enum.join(args, " "))
def handle_command("echo", args, %Dexcord.Message{} = msg) do
Dexcord.Api.send(msg.channel_id, Enum.join(args, " "))
end
end
defmodule MyBot.Handler do
use Dexcord.Handler
def handle_event({:MESSAGE_CREATE, msg}) do
def handle_event({:MESSAGE_CREATE, %Dexcord.Message{} = 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
The router receives the decoded `%Dexcord.Message{}`, so command handlers read
typed fields (`msg.channel_id`, `msg.author.id`) and reply with
`Dexcord.Message.reply/2` or `Dexcord.Api.send/2`. `dispatch/2` skips messages
authored by a bot (including the bot's own messages, checked both via the
`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.
@ -327,13 +332,20 @@ 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):
Typed endpoints take string-keyed request maps (or a bare binary where a
`content`/`name` wrap is natural) and **decode their responses into model
structs** - `{:ok, %Dexcord.Message{}}`, `{:ok, %Dexcord.User{}}`,
`{:ok, %Dexcord.Channel{}}` (the concrete per-type struct), a list of structs
for list endpoints, or `{:error, %Dexcord.Api.Error{}}`:
```elixir
{:ok, %Dexcord.Message{} = sent} = Dexcord.Api.create_message(channel_id, "hi")
{:ok, %Dexcord.User{} = me} = Dexcord.Api.get_current_user()
```
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
delete_message/2 create_reaction/3 get_channel_messages/2
create_interaction_response/3
edit_original_interaction_response/3
create_followup_message/3
@ -341,8 +353,15 @@ 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:
`Dexcord.Api.send/2` is the ergonomic front door over `create_message`: it
accepts anything `Dexcord.Messageable` - a channel or thread struct, a
`%Dexcord.Message{}` (posts to its channel), a `%Dexcord.User{}`/`%Dexcord.Member{}`
(lazily opening and caching a DM), or a bare integer channel id - and applies the
configured `allowed_mentions` default. List endpoints also come as lazy streams
(`message_history/2`, `guild_members_stream/2`, ...) that page on demand.
For anything without a typed wrapper, `Dexcord.Api.request/4` is the escape hatch
every typed endpoint is built on - it stays string-keyed both ways:
```elixir
Dexcord.Api.request(:patch, "/guilds/#{guild_id}", %{"name" => "New Name"})
@ -422,16 +441,19 @@ running untouched.
| 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.Api.*` | `Dexcord.Api.*` (typed struct returns + `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` |
| `%Nostrum.Struct.Message{}` etc. (atom-keyed structs) | `%Dexcord.Message{}` etc. (typed structs, integer snowflake ids) - `msg.content`, `msg.author.id` |
| snowflake ids parsed to integers | snowflake ids **are** integers on every decoded struct |
| 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.
Like Nostrum, Dexcord hands your handler decoded **structs** with typed fields
and integer ids, so day-to-day access is `msg.content` / `msg.author.id`, not
map indexing. The escape hatch below the model layer - `Dexcord.Api.request/4` -
stays string-keyed both ways for endpoints without a typed wrapper. A full
worked port (READY backfill, MESSAGE_CREATE flow, thread cache reads, integer
snowflakes, REST, hydration) lives in
[`docs/alamedya-migration-v2.md`](docs/alamedya-migration-v2.md).
## Reliability design

View file

@ -0,0 +1,482 @@
# Migrating Alamedya from Nostrum to Dexcord (v2 — the typed contract)
This is the v2 migration guide, rewritten against Dexcord's **typed struct
contract**. It supersedes the older internal notes: every gateway payload now
arrives as a decoded model **struct** (`%Dexcord.Message{}`, `%Dexcord.Guild{}`,
`%Dexcord.Interaction{}`, …) with **integer** snowflake ids and typed nested
fields — not the raw string-keyed maps the first draft assumed.
It is self-contained. It is written for a Claude thread (or engineer) with **no
prior context** on either codebase. Follow it top to bottom.
- **Source app:** Alamedya — a Phoenix app whose Discord side currently runs on
Nostrum behind a hand-rolled discord.py bridge.
- **Target library:** Dexcord — a reliability-first, single-shard Discord library
that owns its own gateway (resume-over-reidentify, zombie detection,
crash-surviving sessions).
## Why this migration exists (read this first)
Alamedya today runs Discord in a dual-path hack: Nostrum's own gateway shard is
disabled, and a **discord.py proxy** connects the real gateway and POSTs every
raw payload into a Phoenix controller that re-injects it through Nostrum's
internal dispatch. That contraption exists only because Nostrum's gateway drops
the websocket (laptop sleep / flaky network) and never recovers.
Dexcord was built specifically to survive that. So the migration **cuts the
Python bridge entirely** and lets Dexcord connect the gateway directly — the
intended end state. Alamedya's Discord footprint is small and message-shaped: it
consumes `READY` and `MESSAGE_CREATE`, sends messages, reads channel history,
creates one thread, and reads guild threads from cache. Nothing Dexcord lacks by
design blocks it.
## What changed since the first draft: string maps → typed structs
The single biggest thing to internalize: **Dexcord decodes each payload exactly
once, in the dispatcher, into a struct, before your handler runs.** The first
migration draft told you to reach into raw maps (`msg["author"]["id"]`) and to
`String.to_integer/1` every id at the handler boundary. **Delete all of that.**
The struct already has typed fields and integer ids:
| First-draft assumption (raw maps) | v2 reality (typed structs) |
|---|---|
| `msg["content"]` | `msg.content` |
| `msg["author"]["id"]` (a string) | `msg.author.id` (an integer) |
| `String.to_integer(msg["channel_id"])` | `msg.channel_id` (already an integer) |
| `Dexcord.Cache.threads(gid)` returns maps | returns `[%Dexcord.Thread{}]` |
| `Dexcord.Api.create_message/2` returns `{:ok, map}` | returns `{:ok, %Dexcord.Message{}}` |
The rest of this guide walks every Discord touchpoint Alamedya has, `before`
(Nostrum / raw-map style) → `after` (typed).
## Prerequisites
**Elixir must be `>= 1.18`.** Dexcord uses the built-in `JSON` module and ships
no Jason. Bump `mix.exs` and confirm `elixir --version` reports ≥1.18 wherever
Alamedya builds and runs. Keep Jason as a dep — Phoenix still uses it.
Depend on Dexcord via `path:` (or `git:`), drop the Nostrum dep, `mix deps.get`.
---
## 1. Boot / config
Dexcord is a library you add as **one child** to your own supervision tree; the
event handler is a plain module, not a supervised process. The typed contract
adds one boot-time knob worth setting up front: an `allowed_mentions` **default**
that every `Dexcord.Api.send/2` and `Dexcord.Message.reply/2` merges under. A bot
that should never accidentally `@everyone` sets `parse: []` once, here, instead
of threading `allowed_mentions` through every send.
**Before** (Nostrum auto-starts from application config):
```elixir
# config/config.exs
config :nostrum,
gateway_intents: :all,
num_shards: :manual
# config/runtime.exs
config :nostrum, token: System.get_env("DISCORD_TOKEN")
```
**After** (typed child spec; no application config for the gateway):
```elixir
# lib/alamedya/application.ex
children = [
# ... Repo, PubSub, Endpoint, your own supervisors ...
{Dexcord,
token: System.fetch_env!("DISCORD_TOKEN"),
handler: AlamedyaDiscord.Handler,
intents: :all,
cache_presences: false,
request_guild_members: false,
# Field-wise default: per-send values override key-by-key; unset keys fall
# back to this. `parse: []` suppresses every mention type unless a send opts
# back in.
allowed_mentions: [parse: []]}
]
```
`Dexcord.child_spec/1` validates these eagerly and raises `ArgumentError` on
anything missing or malformed, so a bad token or unknown intent fails at boot,
not at first use. `:allowed_mentions` accepts a keyword list, a map, or a
`%Dexcord.AllowedMentions{}`.
---
## 2. READY backfill
`READY` arrives as a typed `%Dexcord.Events.Ready{}`. Its `guilds` are
**stubs** — `%Dexcord.UnavailableGuild{}` (just an `id`, plus an `unavailable`
flag) — because at `READY` time the guild objects have not been sent yet. The
full `%Dexcord.Guild{}` for each arrives in a subsequent `GUILD_CREATE`, which
Dexcord's dispatcher folds into the cache **before** your handler sees it.
So: do READY-time bootstrapping that only needs ids (locks, per-channel history
backfill by channel id) in the `READY` clause; do anything that needs full guild
state off `GUILD_CREATE` (or just read it from `Dexcord.Cache` on demand).
**Before** (Nostrum: `msg` is a struct-ish payload, ids integers already but the
guild list shape is Nostrum's):
```elixir
def handle_event({:READY, msg, _ws_state}) do
Logger.info("READY! #{inspect(msg)}")
Reminder.Scheduler.lock()
backfill_channels()
Reminder.Scheduler.unlock()
end
```
**After** (2-tuple; typed struct; stub guilds):
```elixir
def handle_event({:READY, %Dexcord.Events.Ready{user: me, guilds: guilds}}) do
Logger.info("READY as #{me.username} (#{me.id}); #{length(guilds)} guild stub(s)")
Reminder.Scheduler.lock()
backfill_channels()
Reminder.Scheduler.unlock()
end
# Full guild objects land here (cache is already populated when this runs).
def handle_event({:GUILD_CREATE, %Dexcord.Guild{} = guild}) do
Logger.debug("GUILD_CREATE #{guild.name} (#{guild.id}) fully cached")
:ok
end
```
Alamedya has no dedicated `GUILD_CREATE` logic — the `use Dexcord.Handler`
catch-all absorbs it, and the dispatcher still caches guilds/threads first. You
only need a `GUILD_CREATE` clause if you want to *react* to it.
---
## 3. MESSAGE_CREATE flow
The core loop. `MESSAGE_CREATE` arrives as `%Dexcord.Message{}` with typed
fields: `msg.content` (string), `msg.author` (a `%Dexcord.User{}`),
`msg.author.bot` (boolean), `msg.channel_id`/`msg.author.id` (**integers**),
`msg.mentions` (a list of `%Dexcord.User{}`), and `msg.webhook_id` (an integer
when the message came from a webhook — in which case `msg.author` is a synthetic
webhook user, so a `webhook_id`-first guard is the clean way to skip those).
Replying is a one-liner: `Dexcord.Message.reply(msg, "…")` sets
`message_reference` to the source message and routes through the same send funnel
(so your `allowed_mentions` default from §1 applies). `mention_author: false`
suppresses the reply ping.
The handler module below is the canonical shape. **It is mirrored verbatim as a
compiled test module in `test/dexcord/migration_guide_samples_test.exs` — keep
the two in sync.**
**Before** (raw maps, manual `String.to_integer`, `get_in`):
```elixir
def handle_event({:MESSAGE_CREATE, msg, _ws_state}) do
author_id = String.to_integer(msg["author"]["id"])
is_bot = get_in(msg, ["author", "bot"]) == true
cond do
is_bot -> :ignore
author_id == @self_id -> :ignore
msg["content"] == "ping!" ->
Nostrum.Api.create_message!(msg["channel_id"], "helo")
true -> :ignore
end
end
```
**After** (typed struct routing; verbatim shared sample):
```elixir
defmodule AlamedyaDiscord.Handler do
# KEEP IN SYNC: mirrored verbatim in
# test/dexcord/migration_guide_samples_test.exs (§3 of docs/alamedya-migration-v2.md).
use Dexcord.Handler
@self_id 1_135_637_126_222_987_365
# Webhook messages carry a `webhook_id` and a synthetic author — skip them
# first, before touching `author.bot`.
def handle_event({:MESSAGE_CREATE, %Dexcord.Message{webhook_id: id}}) when not is_nil(id),
do: :ignore
# Any bot (including ourselves) — never react.
def handle_event({:MESSAGE_CREATE, %Dexcord.Message{author: %Dexcord.User{bot: true}}}),
do: :ignore
def handle_event({:MESSAGE_CREATE, %Dexcord.Message{author: %Dexcord.User{id: @self_id}}}),
do: :ignore
def handle_event({:MESSAGE_CREATE, %Dexcord.Message{} = msg}) do
cond do
mentions_self?(msg) -> Dexcord.Message.reply(msg, "you rang?")
msg.content == "ping!" -> Dexcord.Message.reply(msg, "helo")
true -> :ignore
end
end
defp mentions_self?(%Dexcord.Message{mentions: mentions}),
do: Enum.any?(mentions, fn %Dexcord.User{id: id} -> id == @self_id end)
end
```
Note there is no `String.to_integer`, no `get_in`, and no map indexing anywhere:
the struct is already typed, and `@self_id` (an integer literal) compares
directly against `msg.author.id` (an integer). This is the whole point of the
typed contract.
---
## 4. Thread cache reads
Alamedya reads guild threads from cache to decide routing. Dexcord caches
threads as `%Dexcord.Thread{}` structs and exposes them per-guild via
`Dexcord.Cache.threads/1` (a **list**, not Nostrum's threads map). Thread fields
are typed: `thread.parent_id` (integer), `thread.thread_metadata` (a
`%Dexcord.ThreadMetadata{}` with `.archived`, `.locked`, …). `Dexcord.Cache.channel/1`
returns the concrete per-type struct (`%Dexcord.TextChannel{}`,
`%Dexcord.Thread{}`, …), so you can pattern-match the channel type directly.
**Before** (Nostrum GuildCache threads map, string compare):
```elixir
maybe_thread =
Nostrum.Cache.GuildCache.get!(guild_id).threads
|> Map.values()
|> Enum.find(fn t -> t.id == channel_id end)
archived? = maybe_thread && maybe_thread.thread_metadata.archived
```
**After** (typed list from the cache, integer compare, typed nested metadata):
```elixir
maybe_thread =
Dexcord.Cache.threads(msg.guild_id)
|> Enum.find(fn %Dexcord.Thread{} = t -> t.id == msg.channel_id end)
archived? =
case maybe_thread do
%Dexcord.Thread{thread_metadata: %Dexcord.ThreadMetadata{archived: a}} -> a
_ -> false
end
# parent channel of the thread, if we want it:
parent =
with %Dexcord.Thread{parent_id: pid} <- maybe_thread,
{:ok, channel} <- Dexcord.Cache.channel(pid),
do: channel, else: (_ -> nil)
```
Creating a thread and posting into it stays a straight REST pair, now returning a
typed channel:
```elixir
{:ok, %Dexcord.Thread{} = thread} =
Dexcord.Api.start_thread_with_message(msg.channel_id, msg.id, "request")
Dexcord.Api.create_message(thread.id, "helo from the new thread")
```
---
## 5. Integer snowflakes
The old draft's `String.to_integer(msg["author"]["id"])` dance is **gone**. Every
id on a decoded struct is already an `integer`. That means:
- Comparisons against integer constants (`@self_id`, a config-mapped channel id)
just work — no coercion.
- Interpolating an id into a string (`"member #{msg.author.id}"`) just works.
- An `:integer` Ecto column (`discord_message_id`) takes `msg.id` directly.
The **only** place you convert is the app boundary — an id that arrives as a
string from *outside* Discord (an env var, a DB row, a web request). Use
`Dexcord.Snowflake.cast/1` there, exactly once:
**Before** (coerce on every access, everywhere):
```elixir
channel_id = String.to_integer(msg["channel_id"])
mapped = Application.get_env(:alamedya, :reminders)[:discord_mapping] # integer values
if channel_id == mapped[:mins_30], do: ...
```
**After** (struct id is already an integer; cast only the external config value):
```elixir
# discord_mapping values come from env/config as strings — normalize once, at load:
mapping =
:alamedya
|> Application.get_env(:reminders)
|> Keyword.fetch!(:discord_mapping)
|> Map.new(fn {bucket, raw_id} -> {bucket, Dexcord.Snowflake.cast!(raw_id)} end)
# thereafter compare directly — both integers:
if msg.channel_id == mapping[:mins_30], do: Reminder.add(:mins_30, msg)
```
`Dexcord.Snowflake.cast/1` returns `{:ok, integer} | :error`; `cast!/1` raises on
a non-snowflake. It accepts an integer (passthrough), a decimal string, or a
struct carrying an `:id`, so it is safe to call on "whatever id you have."
---
## 6. Slash commands
Alamedya doesn't use slash commands today, but the typed contract makes them
cheap enough to add, so here's the shape. Interactions arrive as
`%Dexcord.Interaction{}` with a typed, polymorphic `data` field: for an
application command it's a `%Dexcord.Interaction.ApplicationCommandData{}` whose
`.name` is the command name and whose `.options` are typed. `interaction.token`
and `interaction.id` are what the response helpers need; resolved-data maps are
keyed by **integer** ids.
A `Dexcord.Slash` module declares `commands/0` and handles routed interactions;
`Dexcord.Slash.respond/2` (unchanged call shape) sends the immediate response.
```elixir
defmodule AlamedyaDiscord.Slash do
use Dexcord.Slash
def commands, do: [%{name: "ping", description: "Replies with pong."}]
# `name` is the command name; `itx` is the full %Dexcord.Interaction{}.
def handle_interaction("ping", itx) do
Dexcord.Slash.respond(itx, "pong")
end
end
```
Wire it up with `slash: AlamedyaDiscord.Slash` (and, in dev,
`slash_guild_ids: [dev_guild_id]` for instant registration) in the child spec
from §1. `respond/2` takes a binary (used as `content`) or a map
(`%{content: "…", ephemeral: true}`); `respond_later/1`, `followup/2`, and
`edit_response/2` cover the deferred flow.
---
## 7. REST calls
`Dexcord.Api` typed endpoints return **typed structs** on success and the
unchanged `{:error, %Dexcord.Api.Error{}}` on failure:
```elixir
{:ok, %Dexcord.Message{} = sent} = Dexcord.Api.create_message(channel_id, "hi")
{:ok, %Dexcord.User{} = me} = Dexcord.Api.get_current_user()
{:error, %Dexcord.Api.Error{status: 403}} = Dexcord.Api.get_channel(forbidden_id)
```
For anything without a typed wrapper, `Dexcord.Api.request/4` is the escape hatch
(string-keyed request/response maps):
```elixir
Dexcord.Api.request(:patch, "/guilds/#{guild_id}", %{"name" => "New Name"})
```
**Hand-rolled pagination loops become streams.** The old draft read channel
history with an explicit `get_channel_messages(id, 50, {:after, cursor})` loop.
Dexcord ships lazy streams that page for you and only fetch as far as you consume:
**Before** (manual cursor loop):
```elixir
msgs =
Nostrum.Api.get_channel_messages!(channel_id, 50, {:after, last_id})
|> Enum.reverse()
```
**After** (lazy stream; `after:` flips to oldest→newest, `limit:` caps the total):
```elixir
msgs =
Dexcord.Api.message_history(channel_id, after: last_id, limit: 50)
|> Enum.to_list()
# each element is a %Dexcord.Message{}; take/2 stops fetching once satisfied.
```
`message_history/2`, `guild_members_stream/2`, `guild_bans_stream/2`, and
`audit_log_stream/2` are all lazy `Stream`s. The Nostrum-compatible
`Dexcord.Api.get_channel_messages/3` locator arity
(`get_channel_messages(id, limit, {:after, cursor})`) still exists if you want a
single explicit page instead of a stream.
`Dexcord.Api.send/2` is the ergonomic front door over `create_message`: it
accepts anything `Dexcord.Messageable` — a channel struct, a `%Dexcord.Thread{}`,
a `%Dexcord.Message{}` (posts to its channel), a `%Dexcord.User{}`/`%Dexcord.Member{}`
(opens and caches a DM lazily), or a bare integer channel id — and applies the
`allowed_mentions` default from §1.
---
## 8. Hydration
Envelope events (reactions, message deletes) carry only **ids**, not the related
objects — a `%Dexcord.Events.ReactionAdd{}` has `user_id`, `channel_id`,
`guild_id` but leaves `user`, `channel`, `guild` as `nil`. `Dexcord.Cache.fill/1`
best-effort fills those slots from the cache (ETS only — no HTTP, safe on the hot
path). A cache miss leaves that slot `nil`; already-filled slots are untouched
(idempotent).
**Before** (Nostrum: look each id up in a separate cache module by hand):
```elixir
def handle_event({:MESSAGE_REACTION_ADD, reaction, _ws_state}) do
user = Nostrum.Cache.UserCache.get!(reaction.user_id)
channel = Nostrum.Cache.ChannelCache.get!(reaction.channel_id)
handle_reaction(reaction, user, channel)
end
```
**After** (one `fill/1` call hydrates every declared slot):
```elixir
def handle_event({:MESSAGE_REACTION_ADD, %Dexcord.Events.ReactionAdd{} = reaction}) do
reaction = Dexcord.Cache.fill(reaction)
# reaction.user :: %Dexcord.User{} | nil, reaction.channel :: channel struct | nil,
# reaction.guild :: %Dexcord.Guild{} | nil — each nil on a cache miss.
handle_reaction(reaction)
end
```
Because `fill/1` never blocks on the network, treat a `nil` slot as "not cached
right now" and fall back to a typed REST call (`Dexcord.Api.get_user/1`, …) only
when you actually need that object.
---
## Verification
From the Alamedya checkout after the migration:
1. **Clean compile with Nostrum gone** — proves no lingering `Nostrum.*`
references or struct matches, and no leftover `String.to_integer` on ids that
are now integers:
```sh
mix compile --warnings-as-errors
grep -rn "Nostrum" lib/ config/ # expect zero hits
```
2. **Tests** (if the app has any touching this code): `mix test`.
3. **End-to-end — the real acceptance test** (the failure the whole migration
retires): start Alamedya with `DISCORD_TOKEN` set and **no** discord.py
running. Confirm it connects via Dexcord's own gateway (a `READY` log), post a
message in a mapped channel (a reminder persists), @-mention the bot (it opens
a `"request"` thread and replies), then **suspend the machine / drop the
network, wait, and wake it** → confirm events resume via a RESUME, repeatedly.
That last step is the exact Nostrum failure the bridge was working around;
Dexcord must handle it natively.
## Rollback
The migration is a single branch. If E2E fails, `git checkout` back, restore the
`config :nostrum` block and the bridge, and re-run the discord.py proxy. Nothing
in the DB schema changes, so there is no data migration to reverse.
</content>
</invoke>

View file

@ -0,0 +1,388 @@
# Dexcord Full API Surface Design
## Summary
Dexcord's current API surface is a thin, map-based wrapper: REST calls return raw string-keyed maps, gateway events arrive as maps, and callers do their own field access and type coercion. This design replaces that contract end-to-end with generated structs. Three compile-time DSLs (`Dexcord.Struct` for object shapes, `Dexcord.Enum` for open integer enums, `Dexcord.Flags` for bitfields) let each of the ~60 Discord object types and ~180 REST endpoints be declared once — as a field list or a route line — and have the struct definition, type spec, decoder, encoder, and (for endpoints) the callable function itself generated from that single declaration. This keeps the "one source of truth per shape" property that hand-written triple declarations (struct + type + decoder, written separately) tend to lose over time, which is the specific failure mode being designed away from Nostrum's approach.
The rest of the design follows from making that decode step happen exactly once, in one place: gateway JSON is turned into typed structs in the dispatcher before it ever reaches a handler or the cache, and REST responses are decoded the same way before being returned to the caller. Decoding is built to degrade rather than crash — unrecognized fields, enum values, and events fall back to sensible defaults or raw passthrough instead of raising, so a single malformed payload can't take down the dispatch loop. On top of the typed core sits a discord.py-flavored ergonomics layer (protocol-based message sending, reply helpers, mention rendering, an embed builder) intended to make the typed API pleasant to use rather than just type-safe. Work is sequenced across seven phases — DSLs first, then models, then events, then the REST layer and its coverage tooling, then wiring it all into the live gateway/cache/slash-command paths, finishing with the ergonomics layer and a migration guide.
## Definition of Done
1. **Typed REST surface**: `Dexcord.Api` covers all mainstream v10 bot endpoint groups (channels, messages, guilds, members, roles, reactions, threads, webhooks, invites, interactions, app commands, emojis, stickers, polls, auto-mod, scheduled events, audit logs) via a minimal-boilerplate endpoint-definition layer (macros or similar). Monetization, soundboard, stage instances, and templates stay on `request/4`, which remains public as the escape hatch.
2. **Deep structs**: real structs with `Dexcord.Snowflake` IDs everywhere — API returns, gateway event payloads handed to `handle_event`, and cache reads. No back-compat with the map contract.
3. **discord.py-flavored ergonomics**: struct-centric helper functions on model modules (`Dexcord.Message.reply/2`-style), accepting structs or snowflakes where sensible.
4. **Tests**: machinery tested hard (endpoint macro layer, decode engine, Snowflake, partial/null/unknown-field edge cases) + representative per-group fake-server round-trips; suite stays green and hermetic.
5. **New alamedya migration guide**: a fresh guide (not an update of the existing internal `alamedya-migration.md`, which remains uncommitted) written against the new struct contract.
## Acceptance Criteria
### api-surface.AC1: Typed REST surface
- **api-surface.AC1.1 Success:** A DSL-declared endpoint generates a function that issues the correct method + interpolated path (FakeRest wire assertion) and returns `{:ok, decoded_struct}`
- **api-surface.AC1.2 Success:** `query:` params are URL-encoded when provided, omitted entirely when not
- **api-surface.AC1.3 Success:** `reason: true` endpoints send `X-Audit-Log-Reason` (URL-encoded) when `:reason` is passed, and no header otherwise
- **api-surface.AC1.4 Success:** `files: true` endpoints encode multipart with `payload_json` + `files[n]` parts referenced by attachment `id`
- **api-surface.AC1.5 Success:** Snowflake arguments accept integer, decimal string, or struct-with-`.id`, all producing the identical request path
- **api-surface.AC1.6 Failure:** A 4xx/5xx response decodes to `{:error, %Dexcord.Api.Error{}}` with status/code/message intact
- **api-surface.AC1.7 Success:** `Dexcord.Api.request/4` remains public with its current raw-map contract
- **api-surface.AC1.8 Success:** `mix dexcord.coverage` reports zero missing in-scope endpoints against the pinned spec
- **api-surface.AC1.9 Failure:** Removing a route-table entry makes `mix dexcord.coverage` exit non-zero
- **api-surface.AC1.10 Success:** New route shapes get bounded bucket keys (invite codes collapse; no raw tokens in ETS)
### api-surface.AC2: Deep structs & decode engine
- **api-surface.AC2.1 Success:** `from_map/1` decodes declared fields including nested structs and lists
- **api-surface.AC2.2 Success:** Unknown JSON keys are ignored; decoding mints zero atoms from wire data
- **api-surface.AC2.3 Failure:** Wrong-shaped field values (list where map expected, junk scalars) decode to `nil`/default/raw-value — never raise
- **api-surface.AC2.4 Success:** Unknown enum int → `{:unknown, n}`; `encode(decode(n))` is lossless for known and unknown values
- **api-surface.AC2.5 Success:** Flags fields keep the raw integer; `has?/2`, `to_list/1`, `from_list/1` correct; unknown bits survive round-trip in the int
- **api-surface.AC2.6 Success:** `tristate:` fields decode to `:absent | nil | value`
- **api-surface.AC2.7 Success:** `:presence` fields decode key-presence to boolean (present-with-null → `true`)
- **api-surface.AC2.8 Success:** `merge_map/2` updates only keys present in the partial map
- **api-surface.AC2.9 Success:** `to_map/1`/body encoding: snowflakes → strings, enums → ints, `DateTime` → ISO8601; absent keyword key = omitted field, `nil` = JSON null
- **api-surface.AC2.10 Success:** Golden GUILD_CREATE decodes with typed channels/roles/members/threads
- **api-surface.AC2.11 Success:** MESSAGE_CREATE's member-without-user → `%PartialMember{}`, author a full `%User{}`
- **api-surface.AC2.12 Success:** Webhook-authored message decodes (synthetic author, no member)
- **api-surface.AC2.13 Success:** `referenced_message`: absent → `:absent`, null → `nil`, present → `%Message{}`
- **api-surface.AC2.14 Success:** Channel factory maps all 15 documented types to their structs; unknown type → `%UnknownChannel{raw: map}`
- **api-surface.AC2.15 Success:** INTERACTION_CREATE decodes in both guild (member + permissions) and DM (user) variants
- **api-surface.AC2.16 Success:** Resolved interaction data decodes to partial structs (member w/o user, 4-field channel)
- **api-surface.AC2.17 Success:** Every atom in `EventNames.all/0` has decode coverage — full object, envelope struct, or documented passthrough (test iterates the list)
- **api-surface.AC2.18 Success:** Handler receives `{atom, typed_payload}`; unknown events arrive `{{:unknown_event, name}, raw_map}`
- **api-surface.AC2.19 Failure:** A malformed event payload logs, falls back to raw delivery, and dispatch of subsequent events continues
- **api-surface.AC2.20 Success:** Cache stores and returns structs keyed by integer snowflakes
- **api-surface.AC2.21 Success:** GUILD_UPDATE merge preserves gateway-only fields (`joined_at` survives)
- **api-surface.AC2.22 Success:** GUILD_DELETE cascade purge works against struct-valued tables
### api-surface.AC3: Ergonomics
- **api-surface.AC3.1 Success:** `Api.send/2` accepts channel structs, threads, and bare snowflakes via `Messageable`, hitting the right channel route
- **api-surface.AC3.2 Success:** `Api.send/2` to `%User{}`/`%Member{}` creates the DM once, then reuses the cached DM channel (second send makes no create-DM call)
- **api-surface.AC3.3 Success:** `Message.reply/2` sets `message_reference` to the source message; `mention_author:` flows into allowed_mentions
- **api-surface.AC3.4 Success:** `mention/1` + `String.Chars` render `<@id>`/`<#id>`/`<@&id>`; `PartialEmoji.to_string/1` renders send format
- **api-surface.AC3.5 Success:** Config-level `allowed_mentions` default applies to sends; per-send values merge field-wise over it
- **api-surface.AC3.6 Success:** `Embed` builder chain produces a valid wire map
- **api-surface.AC3.7 Success:** Pagination streams lazily page across multiple FakeRest hits
- **api-surface.AC3.8 Success:** `Cache.fill/1` hydrates declared slots from cache; cache miss leaves slot `nil`; never issues HTTP
### api-surface.AC4: Suite health
- **api-surface.AC4.1 Success:** Full suite green and hermetic (no external network) throughout
- **api-surface.AC4.2 Success:** DSL machinery (Struct/Enum/Flags/endpoint) each has a dedicated exhaustive test file
- **api-surface.AC4.3 Success:** `mix dexcord.coverage` runs in CI and gates regressions
### api-surface.AC5: Migration guide
- **api-surface.AC5.1 Success:** `docs/alamedya-migration-v2.md` exists, covers every Discord touchpoint in alamedya (READY backfill, MESSAGE_CREATE flow, thread cache reads, integer snowflakes vs the old string dance), and its code samples are valid against the final API
## Glossary
- **Discord Gateway**: Discord's persistent WebSocket connection that pushes real-time events (message created, member joined, etc.) to a bot, as opposed to the REST API which is request/response.
- **Snowflake**: Discord's ID format — a 64-bit integer that encodes a creation timestamp, making IDs sortable by time. This design narrows Dexcord's representation from "integer or string" to integer-only.
- **Struct** (Elixir): A compile-time-checked, fixed-field data record built on top of Elixir maps. `Dexcord.Struct` here is a DSL that generates these (plus their type spec and (de)serialization functions) from one field declaration.
- **DSL (domain-specific language)**: A small, purpose-built syntax (implemented via Elixir macros) for declaring something — here, object shapes (`Dexcord.Struct`), enums (`Dexcord.Enum`), bitfields (`Dexcord.Flags`), and REST routes (the endpoint DSL) — that expands into ordinary code at compile time.
- **Atom / atomization**: Elixir atoms are fixed constants stored in a global, never-garbage-collected table; converting untrusted external strings into atoms can exhaust that table and crash the VM. "Zero atoms minted from wire data" means decoding matches on string keys directly rather than converting them to atoms.
- **Open integer enum**: An enum that must tolerate values Discord adds later. `Dexcord.Enum` decodes recognized ints to atoms and unrecognized ones to `{:unknown, n}`, and can re-encode either losslessly.
- **Bitfield / Flags**: An integer where each bit is an independent boolean (e.g., Discord permissions). `Dexcord.Flags` keeps the raw integer (so unknown bits aren't lost) while offering helpers to test/list/build named flags.
- **Tristate field**: A field with three meaningfully different states — absent from the payload, explicitly `null`, or a real value — rather than the usual two (present or `nil`). Used where "not present" and "present but null" mean different things (e.g., a message that isn't a reply vs. a reply whose original was deleted).
- **Presence field**: A field where only *whether the key exists* in the JSON matters, not its value — key present (even if null) decodes to `true`.
- **Hydration slot / `hydrate`**: A declared field on a gateway "envelope" struct (e.g., just a `user_id`) that can be optionally filled in later from the cache (e.g., the full `User`) via `Cache.fill/1`, without ever making a network call.
- **Envelope struct**: A per-event struct used for gateway events that don't carry a full Discord object (e.g., `MESSAGE_DELETE` only carries IDs), as opposed to "full-object" events like `MESSAGE_CREATE` that decode directly to the model struct.
- **Partial struct**: A distinct struct for a shape Discord documents as genuinely different from (usually a subset of) the full object — e.g., a guild member payload that lacks the nested user — used only where the shape truly differs, not merely where a field is sometimes missing.
- **Messageable (protocol)**: An Elixir protocol implemented by every "thing you can send a message to" (channels, threads, users, messages, raw IDs) so `Api.send/2` can resolve any of them to the right destination and route.
- **discord.py**: A popular Python Discord bot library whose API taste (naming, `get`/`fetch` conventions, ergonomics) this design explicitly imitates.
- **Nostrum**: An existing Elixir Discord library whose design choices (integer snowflakes, lenient casting, typed lists) are partly kept and partly deliberately rejected (its atom-minting and field-drift problems).
- **ETS (Erlang Term Storage)**: The in-memory key-value store the cache is built on; tables here are `:public` with `read_concurrency: true` so many processes can read concurrently while one process writes.
- **Single-writer cache**: An architecture where only the dispatcher process ever writes to the cache (in gateway event order), while any process can read — avoiding write races without needing locks.
- **Dispatcher**: The process that receives decoded gateway events, writes them to cache, and hands them off to handler code; the design's decode step happens here, once, before anything else sees the event.
- **Ratelimit bucket / `route_key`**: Discord rate-limits are tracked per "bucket," derived from a request's route with variable segments (IDs, tokens) collapsed so buckets don't multiply unboundedly or leak sensitive values (like invite codes) into ETS keys.
- **Multipart / `payload_json`**: The HTTP encoding Discord requires for endpoints that accept file uploads alongside JSON — the JSON body goes in a `payload_json` part, files in separate numbered parts.
- **`X-Audit-Log-Reason` header**: An HTTP header Discord uses to attach a human-readable reason to moderation actions in the guild's audit log.
- **OpenAPI spec (`discord-api-spec`)**: Discord's official, auto-generated machine-readable description of its REST API (published as a "public preview"), used here only as a checklist to verify endpoint coverage, not as codegen input.
- **`mix` task**: A command-line task in Elixir's build tool (`mix`); `mix dexcord.coverage` is a custom one that checks the declared routes against the pinned spec.
- **Golden fixture**: A checked-in, real (or realistic) sample payload used as a stable, exact-match test input — e.g., a captured `GUILD_CREATE` payload used to verify decoding.
- **FakeRest / FakeGateway**: This codebase's existing test doubles that simulate Discord's REST API and gateway connection so tests run hermetically, without real network calls.
- **`persistent_term`**: An Erlang/OTP storage mechanism for rarely-changing global data with very fast reads, used here to hold validated bot configuration.
- **Behaviour + `@before_compile`**: An Elixir pattern where a module declares a contract (`behaviour`) that implementers must satisfy, combined with a compile-time hook (`@before_compile`) that injects fallback/catch-all code into the implementing module.
- **Keyword list**: An Elixir list of `{atom, value}` pairs (written `key: value, ...`), Elixir's idiomatic way of passing optional named arguments — used here for endpoint bodies, where an omitted key means "don't send this field" versus an explicit `nil`.
- **Components V2**: A newer generation of Discord's interactive message-component system (buttons, selects, etc.) alongside the original component set.
## Architecture
### Overview
Three compile-time DSLs generate everything from single declarations; a decode pipeline converts raw gateway/REST JSON (string-keyed maps from the built-in `JSON` module) into structs exactly once per event; the REST surface is declared as a route table that expands into typed functions. Discord's official OpenAPI spec (`discord/discord-api-spec`) is **not** codegen input — it is a coverage checklist consumed by a dev-only mix task.
Prior art: discord.py sets the taste (model taxonomy, tolerance posture, get/fetch semantics, Messageable); Nostrum's casting vocabulary is kept (`{:struct, M}`/`{:list, T}` type specs, nil passthrough, lenient scalar fallback, integer snowflakes) while its regretted mechanics are dropped (global payload atomization, triple-declared fields, raw-int enums).
### The three DSLs
**`Dexcord.Struct`** (`lib/dexcord/struct.ex`) — a `field` declaration DSL. One declaration per field generates, at compile time:
- `defstruct` with defaults
- `@type t` (generated from the same field table — cannot drift)
- `from_map/1` — decodes a string-keyed map; **matches string keys directly** (no atomization pass, zero atoms minted from wire data)
- `to_map/1` — encodes back to a JSON-ready map (snowflakes → strings, enums → ints, flags → int, `DateTime` → ISO8601)
- `merge_map/2``(struct, raw_partial_map) → struct`, updating **only keys present in the map** (correct partial-update semantics for the cache)
- `fill/2` hydration support for declared `hydrate` slots (see Cache integration)
Field types: `:snowflake`, `:string`, `:integer`, `:boolean`, `:datetime` (ISO8601 → `DateTime.t()`), `{:struct, Module}`, `{:list, type}`, `{:enum, Module}`, `{:flags, Module}`, plus modifiers `default:`, `tristate: true` (decodes to `:absent | nil | value`; for `Message.referenced_message` absent = not a reply, null = deleted referent), and `:presence` (key-presence boolean; for `RoleTags.premium_subscriber` where present-null means true).
```elixir
defmodule Dexcord.Message do
use Dexcord.Struct
discord_struct do
field :id, :snowflake
field :channel_id, :snowflake
field :author, {:struct, Dexcord.User}
field :content, :string
field :timestamp, :datetime
field :embeds, {:list, {:struct, Dexcord.Embed}}, default: []
field :type, {:enum, Dexcord.MessageType}
field :flags, {:flags, Dexcord.MessageFlags}
field :referenced_message, {:struct, __MODULE__}, tristate: true
end
end
```
**`Dexcord.Enum`** (`lib/dexcord/enum.ex`) — open integer enums. One table generates `@type t :: :atom | ... | {:unknown, integer()}`, `decode/1` (unknown int → `{:unknown, n}`), `encode/1` (lossless round-trip, `{:unknown, n}``n`).
**`Dexcord.Flags`** (`lib/dexcord/flags.ex`) — bitfields. Struct fields keep the **raw integer** (lossless, forward-compatible); the module generates `has?/2`, `to_list/1` (unknown bits omitted from the list only, never from the int), `from_list/1`, `all/0`. Used for `Dexcord.Permissions`, `Dexcord.MessageFlags`, and friends; `Dexcord.Intents` remains as-is.
### Decode semantics (the tolerance contract)
Decode must never crash the dispatcher:
- Generated `from_map/1` guards shapes (`when is_map/is_list`); surprises fall through to `nil`/default; failed scalar casts keep the raw value.
- Unknown JSON keys are never inspected — forward compatible by construction.
- Unknown enum int → `{:unknown, n}`. Unknown channel `type``%Dexcord.UnknownChannel{type: {:unknown, n}, raw: map}`. Unknown gateway event → `{{:unknown_event, name}, raw_map}` (unchanged from today).
- Absent and JSON-null both decode to `nil`, except fields explicitly declared `tristate:` or `:presence`.
- Encode direction: request bodies come from keyword opts; **absent key = omit field, `nil` value = JSON null** — the PATCH tri-state maps onto keyword-list key presence with no sentinel.
### Snowflake (breaking rework)
`Dexcord.Snowflake.t()` becomes `0..0xFFFF_FFFF_FFFF_FFFF` — a plain integer (Nostrum-proven, discord.py-identical). Module: `cast/1`, `cast!/1`, `dump/1` (→ string for wire), `defguard is_snowflake/1`, existing `from_datetime/1`, `to_datetime/1`, `to_unix/1` (the missing `:error` path in `to_unix/1` gets fixed). Wire strings normalize to int once, at decode. Ids sort chronologically, interpolate, and pattern-match naturally.
### Models
~60 model modules under `lib/dexcord/model/`, **flat module names** (`Dexcord.Message`, `Dexcord.User`, `Dexcord.Guild`, `Dexcord.TextChannel` — directory ≠ namespace, deliberately).
**Channels are distinct structs** chosen by a factory (`lib/dexcord/model/channel.ex`, `Dexcord.Channel.from_map/1` dispatching on `"type"`): `TextChannel`, `VoiceChannel`, `CategoryChannel`, `AnnouncementChannel`, `ForumChannel`, `MediaChannel`, `StageChannel`, `DMChannel`, `GroupDMChannel`, `Thread` (types 10/11/12), `UnknownChannel` (fallback). Shared fields come from a common declaration helper; capability is protocol membership (`CategoryChannel` is not `Messageable` — wrong sends fail at match time).
**Partials are first-class** where Discord documents a genuinely distinct shape (the rule: different provenance → different struct; same shape with an occasionally-missing field → `nil`):
- `Dexcord.PartialEmoji` (reactions/components/poll media; `to_string/1` gives send format)
- `Dexcord.PartialMember` (gateway member-without-user; interaction resolved members)
- `Dexcord.PartialChannel` (interaction resolved channels)
- `Dexcord.PartialGuild` (user-guilds list), `Dexcord.UnavailableGuild` (READY stubs)
- `Dexcord.MessageSnapshot` (forwards)
No `PartialMessage`/`Object` equivalents — the id-accepting API layer makes them unnecessary.
### Gateway events
`Dexcord.Events` (`lib/dexcord/events.ex` + `lib/dexcord/events/*.ex`): a generated table maps event atom → payload type. Full-object events decode to the model struct (`{:MESSAGE_CREATE, %Dexcord.Message{}}`); envelope events get per-event structs (`{:MESSAGE_DELETE, %Dexcord.Events.MessageDelete{}}`, `{:MESSAGE_REACTION_ADD, %Dexcord.Events.ReactionAdd{}}`, …) so the handler always receives exactly what the gateway sent, typed. Handler contract: `{event_atom, typed_payload}` or `{{:unknown_event, name}, raw_map}` — the 2-tuple shape is unchanged.
Envelope structs declare **hydration slots**:
```elixir
discord_struct do
field :user_id, :snowflake
field :channel_id, :snowflake
field :guild_id, :snowflake
field :emoji, {:struct, Dexcord.PartialEmoji}
hydrate :user, from: :user_id, type: Dexcord.User
hydrate :channel, from: :channel_id, type: :channel
hydrate :guild, from: :guild_id, type: Dexcord.Guild
end
```
`Dexcord.Cache.fill/1` populates the slots from cache — best-effort (miss → slot stays `nil`), never blocks, never issues HTTP. Hydration is invoked only by user handler code, never by the dispatcher.
### REST surface
**Endpoint DSL** (`lib/dexcord/api/endpoint.ex`): ~12 group modules (`lib/dexcord/api/channels.ex`, `messages.ex`, `guilds.ex`, `members.ex`, `roles.ex`, `webhooks.ex`, `invites.ex`, `interactions.ex`, `commands.ex`, `emojis_stickers.ex`, `moderation.ex`, `misc.ex` — exact grouping settled in implementation planning) declare routes:
```elixir
endpoint :create_message, :post, "/channels/:channel_id/messages",
body: true, files: true, returns: Dexcord.Message
endpoint :get_guild_audit_log, :get, "/guilds/:guild_id/audit-logs",
query: [:user_id, :action_type, :before, :after, :limit],
returns: Dexcord.AuditLog
endpoint :delete_role, :delete, "/guilds/:guild_id/roles/:role_id",
reason: true, returns: nil
```
Each expands to a function with `@spec`, `@doc` (method + path + docs link), path interpolation (URL-encoded segments), query building, body encoding, response decoding into `returns:`. Conventions:
- Coverage target: **~180 endpoints** across the in-scope groups (channels, messages/reactions/pins, guilds/members/roles/bans, users, webhooks, invites, interaction responses/followups, application commands, emojis, stickers, polls, auto-moderation, scheduled events, audit logs, gateway/application info). Excluded (escape hatch): monetization, soundboard, stage instances, guild templates, deprecated pin routes, Bearer-only endpoints.
- Return contract: `{:ok, struct | [struct] | nil}` | `{:error, %Dexcord.Api.Error{}}`. `request/4` stays public and map-based (the break-glass).
- `reason: true``:reason` opt → `X-Audit-Log-Reason` header (hand-flagged on the ~60 mutating endpoints; the OpenAPI spec doesn't model reason headers).
- `files: true` → multipart `payload_json` + `files[n]` encoding — new machinery alongside `request/4` in `lib/dexcord/api.ex`.
- Snowflake arguments accept int, decimal string, or a struct with an `.id` field. Bodies accept keyword lists, maps, or structs (`to_map/1`).
- Naming: Discord docs names, except "Modify X" → `edit_x`.
- Sugar arities (e.g. `create_message(channel_id, "text")`) are hand-written over the generated base.
- `Dexcord.Api` (`lib/dexcord/api.ex`) remains the flat front door: generated `defdelegate`s to the group modules + `request/4`.
**Ratelimit**: `Dexcord.Api.Ratelimit.route_key/2` (`lib/dexcord/api/ratelimit.ex`) gains `templatize` clauses for new route shapes — invite codes (non-snowflake segment must collapse), `sticker-packs`, application-emoji routes — so buckets neither over-share nor grow unbounded ETS keys. Message-delete's separate bucket family and webhook/interaction token digests already exist and are kept.
**Coverage tooling**: `mix dexcord.coverage` diffs the compiled route table against a checked-in snapshot of `discord-api-spec`'s `openapi.json` (`priv/discord_openapi.json` + a refresh task), reporting endpoints in the spec but missing from Dexcord (excluded groups allowlisted). Runs in CI; fails on regression.
### Ergonomics layer
- `Dexcord.Messageable` protocol (`lib/dexcord/messageable.ex`): `resolve/1 → {:channel, id} | {:dm_user, id}`; implemented for TextChannel, VoiceChannel, Thread, DMChannel, User, Member, Message, Interaction, and bare snowflakes. One `Dexcord.Api.send/2` funnel; `{:dm_user, id}` performs the create-DM dance with the DM channel id cached.
- `Dexcord.Message.reply/2` (sets `message_reference`; `mention_author:` opt).
- `mention/1` on User/Member/Role/channel structs; `String.Chars` impls; `created_at/1` wherever there's an id; `Dexcord.Util.format_dt/2` (`<t:…:R>` markup).
- `Dexcord.Embed` pipe-friendly builder; `Dexcord.Permissions` (`has?/2`, `to_list/1`, `from_list/1`) plus `Dexcord.Guild.member_permissions/2` computing role + overwrite resolution.
- `allowed_mentions`: app-level default in `Dexcord.Config`, per-send field-wise merge, `mention_author:` sugar.
- Pagination: `Dexcord.Api.message_history/2` and friends (members, bans, audit log) return lazy `Stream`s that page transparently.
### Data flow
```
Gateway (JSON.decode!) [unchanged]
→ Dispatcher: Events.decode(name, raw) [decode ONCE]
→ Cache.handle_dispatch(name, decoded, raw) [structs stored; merge_map for partial updates]
→ handler Task: handle_event({name, decoded})
→ Slash.dispatch(%Interaction{}, module) [typed interactions]
```
Cache stores structs keyed by **integer snowflakes** (tables and single-writer discipline unchanged, `lib/dexcord/cache.ex`); GUILD_CREATE explodes typed children into the child tables; UPDATE events use `merge_map/2` so gateway-only fields (e.g. `joined_at`) survive partial updates. Reads return structs. A decode failure for one event logs, falls back to delivering the raw map, and never kills dispatch.
## Existing Patterns
Followed unchanged:
- **Single-writer cache via Dispatcher** (`lib/dexcord/dispatcher.ex:35-51`, `lib/dexcord/cache.ex`): cache writes stay inline in the dispatcher process, in gateway order; tables stay `:public`, `read_concurrency: true`, reads straight from ETS. Only the stored values (structs, not maps) and key types (integers, not strings) change.
- **Behaviour + `@before_compile` catch-all injection** (`Dexcord.Handler`, `Dexcord.Slash`, `Dexcord.Prefix.Router`): kept as-is; the new DSLs follow the same macro hygiene conventions.
- **Pure logic in standalone testable modules** (`Gateway.Payload`, `EventNames`, `Snowflake`, `Intents`): the DSLs, `Events` table, and endpoint expansion follow this.
- **Config**: validated bot config in `:persistent_term` via `Dexcord.Config`; tuning knobs via `Application.get_env` (preserves the EnvSandbox test pattern). `allowed_mentions` default joins the validated config.
- **Test harness** (`test/support/`): EnvSandbox + FakeRest + FakeGateway patterns are the template for all new tests; `api_integration_test.exs` is the model for endpoint round-trips.
- **`Dexcord.Api.Error`** stays the error struct; `request/4` keeps its exact contract.
- **Moduledoc style**: substantial `@moduledoc` with design rationale, `@spec` on all public functions, `@doc false` for internals.
Deliberate divergences:
- **The map contract is removed** at all four seams (handler events, cache values, Api returns, Slash interactions). This is the point of the design; no compatibility layer. v0.1's "minimal typed REST" locked decision is consciously revisited.
- **Macro DSLs are a new pattern** in this codebase. Justification: ~130 shapes × 3 hand-written declarations each is the alternative, and it is the approach Nostrum demonstrably regrets (field drift, atomization hedges).
- **`Dexcord.Snowflake.t()` narrows** from `integer() | String.t()` to integer-only. Justification: one canonical representation ends the string/int comparison bugs (alamedya's `String.to_integer` dance).
## Implementation Phases
<!-- START_PHASE_1 -->
### Phase 1: Foundations — DSLs and Snowflake
**Goal:** The three code-generating DSLs and the integer Snowflake exist, fully tested; everything later is declarations.
**Components:**
- `Dexcord.Struct` DSL in `lib/dexcord/struct.ex``discord_struct`/`field`/`hydrate` macros generating `defstruct`, `@type t`, `from_map/1`, `to_map/1`, `merge_map/2`; field types `:snowflake`, `:string`, `:integer`, `:boolean`, `:datetime`, `{:struct, M}`, `{:list, t}`, `{:enum, M}`, `{:flags, M}`; modifiers `default:`, `tristate:`, `:presence`
- `Dexcord.Enum` DSL in `lib/dexcord/enum.ex``decode/1`/`encode/1` with `{:unknown, n}` round-trip
- `Dexcord.Flags` DSL in `lib/dexcord/flags.ex` — raw-int storage, `has?/2`, `to_list/1`, `from_list/1`, `all/0`
- Reworked `Dexcord.Snowflake` in `lib/dexcord/snowflake.ex` — integer `t()`, `cast/1`, `cast!/1`, `dump/1`, `is_snowflake/1` guard, fixed `to_unix/1`
- Fixture modules + exhaustive DSL tests in `test/dexcord/struct_test.exs`, `enum_test.exs`, `flags_test.exs`, updated `snowflake_test.exs`
**Dependencies:** None.
**Done when:** DSL fixture modules compile with correct `defstruct`/`@type`; decode/encode/merge/tristate/presence/lenient-fallback behaviors all pass (`api-surface.AC2.1AC2.9`); suite green.
<!-- END_PHASE_1 -->
<!-- START_PHASE_2 -->
### Phase 2: Core models
**Goal:** The message-path object graph decodes from real payloads.
**Components:**
- Models in `lib/dexcord/model/`: `User`, `Member`, `PartialMember`, `Message`, `Attachment`, `Embed` (+ sub-objects), `Reaction`, `Emoji`, `PartialEmoji`, `Role` (+ `RoleTags` with presence-booleans), `Guild`, `Channel` factory + `TextChannel`/`VoiceChannel`/`CategoryChannel`/`AnnouncementChannel`/`ForumChannel`/`MediaChannel`/`StageChannel`/`DMChannel`/`GroupDMChannel`/`Thread`/`UnknownChannel`, `UnavailableGuild`, `PartialGuild`, `VoiceState`, `Presence`, `Sticker`, `StickerItem`, `MessageSnapshot`
- Enum/flag tables: `ChannelType`, `MessageType`, `MessageFlags`, `Permissions`, `UserFlags`, and companions the above need
- Golden payload fixtures in `test/fixtures/` (GUILD_CREATE, MESSAGE_CREATE with member+mentions, DM message, forum/thread channels, webhook-authored message, reply with deleted referent) + field-level decode tests
**Dependencies:** Phase 1.
**Done when:** Golden fixtures decode field-correct, including the notorious corners (`api-surface.AC2.10AC2.14`); channel factory dispatches all 15 documented types + unknown fallback.
<!-- END_PHASE_2 -->
<!-- START_PHASE_3 -->
### Phase 3: Remaining models and event envelopes
**Goal:** Every in-scope object and gateway event has a typed home.
**Components:**
- Remaining models in `lib/dexcord/model/`: `Interaction` (+ data variants, `ResolvedData` with `PartialChannel`/resolved partials), `ApplicationCommand` (+ options tree), component structs (ActionRow/Button/selects/TextInput + Components V2 set), `Webhook`, `Invite` (+ metadata), `AuditLog` (+ entries/changes), `AutoModerationRule` (+ triggers/actions), `GuildScheduledEvent` (+ recurrence), `Poll` (+ media/answers/results), `Application`, `Team`, `WelcomeScreen`, `Onboarding`, `Integration`, `Ban`, `Widget` objects, `ThreadMember`, `FollowedChannel`, `VoiceRegion`, `Connection`
- `Dexcord.Events` decode table in `lib/dexcord/events.ex`; envelope structs with `hydrate` slots in `lib/dexcord/events/*.ex` for every non-full-object event in `Dexcord.EventNames` (reactions, deletes, bulk delete, member add/remove/update, role events, typing, thread list sync, poll votes, …)
- Golden payload fixtures: INTERACTION_CREATE (slash + component + modal, guild and DM), reaction add, audit log entry, scheduled event
**Dependencies:** Phase 2.
**Done when:** `Events.decode/2` covers every atom in `Dexcord.EventNames.all/0` (full-object, envelope, or documented raw passthrough); interaction fixtures decode including resolved partials (`api-surface.AC2.15AC2.17`).
<!-- END_PHASE_3 -->
<!-- START_PHASE_4 -->
### Phase 4: Endpoint DSL and Api core
**Goal:** The endpoint machinery works end-to-end; the existing 16 endpoints are reborn as declarations.
**Components:**
- Endpoint DSL in `lib/dexcord/api/endpoint.ex` — route declaration → function generation (`@spec`, docs, path interpolation with URL-encoded segments, `query:` building, `body:` encoding from keyword/map/struct, `reason:` header, `returns:` decoding)
- Multipart machinery in `lib/dexcord/api.ex``payload_json` + `files[n]` encoding for `files: true` endpoints
- `Ratelimit.route_key/2` extensions in `lib/dexcord/api/ratelimit.ex` — invite-code collapse, `sticker-packs`, application-emoji clauses
- Existing 16 endpoints redeclared via the DSL in group modules; `Dexcord.Api` facade with generated delegates + `request/4` unchanged
- Endpoint-machinery tests against FakeRest (path/query/body/reason/multipart on the wire; struct decode on return; error passthrough)
**Dependencies:** Phases 12 (Phase 3 models only needed for later groups).
**Done when:** All 16 existing endpoints pass FakeRest round-trips returning structs (`api-surface.AC1.1AC1.7`); multipart and reason-header wire format verified; ratelimit templatizer tests cover new clauses.
<!-- END_PHASE_4 -->
<!-- START_PHASE_5 -->
### Phase 5: Full endpoint rollout and coverage gate
**Goal:** ~180 endpoints declared; coverage is mechanically enforced.
**Components:**
- All in-scope endpoint declarations across the group modules in `lib/dexcord/api/`
- Sugar arities (`create_message/2` with binary, `get_channel_messages/3` locator form, etc.)
- `mix dexcord.coverage` task in `lib/mix/tasks/dexcord.coverage.ex` + pinned `priv/discord_openapi.json` + refresh task; CI wiring
- Representative per-group FakeRest tests (one round-trip per group minimum, plus every special shape: top-level-array bodies, `wait=true` webhooks, `with_response` callbacks, binary widget.png, no-auth routes)
**Dependencies:** Phases 34.
**Done when:** `mix dexcord.coverage` reports zero missing in-scope endpoints and runs in CI (`api-surface.AC1.8AC1.10`); representative tests green.
<!-- END_PHASE_5 -->
<!-- START_PHASE_6 -->
### Phase 6: Gateway, cache, and slash integration
**Goal:** The typed contract is live end-to-end; the map contract is gone.
**Components:**
- `Dexcord.Dispatcher` decodes once via `Events.decode/2`; delivers `{name, typed}` to handler Tasks; decode-failure fallback to raw with logging
- `Dexcord.Cache` stores structs keyed by integer snowflakes; typed GUILD_CREATE explosion; `merge_map/2` on UPDATE events; reads return structs; `Dexcord.Cache.fill/1` hydration
- `Dexcord.Slash` typed: `%Interaction{}` into callbacks, struct-or-map `commands/0`, response helpers accepting keyword/map/struct bodies
- `Dexcord.Prefix` internals matching on `%Message{}`
- Updated integration tests: `cache_integration_test.exs`, `cache_cascade_test.exs`, `gateway_integration_test.exs`, `slash_test.exs`, `ergonomics_integration_test.exs`, cache-merge scenarios, `Cache.fill` round-trips
**Dependencies:** Phase 3 (models/events); independent of Phases 45.
**Done when:** FakeGateway end-to-end flows deliver typed events, cache returns structs surviving partial updates, hydration fills from cache (`api-surface.AC2.18AC2.22`, `api-surface.AC3.8`); no map-shaped reads remain.
<!-- END_PHASE_6 -->
<!-- START_PHASE_7 -->
### Phase 7: Ergonomics, docs, and the alamedya guide
**Goal:** The discord.py taste layer; the library is adoptable.
**Components:**
- `Dexcord.Messageable` protocol in `lib/dexcord/messageable.ex` + `Dexcord.Api.send/2` funnel with create-DM handling
- `Dexcord.Message.reply/2`; `mention/1` + `String.Chars` + `created_at/1` across models; `Dexcord.Util.format_dt/2`
- `Dexcord.Embed` builder; `Dexcord.Guild.member_permissions/2`; `allowed_mentions` config default + merge
- Pagination streams (`message_history/2`, member/ban/audit-log streams)
- New migration guide `docs/alamedya-migration-v2.md` written against the struct contract; README + moduledoc refresh
- Ergonomics tests (protocol dispatch incl. DM dance, reply reference wiring, permission computation, allowed-mentions merge, stream pagination against FakeRest)
**Dependencies:** Phases 46.
**Done when:** Ergonomics ACs pass (`api-surface.AC3.1AC3.7`); guide exists and compiles against the final API (`api-surface.AC5.1`); full suite green, `mix dexcord.coverage` green (`api-surface.AC4.1AC4.3`).
<!-- END_PHASE_7 -->
## Additional Considerations
**Decode failure blast radius:** one malformed event must never take down dispatch — `Events.decode/2` rescues per-event, logs at warning with the event name (never the full payload at warn level), and falls back to `{{name}, raw_map}` delivery so a handler can still act. Cache skips writes for events whose decode failed rather than storing half-decoded structs.
**GUILD_CREATE size:** decode of a large guild (channels + members + presences) happens once, inline in the dispatcher, replacing today's raw-map writes. The generated `from_map/1` is a single traversal with no atomization pass; if profiling during Phase 6 shows dispatcher lag on very large guilds, the escape valve is decoding guild children lazily per-table (design permits; not built preemptively).
**Spec drift:** `mix dexcord.coverage` compares against a *pinned* `openapi.json`; refreshing the pin is a deliberate act (`mix dexcord.coverage.refresh`), so upstream spec churn never breaks CI spontaneously. The spec is "public preview" — docs win on conflict, and reason-header support is hand-flagged because the spec omits it entirely.
**Registrar/global-wipe guards:** the Registrar's empty-command-list guard and retry containment (`lib/dexcord/slash/registrar.ex`) are untouched; `commands/0` returning structs is normalized to maps before the existing logic.
**Out of scope, still reachable:** monetization, soundboard, stage instances, guild templates, and Bearer-only endpoints remain on `request/4`. New Discord surface lands first as an `{:unknown, n}` enum value, an unmatched map key (silently ignored), or a missing endpoint flagged by the coverage task — all non-breaking.

View file

@ -0,0 +1,84 @@
# Human Test Plan — dexcord Full API Surface (2026-07-05-api-surface)
Coverage validation: **PASS** — 44/44 acceptance criteria covered by automated tests
(633 tests, 0 failures, 1 excluded `:flaky` with a deterministic default-suite twin).
This plan covers the residue automation cannot prove: hermeticity, CI enablement,
and migration-guide editorial quality, plus an optional live end-to-end.
## Prerequisites
- Elixir 1.18 / OTP 27, `mix deps.get` clean.
- `mix test` green from the repo root (baseline: 633 tests, 0 failures, 1 excluded).
- A Gitea repo with push access for the CI checks.
- Optional: a real Discord bot token + a throwaway test guild for the live end-to-end.
## Phase A: Suite Hermeticity (HV-1 — AC4.1)
Automated coverage proves the suite is green; this proves green also means *no external network*.
| Step | Action | Expected |
|------|--------|----------|
| A1 | Add `127.0.0.1 discord.com` (and `docs.discord.com`) to `/etc/hosts`, or run `sudo unshare -n mix test` from repo root | Full suite still exits 0 with the same pass count (633/0/1) |
| A2 | `rg -n 'discord\.com|githubusercontent' test/` | Hits only fixtures, doc-link strings, and the deliberately network-only `dexcord.coverage.refresh` task — never a live call from a test |
| A3 | `rg -n 'coverage\.refresh' test/ .gitea/` | No match — the network-only refresh task is never invoked by tests or CI |
| A4 | Revert `/etc/hosts` | Clean state restored |
## Phase B: CI Gate Actually Runs & Bites (HV-2 — AC4.3)
| Step | Action | Expected |
|------|--------|----------|
| B1 | Confirm `.gitea/workflows/ci.yml` lists `- run: mix test` and `- run: mix dexcord.coverage` | Both steps present |
| B2 | In Gitea repo settings, enable **Actions** | Actions enabled (owner infra step; no CI configured today) |
| B3 | Push a normal commit (or open a PR) | Pipeline triggers; both `mix test` and `mix dexcord.coverage` run green |
| B4 | On a throwaway branch, delete one in-scope `endpoint` declaration line (e.g. `get_channel` in `lib/dexcord/api/channels.ex`), push | The `mix dexcord.coverage` step turns the pipeline **red** (non-zero exit, route reported missing) |
| B5 | Delete the throwaway branch | Gate proven to bite; no residue |
## Phase C: Migration Guide Completeness & Prose (HV-3 — AC5.1)
The sample-validity half is automated (`migration_guide_samples_test.exs`); this covers
editorial completeness against the real old app.
| Step | Action | Expected |
|------|--------|----------|
| C1 | Read the old `alamedya-migration.md` (untracked, repo root) and extract its Discord touchpoint checklist | A concrete list, esp. the old string↔int snowflake dance and thread/cache reads specific to alamedya |
| C2 | Open `docs/alamedya-migration-v2.md` and confirm all 8 mandated sections exist | §1 Boot/config, §2 READY backfill, §3 MESSAGE_CREATE, §4 Thread cache reads, §5 Integer snowflakes, §6 Slash commands, §7 REST calls, §8 Hydration |
| C3 | For each old touchpoint from C1, find its before→after mapping in v2 | Nothing dropped; each maps to a v2 section |
| C4 | Confirm the `KEEP IN SYNC` comment sits in both the guide §3 handler and `test/dexcord/migration_guide_samples_test.exs` | Present in both |
| C5 | Read for prose quality | Accurate, adoptable, no stale map-shaped examples |
## End-to-End: Live Bot Round-Trip (optional; spans Phases 2/6/7)
Validates decode → dispatch → cache → send against real Discord, which the fakes only approximate.
1. Configure a real bot token + `allowed_mentions` default per guide §1; boot the app against a test guild.
2. On READY, call `Dexcord.Cache.guilds()` → expect the test guild present; after `GUILD_CREATE`, `Dexcord.Cache.channels(guild_id)` populated with typed channel structs (`%Dexcord.TextChannel{}` etc.).
3. From another account, post `ping!` in a visible channel → the bot replies as a threaded reply (`message_reference` set to your message).
4. `@mention` the bot → it replies.
5. Post as a webhook (or another bot) → the bot sends nothing (webhook/bot authors skipped).
6. `Dexcord.Api.message_history(channel_id) |> Enum.take(5)` in `iex` → exactly 5 `%Dexcord.Message{}` in descending id order, one page fetched.
7. Register a slash command, invoke it → handler receives a typed `%Dexcord.Interaction{}`; `Dexcord.Slash.respond/2` posts a visible reply.
## Human Verification Required
| Criterion | Why Manual | Steps |
|-----------|------------|-------|
| AC4.1 (hermeticity) | "green suite" ≠ "no egress" — a mis-stubbed fake could pass while calling out | Phase A |
| AC4.3 (CI runs) | A checked-in YAML can't assert the server executes it; Gitea Actions enablement is owner infra | Phase B |
| AC5.1 (guide completeness/prose) | Coverage-completeness and readability are editorial judgments | Phase C |
## Traceability
| Acceptance Criterion | Automated Test | Manual Step |
|----------------------|----------------|-------------|
| AC1.11.7 | endpoint_macro_test, endpoint_test, api_facade_test | E2E steps 2, 6 |
| AC1.81.10 | coverage_test, ratelimit_test | Phase B (B4 exercises the gate) |
| AC2.12.9 | struct_test, enum_test, flags_test, endpoint_test | — (pure) |
| AC2.102.17 | model_guild/message/channel/interaction/role_test, events_test | E2E step 2 |
| AC2.182.19 | gateway_integration_test | E2E steps 35 |
| AC2.202.22, AC3.8 | cache_test, cache_fill_test, cache_cascade_test | E2E step 2 |
| AC3.13.6 | send_test, messageable_test, ergonomics_helpers_test, model_message_parts_test | E2E steps 34 |
| AC3.7 | pagination_test | E2E step 6 |
| AC4.1 | full `mix test` | Phase A |
| AC4.2 | struct/enum/flags/endpoint test files (async categorization) | — |
| AC4.3 | coverage_test + `.gitea/workflows/ci.yml` | Phase B |
| AC5.1 | migration_guide_samples_test | Phase C |

View file

@ -81,6 +81,8 @@ defmodule Dexcord do
slash_guild_ids = Keyword.get(opts, :slash_guild_ids)
validate_slash_guild_ids(slash_guild_ids)
allowed_mentions = validate_allowed_mentions(Keyword.get(opts, :allowed_mentions))
%{
token: token,
handler: handler,
@ -90,7 +92,8 @@ defmodule Dexcord do
request_guild_members: request_guild_members,
slash: slash,
slash_guild_ids: slash_guild_ids,
gateway_url: gateway_url
gateway_url: gateway_url,
allowed_mentions: allowed_mentions
}
end
@ -153,6 +156,21 @@ defmodule Dexcord do
"got: #{inspect(other)}"
end
# Accepts a keyword, a map, or a `%Dexcord.AllowedMentions{}`, normalized to a
# string-keyed wire map (only explicitly-provided keys). Absent -> nil.
defp validate_allowed_mentions(nil), do: nil
defp validate_allowed_mentions(spec)
when is_list(spec) or is_map(spec) do
Dexcord.AllowedMentions.normalize(spec)
end
defp validate_allowed_mentions(other) do
raise ArgumentError,
"Dexcord :allowed_mentions must be a keyword list, map, or %Dexcord.AllowedMentions{}, " <>
"got: #{inspect(other)}"
end
defp boolean_opt(opts, key, default) do
value = Keyword.get(opts, key, default)

View file

@ -3,7 +3,11 @@ defmodule Dexcord.Api do
REST client over Finch, governed by `Dexcord.Api.Ratelimit`.
`request/4` is the generic escape hatch and the only place that talks to the
network; the ~16 typed endpoints below are thin wrappers over it. Every request
network; the typed endpoint surface is declared with the
`Dexcord.Api.Endpoint` DSL across `Dexcord.Api.Channels`,
`Dexcord.Api.Messages`, `Dexcord.Api.Users`, `Dexcord.Api.Interactions`,
`Dexcord.Api.Commands`, and `Dexcord.Api.Misc`, then re-exported here as
generated delegates (`Dexcord.Api.get_channel/1` and friends). Every request
flows through the limiter: acquire a token (sleeping in the caller's process
while `{:wait, ms}`), issue the request, feed the response headers back for
bucket learning, and on a 429 honor `retry_after` / the global scope and retry
@ -17,6 +21,21 @@ defmodule Dexcord.Api do
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
fake server).
## Internal multipart form
As an internal detail used by the endpoint layer, `request/4` also accepts a
body of the shape `{:multipart, payload_map, files}` where `files` is a list of
`%{filename: String.t(), data: binary(), content_type: String.t()}`. It is
encoded as `multipart/form-data` with a `payload_json` part plus one `files[n]`
part per file (see "Uploading Files" in the Discord reference). This form is not
part of the public contract; external callers should pass plain maps/lists.
A second internal shape, `{:form_multipart, fields_map, file}`, encodes plain
form-field multipart (Discord's "Create Guild Sticker"): each `fields_map` key
becomes its own bare form part and `file` (a `%{filename, data, content_type}`
map) rides in a single part named `file` NOT the `payload_json`/`files[n]`
shape above.
"""
alias Dexcord.Api.Error
@ -54,7 +73,12 @@ defmodule Dexcord.Api do
when the internal waits would exceed `:api_deadline_ms`. For a non-JSON
error body a bounded snippet of the raw body is kept in `:message`.
"""
@spec request(atom(), String.t(), map() | list() | nil, keyword()) ::
@spec request(
atom(),
String.t(),
map() | list() | {:multipart, map(), list()} | {:form_multipart, map(), map()} | nil,
keyword()
) ::
{:ok, map()} | {:ok, nil} | {:ok, {:raw, binary()}} | {:error, Error.t()}
def request(method, path, body \\ nil, opts \\ []) do
route = Ratelimit.route_key(method, path)
@ -169,11 +193,89 @@ defmodule Dexcord.Api do
nil ->
{headers, nil}
{:multipart, payload_map, files} ->
build_multipart(payload_map, files, headers)
{:form_multipart, fields, file} ->
build_form_multipart(fields, file, headers)
body ->
{[{"content-type", "application/json"} | headers], JSON.encode!(body)}
end
end
# Hand-rolled `multipart/form-data` per the Discord "Uploading Files" reference:
# a `payload_json` part carrying the JSON body, followed by one `files[n]` part
# per attachment (referenced from the payload's `attachments` array by index).
defp build_multipart(payload_map, files, headers) do
payload_part = [
"Content-Disposition: form-data; name=\"payload_json\"\r\n",
"Content-Type: application/json\r\n\r\n",
JSON.encode!(payload_map)
]
file_parts =
files
|> Enum.with_index()
|> Enum.map(fn {%{filename: filename, data: data, content_type: ctype}, n} ->
file_part("files[#{n}]", filename, ctype, data)
end)
multipart_wrap([payload_part | file_parts], headers)
end
# Plain form-field `multipart/form-data` (Discord "Create Guild Sticker"): each
# payload key is its own form-data part carrying a bare scalar value, plus a
# single file part named literally `file` — NOT the `payload_json` + `files[n]`
# shape. `file` is a `%{filename, data, content_type}` map.
defp build_form_multipart(
fields,
%{filename: filename, data: data, content_type: ctype},
headers
) do
field_parts =
Enum.map(fields, fn {key, value} ->
[
"Content-Disposition: form-data; name=\"",
to_string(key),
"\"\r\n\r\n",
to_string(value)
]
end)
multipart_wrap(field_parts ++ [file_part("file", filename, ctype, data)], headers)
end
# A file part's iodata: content-disposition (with filename) + content-type + data.
defp file_part(name, filename, content_type, data) do
[
"Content-Disposition: form-data; name=\"",
name,
"\"; filename=\"",
escape_filename(filename),
"\"\r\n",
"Content-Type: ",
content_type,
"\r\n\r\n",
data
]
end
# Wraps a list of part iodatas (each the bytes AFTER the boundary line) in a
# fresh random boundary and returns `{headers, body}`.
defp multipart_wrap(parts, headers) do
boundary =
"dexcord" <> Base.url_encode64(:crypto.strong_rand_bytes(12), padding: false)
framed = Enum.map(parts, fn part -> ["--", boundary, "\r\n", part, "\r\n"] end)
body = IO.iodata_to_binary([framed, "--", boundary, "--\r\n"])
{[{"content-type", "multipart/form-data; boundary=" <> boundary} | headers], body}
end
# Quotes, backslashes and CR/LF in a filename would corrupt the part header.
defp escape_filename(name), do: String.replace(name, ~r/["\\\r\n]/, "_")
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)
@ -273,185 +375,313 @@ defmodule Dexcord.Api do
end
end
# Builds the query string for `get_channel_messages/2`: `:limit` plus at most
# one anchor (`:before` > `:after` > `:around`), or `""` when neither is given.
defp message_query(opts) do
anchor =
Enum.find_value([:before, :after, :around], fn k ->
case Keyword.get(opts, k) do
nil -> nil
id -> {k, id}
# --- Ergonomic send funnel ---------------------------------------------
@doc """
Sends a message to anything `Dexcord.Messageable`: channels, threads,
messages (their channel), users/members (lazy DM), or a bare channel id.
The target is resolved through `Dexcord.Messageable.resolve/1` a
non-sendable value (a category/forum/media/directory channel) raises
`Protocol.UndefinedError` here, before any HTTP. A user/member target opens
(and caches) a DM channel on first use; see `Dexcord.Cache.dm_channel/1`.
`body` follows `Dexcord.Api.Messages.create_message/2`: a binary (wrapped as
`content`), or a keyword/map/struct body.
"""
@spec send(Dexcord.Messageable.t(), term(), keyword()) ::
{:ok, Dexcord.Message.t()} | {:error, Error.t()}
def send(target, body, opts \\ []) do
body = apply_allowed_mentions_default(body)
case Dexcord.Messageable.resolve(target) do
{:channel, channel_id} ->
Dexcord.Api.Messages.create_message(channel_id, body, opts)
{:dm_user, user_id} ->
with {:ok, channel_id} <- dm_channel_id(user_id) do
Dexcord.Api.Messages.create_message(channel_id, body, opts)
end
end)
params =
[]
|> maybe_param(:limit, Keyword.get(opts, :limit))
|> maybe_param(anchor && elem(anchor, 0), anchor && elem(anchor, 1))
case params do
[] -> ""
_ -> "?" <> URI.encode_query(params)
end
end
defp maybe_param(params, _key, nil), do: params
defp maybe_param(params, key, value), do: [{key, to_string(value)} | params]
# Applies the configured `allowed_mentions` default (if any) with a field-wise
# merge under any per-send value (per-send wins). Only the `send`/`reply`
# ergonomics funnel does this — the raw `create_message` endpoint stays
# mechanical. The body is normalized to a string-keyed map here so the merge
# (and `create_message`'s own encode) both see the same shape.
#
# Skipped entirely when the config key is unset AND the body carries no
# allowed_mentions, so an absent field keeps Discord's own defaults.
defp apply_allowed_mentions_default(body) do
default = Dexcord.Config.get(:allowed_mentions)
normalized = Dexcord.Api.Endpoint.encode_body(body, %{binary_wrap: :content})
defp maybe_put(map, _key, nil), do: map
defp maybe_put(map, key, value), do: Map.put(map, key, value)
case normalized do
%{} = map ->
if is_nil(default) and not Map.has_key?(map, "allowed_mentions") do
map
else
Map.update(map, "allowed_mentions", default, fn per_send ->
Dexcord.AllowedMentions.merge(default, Dexcord.AllowedMentions.normalize(per_send))
end)
end
# --- Typed endpoints ----------------------------------------------------
@doc "GET /gateway/bot - recommended gateway url + shard/session-start info."
@spec get_gateway_bot() :: {:ok, map()} | {:error, Error.t()}
def get_gateway_bot, do: request(:get, "/gateway/bot")
@doc "GET /users/@me - the current bot user."
@spec get_current_user() :: {:ok, map()} | {:error, Error.t()}
def get_current_user, do: request(:get, "/users/@me")
@doc "GET /oauth2/applications/@me - the current application object."
@spec get_current_application() :: {:ok, map()} | {:error, Error.t()}
def get_current_application, do: request(:get, "/oauth2/applications/@me")
@doc "GET /users/:user_id - a user."
@spec get_user(id()) :: {:ok, map()} | {:error, Error.t()}
def get_user(user_id), do: request(:get, "/users/#{user_id}")
@doc "GET /channels/:channel_id - a channel."
@spec get_channel(id()) :: {:ok, map()} | {:error, Error.t()}
def get_channel(channel_id), do: request(:get, "/channels/#{channel_id}")
@doc "GET /guilds/:guild_id - a guild."
@spec get_guild(id()) :: {:ok, map()} | {:error, Error.t()}
def get_guild(guild_id), do: request(:get, "/guilds/#{guild_id}")
@doc "POST /users/@me/channels - open (or fetch) a DM channel with a user."
@spec create_dm(id()) :: {:ok, map()} | {:error, Error.t()}
def create_dm(user_id) do
request(:post, "/users/@me/channels", %{"recipient_id" => to_string(user_id)})
other ->
other
end
end
@doc """
GET /channels/:channel_id/messages - a channel's messages, newest first.
# Resolve a user id to a DM channel id, opening (and caching) the DM on a miss.
defp dm_channel_id(user_id) do
case Dexcord.Cache.dm_channel(user_id) do
{:ok, channel_id} ->
{:ok, channel_id}
`opts` is a keyword list; `:limit` caps the count and at most one anchor of
`:before` / `:after` / `:around` (that precedence) selects the window. All are
encoded into the query string.
"""
@spec get_channel_messages(id(), keyword()) :: {:ok, [map()]} | {:error, Error.t()}
def get_channel_messages(channel_id, opts \\ []) do
request(:get, "/channels/#{channel_id}/messages" <> message_query(opts))
:error ->
with {:ok, %Dexcord.DMChannel{id: id}} <- Dexcord.Api.Users.create_dm(user_id) do
Dexcord.Cache.put_dm_channel(user_id, id)
{:ok, id}
end
end
end
@doc """
GET /channels/:channel_id/messages - `Nostrum.Api`-compatible arity.
# --- Lazy pagination streams -------------------------------------------
#
# Each returns a lazy `Stream` over `Dexcord.Api.Paginate`: a page is fetched
# only when the consumer walks that far, so `Stream.take/2` on a fresh stream
# makes exactly one wire hit. A page-fetch `{:error, _}` raises
# `Dexcord.Api.Paginate.PageError` mid-stream (streams cannot carry a tagged
# tuple), which the caller can `rescue`.
`locator` is `{:before, id}` / `{:after, id}` / `{:around, id}` or `{}` for no
anchor; it is normalized onto the keyword form of `get_channel_messages/2`.
@history_page_size 100
@members_page_size 1000
@bans_page_size 1000
@audit_log_page_size 100
@doc """
Streams a channel's message history, resolving `messageable` through
`Dexcord.Messageable`.
By default it pages newestoldest using the `before:` anchor (cursor = the
last message's id). Passing `after: id` flips to oldest→newest paging with the
`after:` anchor (mirroring discord.py's `oldest_first`, which defaults true iff
`after` is given). `limit: n` caps the total number of messages yielded.
The stream is lazy pages are fetched on demand and raises
`Dexcord.Api.Paginate.PageError` if a page request fails.
"""
@spec get_channel_messages(id(), pos_integer(), {atom(), id()} | {}) ::
{:ok, [map()]} | {:error, Error.t()}
def get_channel_messages(channel_id, limit, locator) do
opts =
case locator do
{a, id} when a in [:before, :after, :around] -> [{:limit, limit}, {a, id}]
{} -> [limit: limit]
@spec message_history(Dexcord.Messageable.t(), keyword()) :: Enumerable.t()
def message_history(messageable, opts \\ []) do
{:channel, channel_id} = Dexcord.Messageable.resolve(messageable)
stream =
case Keyword.fetch(opts, :after) do
{:ok, after_id} ->
Dexcord.Api.Paginate.stream(
after_id,
@history_page_size,
fn cursor ->
Dexcord.Api.Messages.get_channel_messages(channel_id,
limit: @history_page_size,
after: cursor
)
end,
fn last -> last.id end
)
:error ->
Dexcord.Api.Paginate.stream(
nil,
@history_page_size,
fn
nil ->
Dexcord.Api.Messages.get_channel_messages(channel_id, limit: @history_page_size)
cursor ->
Dexcord.Api.Messages.get_channel_messages(channel_id,
limit: @history_page_size,
before: cursor
)
end,
fn last -> last.id end
)
end
get_channel_messages(channel_id, opts)
end
@doc "POST /channels/:channel_id/messages - send a message."
@spec create_message(id(), String.t() | map()) :: {:ok, map()} | {:error, Error.t()}
def create_message(channel_id, content) when is_binary(content) do
create_message(channel_id, %{"content" => content})
end
def create_message(channel_id, %{} = params) do
request(:post, "/channels/#{channel_id}/messages", params)
end
@doc "PATCH /channels/:channel_id/messages/:message_id - edit a message."
@spec edit_message(id(), id(), String.t() | map()) :: {:ok, map()} | {:error, Error.t()}
def edit_message(channel_id, message_id, content) when is_binary(content) do
edit_message(channel_id, message_id, %{"content" => content})
end
def edit_message(channel_id, message_id, %{} = params) do
request(:patch, "/channels/#{channel_id}/messages/#{message_id}", params)
end
@doc "DELETE /channels/:channel_id/messages/:message_id - delete a message."
@spec delete_message(id(), id()) :: {:ok, nil} | {:error, Error.t()}
def delete_message(channel_id, message_id) do
request(:delete, "/channels/#{channel_id}/messages/#{message_id}")
maybe_take(stream, Keyword.get(opts, :limit))
end
@doc """
PUT /channels/:channel_id/messages/:message_id/reactions/:emoji/@me - react as
the bot. `emoji` is a unicode emoji or a `name:id` custom emoji; it is
URL-encoded for you.
Streams a guild's members ascending, paging with the `after:` anchor (cursor =
the last member's user id), 1000 per page. `limit: n` caps the total.
Lazy; raises `Dexcord.Api.Paginate.PageError` on a failed page.
"""
@spec create_reaction(id(), id(), String.t()) :: {:ok, nil} | {:error, Error.t()}
def create_reaction(channel_id, message_id, emoji) do
encoded = URI.encode(emoji, &URI.char_unreserved?/1)
request(:put, "/channels/#{channel_id}/messages/#{message_id}/reactions/#{encoded}/@me")
@spec guild_members_stream(Dexcord.Guild.t() | Dexcord.Snowflake.t(), keyword()) ::
Enumerable.t()
def guild_members_stream(guild_or_id, opts \\ []) do
guild_id = resolve_guild_id(guild_or_id)
after0 = Keyword.get(opts, :after, 0)
stream =
Dexcord.Api.Paginate.stream(
after0,
@members_page_size,
fn cursor ->
Dexcord.Api.Members.list_guild_members(guild_id,
limit: @members_page_size,
after: cursor
)
end,
fn last -> member_user_id(last) end
)
maybe_take(stream, Keyword.get(opts, :limit))
end
@doc """
POST /channels/:channel_id/messages/:message_id/threads - start a thread from
an existing message.
`opts` may carry `:auto_archive_duration` and `:rate_limit_per_user`; each is
omitted from the body when `nil`.
Streams a guild's bans ascending, paging with the `after:` anchor (cursor =
the last ban's user id), 1000 per page. `limit: n` caps the total.
Lazy; raises `Dexcord.Api.Paginate.PageError` on a failed page.
"""
@spec start_thread_with_message(id(), id(), String.t(), keyword()) ::
{:ok, map()} | {:error, Error.t()}
def start_thread_with_message(channel_id, message_id, name, opts \\ []) when is_binary(name) do
body =
%{"name" => name}
|> maybe_put("auto_archive_duration", Keyword.get(opts, :auto_archive_duration))
|> maybe_put("rate_limit_per_user", Keyword.get(opts, :rate_limit_per_user))
@spec guild_bans_stream(Dexcord.Guild.t() | Dexcord.Snowflake.t(), keyword()) :: Enumerable.t()
def guild_bans_stream(guild_or_id, opts \\ []) do
guild_id = resolve_guild_id(guild_or_id)
after0 = Keyword.get(opts, :after, 0)
request(:post, "/channels/#{channel_id}/messages/#{message_id}/threads", body)
stream =
Dexcord.Api.Paginate.stream(
after0,
@bans_page_size,
fn cursor ->
Dexcord.Api.Members.get_guild_bans(guild_id, limit: @bans_page_size, after: cursor)
end,
fn last -> last.user.id end
)
maybe_take(stream, Keyword.get(opts, :limit))
end
@doc "POST /interactions/:interaction_id/:token/callback - respond to an interaction."
@spec create_interaction_response(id(), String.t(), map()) ::
{:ok, nil} | {:ok, map()} | {:error, Error.t()}
def create_interaction_response(interaction_id, token, response) do
request(:post, "/interactions/#{interaction_id}/#{token}/callback", response)
@doc """
Streams a guild's audit-log entries descending, paging with the `before:`
anchor (cursor = the last entry's id), 100 per page.
The `get_guild_audit_log` endpoint returns a `%Dexcord.AuditLog{}` container;
this stream yields the `audit_log_entries` out of it (the referenced
users/webhooks/etc. on the container are not threaded through). `limit: n`
caps the total. Lazy; raises `Dexcord.Api.Paginate.PageError` on a failed page.
"""
@spec audit_log_stream(Dexcord.Guild.t() | Dexcord.Snowflake.t(), keyword()) :: Enumerable.t()
def audit_log_stream(guild_or_id, opts \\ []) do
guild_id = resolve_guild_id(guild_or_id)
stream =
Dexcord.Api.Paginate.stream(
nil,
@audit_log_page_size,
fn
nil ->
with {:ok, %Dexcord.AuditLog{} = al} <-
Dexcord.Api.Guilds.get_guild_audit_log(guild_id, limit: @audit_log_page_size),
do: {:ok, al.audit_log_entries}
cursor ->
with {:ok, %Dexcord.AuditLog{} = al} <-
Dexcord.Api.Guilds.get_guild_audit_log(guild_id,
limit: @audit_log_page_size,
before: cursor
),
do: {:ok, al.audit_log_entries}
end,
fn last -> last.id end
)
maybe_take(stream, Keyword.get(opts, :limit))
end
@doc "PATCH /webhooks/:application_id/:token/messages/@original - edit the original response."
@spec edit_original_interaction_response(id(), String.t(), map()) ::
{:ok, map()} | {:error, Error.t()}
def edit_original_interaction_response(application_id, token, body) do
request(:patch, "/webhooks/#{application_id}/#{token}/messages/@original", body)
defp maybe_take(stream, nil), do: stream
defp maybe_take(stream, limit) when is_integer(limit), do: Stream.take(stream, limit)
defp resolve_guild_id(%{id: id}) when is_integer(id), do: id
defp resolve_guild_id(id) when is_integer(id), do: id
defp resolve_guild_id(other), do: Dexcord.Snowflake.cast!(other)
defp member_user_id(%{user_id: user_id, user: user}), do: user_id || (user && user.id)
# --- Generated endpoint facade -----------------------------------------
#
# The typed endpoint surface lives in the group modules below (declared with
# the `Dexcord.Api.Endpoint` DSL). Each public endpoint head is re-exported
# here as a `defdelegate`, so `Dexcord.Api.<endpoint>` keeps working for every
# internal and external caller.
#
# The delegate set is derived from STATIC, compile-time-stable data — each
# group's `__endpoints__/0` route table — NOT from `module_info(:exports)`.
# `module_info/1` is an auto-generated function; the Elixir compiler's tracer
# does not record calls to it as compile-time dependencies, so reading a
# group's export table established no compile-order edge and raced under
# parallel/incremental compilation, silently dropping delegates (an incremental
# recompile could leave `Dexcord.Api.get_gateway_bot/0` undefined). `__endpoints__/0`
# is an ordinary remote call: it IS tracked, forcing every group fully compiled
# before this module, and its contents are deterministic.
#
# Each generated endpoint head ends in `opts \\ []`, so it exports at two
# arities — `base` and `base + 1`, where `base = path_param_count + (body? 1 : 0)`.
# That reproduces the old "default-arity heads are separate exports" behavior
# (e.g. both `create_message/2` and `create_message/3`) without introspection.
@endpoint_groups [
Dexcord.Api.Channels,
Dexcord.Api.Messages,
Dexcord.Api.Guilds,
Dexcord.Api.Members,
Dexcord.Api.Roles,
Dexcord.Api.Webhooks,
Dexcord.Api.Invites,
Dexcord.Api.Users,
Dexcord.Api.Interactions,
Dexcord.Api.Commands,
Dexcord.Api.EmojisStickers,
Dexcord.Api.Moderation,
Dexcord.Api.Misc
]
@doc false
def __endpoint_groups__, do: @endpoint_groups
# Hand-written sugar heads whose arities are NOT reproduced by the
# `__endpoints__/0` derivation, listed explicitly as `{group, name, arity}`.
# `Dexcord.Api.Users.create_dm/1,2` is already covered: its spec lives in the
# `__endpoints__/0` table (body: true, 0 path params -> arities 1 and 2). Only
# `Dexcord.Api.Messages.get_channel_messages/3` (the Nostrum-compatible locator
# arity, a plain `def` with no `endpoint/4` spec) needs to be named here.
@facade_sugar [
{Dexcord.Api.Messages, :get_channel_messages, 3}
]
@facade_exports (
endpoint_exports =
for group <- @endpoint_groups,
spec <- group.__endpoints__(),
params = Enum.count(spec.segments, &match?({:param, _}, &1)),
base = params + if(spec.body, do: 1, else: 0),
arity <- [base, base + 1] do
{group, spec.name, arity}
end
(endpoint_exports ++ @facade_sugar)
|> Enum.uniq_by(fn {_group, name, arity} -> {name, arity} end)
)
@doc false
# The statically-derived facade delegate set as a MapSet of `{name, arity}`.
# This is the single source of truth `api_facade_test.exs` asserts against, so
# a dropped delegate fails the guard deterministically rather than flakily.
def __facade_exports__ do
MapSet.new(for {_group, name, arity} <- @facade_exports, do: {name, arity})
end
@doc "POST /webhooks/:application_id/:token - send an interaction followup message."
@spec create_followup_message(id(), String.t(), map()) ::
{:ok, map()} | {:error, Error.t()}
def create_followup_message(application_id, token, body) do
request(:post, "/webhooks/#{application_id}/#{token}", body)
for {group, name, arity} <- @facade_exports do
args = Macro.generate_arguments(arity, __MODULE__)
defdelegate unquote(name)(unquote_splicing(args)), to: group
end
@doc "PUT /applications/:application_id/commands - bulk overwrite global commands."
@spec bulk_overwrite_global_commands(id(), [map()]) :: {:ok, map()} | {:error, Error.t()}
def bulk_overwrite_global_commands(application_id, commands) when is_list(commands) do
request(:put, "/applications/#{application_id}/commands", commands)
end
@doc "PUT /applications/:application_id/guilds/:guild_id/commands - bulk overwrite guild commands."
@spec bulk_overwrite_guild_commands(id(), id(), [map()]) ::
{:ok, map()} | {:error, Error.t()}
def bulk_overwrite_guild_commands(application_id, guild_id, commands) when is_list(commands) do
request(:put, "/applications/#{application_id}/guilds/#{guild_id}/commands", commands)
end
@typedoc "A Discord snowflake id, as a string or integer."
@type id :: String.t() | integer()
end

104
lib/dexcord/api/channels.ex Normal file
View file

@ -0,0 +1,104 @@
defmodule Dexcord.Api.Channels do
@moduledoc """
Channel endpoints, declared with the `Dexcord.Api.Endpoint` DSL.
Every function here is also re-exported from `Dexcord.Api` as a delegate, so
callers may use either `Dexcord.Api.get_channel/1` or
`Dexcord.Api.Channels.get_channel/1`.
`get_channel/1` returns the polymorphic `Dexcord.Channel` (the concrete
struct for the channel's `type`); `start_thread_with_message/4` returns the
created thread channel and honors an audit-log `:reason` opt.
"""
use Dexcord.Api.Endpoint
endpoint :get_channel, :get, "/channels/:channel_id", returns: Dexcord.Channel
endpoint :start_thread_with_message,
:post,
"/channels/:channel_id/messages/:message_id/threads",
body: true,
binary_wrap: :name,
reason: true,
returns: Dexcord.Channel
endpoint :edit_channel, :patch, "/channels/:channel_id",
body: true,
reason: true,
returns: Dexcord.Channel
endpoint :delete_channel, :delete, "/channels/:channel_id",
reason: true,
returns: Dexcord.Channel
endpoint :set_voice_channel_status, :put, "/channels/:channel_id/voice-status",
body: true,
reason: true,
returns: nil
endpoint :edit_channel_permissions, :put, "/channels/:channel_id/permissions/:overwrite_id",
body: true,
reason: true,
returns: nil
endpoint :delete_channel_permission, :delete, "/channels/:channel_id/permissions/:overwrite_id",
reason: true,
returns: nil
endpoint :get_channel_invites, :get, "/channels/:channel_id/invites", returns: [Dexcord.Invite]
endpoint :create_channel_invite, :post, "/channels/:channel_id/invites",
body: true,
reason: true,
returns: Dexcord.Invite
endpoint :follow_announcement_channel, :post, "/channels/:channel_id/followers",
body: true,
reason: true,
returns: Dexcord.FollowedChannel
endpoint :trigger_typing, :post, "/channels/:channel_id/typing", returns: nil
endpoint :group_dm_add_recipient, :put, "/channels/:channel_id/recipients/:user_id",
body: true,
returns: nil
endpoint :group_dm_remove_recipient, :delete, "/channels/:channel_id/recipients/:user_id",
returns: nil
endpoint :start_thread, :post, "/channels/:channel_id/threads",
body: true,
binary_wrap: :name,
files: true,
reason: true,
returns: Dexcord.Channel
endpoint :join_thread, :put, "/channels/:channel_id/thread-members/@me", returns: nil
endpoint :add_thread_member, :put, "/channels/:channel_id/thread-members/:user_id", returns: nil
endpoint :leave_thread, :delete, "/channels/:channel_id/thread-members/@me", returns: nil
endpoint :remove_thread_member, :delete, "/channels/:channel_id/thread-members/:user_id",
returns: nil
endpoint :get_thread_member, :get, "/channels/:channel_id/thread-members/:user_id",
query: [:with_member],
returns: Dexcord.ThreadMember
endpoint :list_thread_members, :get, "/channels/:channel_id/thread-members",
query: [:with_member, :after, :limit],
returns: [Dexcord.ThreadMember]
endpoint :list_public_archived_threads, :get, "/channels/:channel_id/threads/archived/public",
query: [:before, :limit],
returns: :raw
endpoint :list_private_archived_threads, :get, "/channels/:channel_id/threads/archived/private",
query: [:before, :limit],
returns: :raw
endpoint :list_joined_private_archived_threads,
:get,
"/channels/:channel_id/users/@me/threads/archived/private",
query: [:before, :limit],
returns: :raw
end

View file

@ -0,0 +1,75 @@
defmodule Dexcord.Api.Commands do
@moduledoc """
Application (slash) command endpoints, declared with the
`Dexcord.Api.Endpoint` DSL.
Both bulk-overwrite endpoints take a top-level list body (each element a
command definition map/struct) and return `[Dexcord.ApplicationCommand]`.
"""
use Dexcord.Api.Endpoint
endpoint :bulk_overwrite_global_commands,
:put,
"/applications/:application_id/commands",
body: true,
returns: [Dexcord.ApplicationCommand]
endpoint :bulk_overwrite_guild_commands,
:put,
"/applications/:application_id/guilds/:guild_id/commands",
body: true,
returns: [Dexcord.ApplicationCommand]
endpoint :get_global_commands, :get, "/applications/:application_id/commands",
query: [:with_localizations],
returns: [Dexcord.ApplicationCommand]
endpoint :create_global_command, :post, "/applications/:application_id/commands",
body: true,
returns: Dexcord.ApplicationCommand
endpoint :get_global_command, :get, "/applications/:application_id/commands/:command_id",
returns: Dexcord.ApplicationCommand
endpoint :edit_global_command, :patch, "/applications/:application_id/commands/:command_id",
body: true,
returns: Dexcord.ApplicationCommand
endpoint :delete_global_command, :delete, "/applications/:application_id/commands/:command_id",
returns: nil
endpoint :get_guild_commands, :get, "/applications/:application_id/guilds/:guild_id/commands",
query: [:with_localizations],
returns: [Dexcord.ApplicationCommand]
endpoint :create_guild_command,
:post,
"/applications/:application_id/guilds/:guild_id/commands",
body: true,
returns: Dexcord.ApplicationCommand
endpoint :get_guild_command,
:get,
"/applications/:application_id/guilds/:guild_id/commands/:command_id",
returns: Dexcord.ApplicationCommand
endpoint :edit_guild_command,
:patch,
"/applications/:application_id/guilds/:guild_id/commands/:command_id",
body: true,
returns: Dexcord.ApplicationCommand
endpoint :delete_guild_command,
:delete,
"/applications/:application_id/guilds/:guild_id/commands/:command_id", returns: nil
endpoint :get_guild_command_permissions,
:get,
"/applications/:application_id/guilds/:guild_id/commands/permissions",
returns: [Dexcord.GuildApplicationCommandPermissions]
endpoint :get_command_permissions,
:get,
"/applications/:application_id/guilds/:guild_id/commands/:command_id/permissions",
returns: Dexcord.GuildApplicationCommandPermissions
end

View file

@ -0,0 +1,86 @@
defmodule Dexcord.Api.Coverage do
@moduledoc """
Diffs the compiled endpoint route table against a pinned snapshot of
Discord's official OpenAPI spec (`priv/discord_openapi.json`).
The spec is a coverage CHECKLIST, not codegen input: it is refreshed only
by the deliberate `mix dexcord.coverage.refresh`, so upstream churn never
breaks CI spontaneously. Docs win over spec on conflict; out-of-scope
routes live in `priv/coverage_allowlist.exs`.
"""
@spec_path Path.join(:code.priv_dir(:dexcord), "discord_openapi.json")
@allowlist_path Path.join(:code.priv_dir(:dexcord), "coverage_allowlist.exs")
@doc "Returns {:ok, %{declared: n, spec: n, allowlisted: n}} or {:missing, [route]}."
def check(declared \\ declared_routes()) do
spec = spec_routes()
allowlist = allowlist()
missing =
spec
|> Enum.reject(&(&1 in declared))
|> Enum.reject(&allowlisted?(&1, allowlist))
|> Enum.sort()
case missing do
[] ->
{:ok, %{declared: length(declared), spec: length(spec), allowlisted: length(allowlist)}}
missing ->
{:missing, missing}
end
end
@doc "All declared routes as normalized \"VERB /path/*\" strings."
def declared_routes do
for group <- Dexcord.Api.__endpoint_groups__(),
spec <- group.__endpoints__(),
uniq: true do
normalize(spec.method, Enum.map(spec.segments, &segment_to_string/1))
end
end
@doc "All spec routes as normalized strings."
def spec_routes do
%{"paths" => paths} = @spec_path |> File.read!() |> JSON.decode!()
for {path, operations} <- paths,
{method, _op} <- operations,
method in ~w(get post put patch delete),
uniq: true do
segments =
path
|> String.split("/", trim: true)
|> Enum.map(fn
"{" <> _ -> "*"
literal -> literal
end)
normalize(method, segments)
end
end
defp segment_to_string({:param, _}), do: "*"
defp segment_to_string(literal), do: literal
defp normalize(method, segments) do
String.upcase(to_string(method)) <> " /" <> Enum.join(segments, "/")
end
defp allowlist do
{list, _} = Code.eval_file(@allowlist_path)
list
end
# Entries are exact normalized routes or prefix patterns ending in "*".
defp allowlisted?(route, allowlist) do
Enum.any?(allowlist, fn pattern ->
if String.ends_with?(pattern, "*") do
String.starts_with?(route, String.trim_trailing(pattern, "*"))
else
route == pattern
end
end)
end
end

View file

@ -0,0 +1,69 @@
defmodule Dexcord.Api.EmojisStickers do
@moduledoc """
Emoji and sticker endpoints, declared with the `Dexcord.Api.Endpoint` DSL
mirrors the Discord "Emoji" and "Sticker" resource pages.
Emoji create/edit bodies carry base64 `image` data in plain JSON (no
multipart). `create_guild_sticker` is the exception: it uses plain form-field
multipart (`files: :form`) with `name`/`description`/`tags` parts plus a single
`file` part. `list_application_emojis` and the sticker-pack listings return
`{"items": [...]}` / pack envelopes with no model (`:raw`).
"""
use Dexcord.Api.Endpoint
endpoint :list_guild_emojis, :get, "/guilds/:guild_id/emojis", returns: [Dexcord.Emoji]
endpoint :get_guild_emoji, :get, "/guilds/:guild_id/emojis/:emoji_id", returns: Dexcord.Emoji
endpoint :create_guild_emoji, :post, "/guilds/:guild_id/emojis",
body: true,
reason: true,
returns: Dexcord.Emoji
endpoint :edit_guild_emoji, :patch, "/guilds/:guild_id/emojis/:emoji_id",
body: true,
reason: true,
returns: Dexcord.Emoji
endpoint :delete_guild_emoji, :delete, "/guilds/:guild_id/emojis/:emoji_id",
reason: true,
returns: nil
endpoint :list_application_emojis, :get, "/applications/:application_id/emojis", returns: :raw
endpoint :get_application_emoji, :get, "/applications/:application_id/emojis/:emoji_id",
returns: Dexcord.Emoji
endpoint :create_application_emoji, :post, "/applications/:application_id/emojis",
body: true,
returns: Dexcord.Emoji
endpoint :edit_application_emoji, :patch, "/applications/:application_id/emojis/:emoji_id",
body: true,
returns: Dexcord.Emoji
endpoint :delete_application_emoji, :delete, "/applications/:application_id/emojis/:emoji_id",
returns: nil
endpoint :get_sticker, :get, "/stickers/:sticker_id", returns: Dexcord.Sticker
endpoint :list_sticker_packs, :get, "/sticker-packs", returns: :raw
endpoint :get_sticker_pack, :get, "/sticker-packs/:pack_id", returns: :raw
endpoint :list_guild_stickers, :get, "/guilds/:guild_id/stickers", returns: [Dexcord.Sticker]
endpoint :get_guild_sticker, :get, "/guilds/:guild_id/stickers/:sticker_id",
returns: Dexcord.Sticker
endpoint :create_guild_sticker, :post, "/guilds/:guild_id/stickers",
body: true,
files: :form,
reason: true,
returns: Dexcord.Sticker
endpoint :edit_guild_sticker, :patch, "/guilds/:guild_id/stickers/:sticker_id",
body: true,
reason: true,
returns: Dexcord.Sticker
endpoint :delete_guild_sticker, :delete, "/guilds/:guild_id/stickers/:sticker_id",
reason: true,
returns: nil
end

336
lib/dexcord/api/endpoint.ex Normal file
View file

@ -0,0 +1,336 @@
defmodule Dexcord.Api.Endpoint do
@moduledoc """
Route-declaration DSL for the Discord REST surface (see `endpoint/4`)
plus the runtime engine behind every generated endpoint function.
A group module `use`s this module and declares its routes with `endpoint/4`;
each declaration compiles to a spec map (the compile-time route table lives
at `__endpoints__/0`) and a documented function that funnels its arguments
through `run/4`. `run/4` builds the path, query string, headers, and body and
hands everything to `Dexcord.Api.request/4`, inheriting its ratelimit and
429-retry guarantees rather than reimplementing them.
## Generated `@spec`s
This layer deliberately does **not** emit per-endpoint `@spec`s. The `@doc`
string carries the route; there is no dialyzer in this repo (no `dialyxir`
dep), so specs on the ~180 generated heads would add macro complexity with no
verification leverage.
"""
alias Dexcord.Snowflake
@type snowflake_arg :: Dexcord.Snowflake.t() | String.t() | struct()
@type body_arg :: keyword() | map() | struct() | [map() | struct()]
# --- macro ---
@doc false
defmacro __using__(_opts) do
quote do
import Dexcord.Api.Endpoint, only: [endpoint: 3, endpoint: 4]
Module.register_attribute(__MODULE__, :dexcord_endpoints, accumulate: true)
@before_compile Dexcord.Api.Endpoint
end
end
@doc false
defmacro __before_compile__(env) do
endpoints =
env.module |> Module.get_attribute(:dexcord_endpoints) |> Enum.reverse()
quote do
@doc false
def __endpoints__, do: unquote(Macro.escape(endpoints))
end
end
@doc """
Declares a REST endpoint, generating a documented function plus a spec entry
in the module's `__endpoints__/0` route table.
`name` is the generated function name, `method` the HTTP verb atom, and `path`
the route with `:param` placeholders (e.g. `"/channels/:channel_id/messages"`).
Each placeholder becomes a leading positional argument; `body: true` adds a
`body` argument before the trailing `opts` keyword list.
## Options
* `:query` - allowed query-parameter keys (atoms), read from `opts`
* `:body` - `true` if the endpoint takes a body argument
* `:binary_wrap` - key (atom) a binary body is wrapped under
* `:files` - `true` if `opts[:files]` triggers multipart encoding
* `:reason` - `true` if `opts[:reason]` sends `X-Audit-Log-Reason`
* `:returns` - `module | [module] | nil | :raw` decode target (default `:raw`)
* `:docs` - a Discord docs URL appended to the generated `@doc`
"""
defmacro endpoint(name, method, path, opts \\ []) do
segments = parse_path(path)
params = for {:param, p} <- segments, do: p
returns_ast = Keyword.get(opts, :returns, :raw)
spec = %{
name: name,
method: method,
path: path,
segments: segments,
query: Keyword.get(opts, :query, []),
body: Keyword.get(opts, :body, false),
binary_wrap: Keyword.get(opts, :binary_wrap),
files: Keyword.get(opts, :files, false),
reason: Keyword.get(opts, :reason, false)
}
param_vars = Enum.map(params, &Macro.var(&1, __MODULE__))
doc_string =
"`#{method |> to_string() |> String.upcase()} #{path}`" <>
case Keyword.get(opts, :docs) do
nil -> ""
url -> "\n\nDiscord docs: #{url}"
end
if spec.body do
quote do
@dexcord_endpoints Map.put(
unquote(Macro.escape(spec)),
:returns,
unquote(returns_ast)
)
@doc unquote(doc_string)
def unquote(name)(unquote_splicing(param_vars), body, opts \\ []) do
Dexcord.Api.Endpoint.run(
Map.put(unquote(Macro.escape(spec)), :returns, unquote(returns_ast)),
[unquote_splicing(param_vars)],
body,
opts
)
end
end
else
quote do
@dexcord_endpoints Map.put(
unquote(Macro.escape(spec)),
:returns,
unquote(returns_ast)
)
@doc unquote(doc_string)
def unquote(name)(unquote_splicing(param_vars), opts \\ []) do
Dexcord.Api.Endpoint.run(
Map.put(unquote(Macro.escape(spec)), :returns, unquote(returns_ast)),
[unquote_splicing(param_vars)],
nil,
opts
)
end
end
end
end
@doc false
def parse_path(path) do
path
|> String.split("/", trim: true)
|> Enum.map(fn
":" <> param -> {:param, String.to_atom(param)}
literal -> literal
end)
end
# --- runtime ---
@doc false
def run(spec, path_args, body, opts) do
with {:ok, path} <- build_path(spec, path_args) do
full_path = path <> build_query(spec.query, opts)
encoded_body = prepare_body(spec, body, opts)
req_opts = reason_opts(spec, opts)
Dexcord.Api.request(spec.method, full_path, encoded_body, req_opts)
|> decode_response(spec.returns)
end
end
# --- path ---
@doc false
def build_path(spec, path_args) do
params = for {:param, p} <- spec.segments, do: p
values =
Enum.zip(params, path_args)
|> Map.new(fn {p, arg} -> {p, encode_segment(p, arg)} end)
case Enum.find(values, fn {_p, v} -> v == :error end) do
{p, :error} ->
{:error,
%Dexcord.Api.Error{
status: nil,
code: nil,
message: "invalid value for path parameter :#{p}",
errors: nil
}}
nil ->
segments =
Enum.map(spec.segments, fn
{:param, p} -> values[p]
literal -> literal
end)
{:ok, "/" <> Enum.join(segments, "/")}
end
end
# *_id params take snowflake-ish args; everything else is a URL-encoded string.
defp encode_segment(param, arg) do
if String.ends_with?(Atom.to_string(param), "_id") do
case Snowflake.cast(arg) do
{:ok, int} -> Integer.to_string(int)
:error -> :error
end
else
case arg do
arg when is_binary(arg) -> URI.encode(arg, &URI.char_unreserved?/1)
arg when is_integer(arg) -> Integer.to_string(arg)
_ -> :error
end
end
end
# --- query ---
@doc false
def build_query(allowed, opts) do
pairs =
for key <- allowed,
{:ok, value} <- [Keyword.fetch(opts, key)],
value != nil,
do: {key, query_value(value)}
case pairs do
[] -> ""
pairs -> "?" <> URI.encode_query(pairs)
end
end
defp query_value(%{id: id}) when is_integer(id), do: Integer.to_string(id)
defp query_value(true), do: "true"
defp query_value(false), do: "false"
defp query_value(v) when is_integer(v), do: Integer.to_string(v)
defp query_value(v) when is_binary(v), do: v
defp query_value(v), do: to_string(v)
# --- body ---
@doc false
def prepare_body(%{body: false}, _body, _opts), do: nil
def prepare_body(spec, body, opts) do
payload = encode_body(body, spec)
case {spec.files, Keyword.get(opts, :files)} do
{true, [_ | _] = files} ->
normalized = Enum.map(files, &normalize_file/1)
{:multipart, attach_ids(payload, normalized),
Enum.map(normalized, &Map.delete(&1, :description))}
# Plain form-field multipart (sticker create): exactly one file, and every
# payload value goes on the wire as a bare string form part.
{:form, [file]} ->
{:form_multipart, stringify_values(payload),
Map.delete(normalize_file(file), :description)}
_ ->
payload
end
end
# Form-field parts carry bare scalars, so non-binary values are stringified.
defp stringify_values(payload) when is_map(payload),
do: Map.new(payload, fn {k, v} -> {k, to_string(v)} end)
# A nil payload is legitimate (payload || %{} semantics); any other non-map
# is a caller error and must raise rather than silently drop the form fields.
defp stringify_values(nil), do: %{}
@doc false
def encode_body(nil, _spec), do: nil
def encode_body(body, %{binary_wrap: key}) when is_binary(body) and key != nil,
do: %{Atom.to_string(key) => body}
def encode_body(body, _spec) when is_list(body) do
if Keyword.keyword?(body) do
# Keyword contract (AC2.9): absent key = omitted; explicit nil = JSON null.
Map.new(body, fn {k, v} -> {Atom.to_string(k), encode_body_value(v)} end)
else
# top-level array bodies (e.g. bulk overwrite)
Enum.map(body, &encode_body(&1, %{binary_wrap: nil}))
end
end
def encode_body(%_{} = struct, _spec), do: struct.__struct__.to_map(struct)
def encode_body(body, _spec) when is_map(body), do: body
defp encode_body_value(%DateTime{} = dt), do: DateTime.to_iso8601(dt)
defp encode_body_value(%_{} = struct), do: struct.__struct__.to_map(struct)
defp encode_body_value(list) when is_list(list), do: Enum.map(list, &encode_body_value/1)
defp encode_body_value(v), do: v
defp normalize_file(%{filename: f, data: d} = file) when is_binary(f) and is_binary(d) do
%{
filename: f,
data: d,
content_type: Map.get(file, :content_type, "application/octet-stream"),
description: Map.get(file, :description)
}
end
# payload_json's attachments array references files[n] by id == n.
defp attach_ids(payload, files) do
declared =
files
|> Enum.with_index()
|> Enum.map(fn {file, n} ->
%{"id" => n, "filename" => file.filename}
|> then(fn m ->
if file.description, do: Map.put(m, "description", file.description), else: m
end)
end)
Map.update(payload || %{}, "attachments", declared, fn existing -> existing ++ declared end)
end
# --- reason header ---
defp reason_opts(%{reason: true}, opts) do
case Keyword.get(opts, :reason) do
nil -> []
reason -> [audit_log_reason: URI.encode(reason, &URI.char_unreserved?/1)]
end
end
defp reason_opts(_spec, _opts), do: []
# --- response decode ---
@doc false
def decode_response({:error, _} = err, _returns), do: err
def decode_response({:ok, nil}, _returns), do: {:ok, nil}
def decode_response({:ok, {:raw, _}} = raw, _returns), do: raw
def decode_response(ok, :raw), do: ok
# `returns: nil` endpoints normalize to `{:ok, nil}` even when the server sends
# a non-204 JSON body (otherwise the raw map would leak through the passthrough
# clause, since the atom-mod clause's `mod != nil` guard rightly skips nil).
def decode_response({:ok, _}, nil), do: {:ok, nil}
def decode_response({:ok, list}, [mod]) when is_list(list),
do: {:ok, Enum.map(list, &mod.from_map/1)}
def decode_response({:ok, map}, mod) when is_atom(mod) and mod != nil and is_map(map),
do: {:ok, mod.from_map(map)}
def decode_response(ok, _returns), do: ok
end

96
lib/dexcord/api/guilds.ex Normal file
View file

@ -0,0 +1,96 @@
defmodule Dexcord.Api.Guilds do
@moduledoc """
Guild endpoints, declared with the `Dexcord.Api.Endpoint` DSL mirrors the
Discord "Guild" resource page (plus the guild widget / welcome-screen /
onboarding / prune / audit-log sub-resources).
`get_guild/1,2` moved here from `Dexcord.Api.Misc`; the `Dexcord.Api` facade
delegate follows the group automatically. `get_guild_preview/1` decodes into
`Dexcord.Guild` (a lenient subset shape). Shapes with no model
(`get_guild_widget_image`, `get_guild_vanity_url`, prune counts, incident
actions, active-thread listings) stay `:raw`.
"""
use Dexcord.Api.Endpoint
endpoint :get_guild, :get, "/guilds/:guild_id", query: [:with_counts], returns: Dexcord.Guild
endpoint :get_guild_preview, :get, "/guilds/:guild_id/preview", returns: Dexcord.Guild
endpoint :edit_guild, :patch, "/guilds/:guild_id",
body: true,
reason: true,
returns: Dexcord.Guild
endpoint :get_guild_channels, :get, "/guilds/:guild_id/channels", returns: [Dexcord.Channel]
endpoint :create_guild_channel, :post, "/guilds/:guild_id/channels",
body: true,
binary_wrap: :name,
reason: true,
returns: Dexcord.Channel
endpoint :edit_guild_channel_positions, :patch, "/guilds/:guild_id/channels",
body: true,
returns: nil
endpoint :list_active_guild_threads, :get, "/guilds/:guild_id/threads/active", returns: :raw
endpoint :get_guild_invites, :get, "/guilds/:guild_id/invites", returns: [Dexcord.Invite]
endpoint :get_guild_integrations, :get, "/guilds/:guild_id/integrations",
returns: [Dexcord.Integration]
endpoint :delete_guild_integration, :delete, "/guilds/:guild_id/integrations/:integration_id",
reason: true,
returns: nil
endpoint :get_guild_widget_settings, :get, "/guilds/:guild_id/widget",
returns: Dexcord.GuildWidgetSettings
endpoint :edit_guild_widget, :patch, "/guilds/:guild_id/widget",
body: true,
reason: true,
returns: Dexcord.GuildWidgetSettings
endpoint :get_guild_widget, :get, "/guilds/:guild_id/widget.json", returns: Dexcord.GuildWidget
endpoint :get_guild_widget_image, :get, "/guilds/:guild_id/widget.png",
query: [:style],
returns: :raw
endpoint :get_guild_vanity_url, :get, "/guilds/:guild_id/vanity-url", returns: :raw
endpoint :get_guild_welcome_screen, :get, "/guilds/:guild_id/welcome-screen",
returns: Dexcord.WelcomeScreen
endpoint :edit_guild_welcome_screen, :patch, "/guilds/:guild_id/welcome-screen",
body: true,
reason: true,
returns: Dexcord.WelcomeScreen
endpoint :get_guild_onboarding, :get, "/guilds/:guild_id/onboarding",
returns: Dexcord.GuildOnboarding
endpoint :edit_guild_onboarding, :put, "/guilds/:guild_id/onboarding",
body: true,
reason: true,
returns: Dexcord.GuildOnboarding
endpoint :get_guild_prune_count, :get, "/guilds/:guild_id/prune",
query: [:days, :include_roles],
returns: :raw
endpoint :begin_guild_prune, :post, "/guilds/:guild_id/prune",
body: true,
reason: true,
returns: :raw
endpoint :get_guild_voice_regions, :get, "/guilds/:guild_id/regions",
returns: [Dexcord.VoiceRegion]
endpoint :edit_guild_incident_actions, :put, "/guilds/:guild_id/incident-actions",
body: true,
returns: :raw
endpoint :get_guild_audit_log, :get, "/guilds/:guild_id/audit-logs",
query: [:user_id, :action_type, :before, :after, :limit],
returns: Dexcord.AuditLog
end

View file

@ -0,0 +1,56 @@
defmodule Dexcord.Api.Interactions do
@moduledoc """
Interaction response endpoints, declared with the `Dexcord.Api.Endpoint` DSL.
`create_interaction_response/3` stays `:raw` a `204` becomes `{:ok, nil}`
naturally, and `query: [:with_response]` lets the caller opt into the richer
callback-resource body. The followup/edit endpoints return a `Dexcord.Message`.
These routes are token-authed (`:token` is the interaction/application token);
each collapses to its own digested ratelimit bucket, so they are isolated from
the global bot rate limit (see `Dexcord.Api.Ratelimit.route_key/2`).
"""
use Dexcord.Api.Endpoint
endpoint :create_interaction_response,
:post,
"/interactions/:interaction_id/:token/callback",
body: true,
files: true,
query: [:with_response],
returns: :raw
endpoint :edit_original_interaction_response,
:patch,
"/webhooks/:application_id/:token/messages/@original",
body: true,
files: true,
returns: Dexcord.Message
endpoint :create_followup_message, :post, "/webhooks/:application_id/:token",
body: true,
files: true,
returns: Dexcord.Message
endpoint :get_original_interaction_response,
:get,
"/webhooks/:application_id/:token/messages/@original", returns: Dexcord.Message
endpoint :delete_original_interaction_response,
:delete,
"/webhooks/:application_id/:token/messages/@original", returns: nil
endpoint :get_followup_message, :get, "/webhooks/:application_id/:token/messages/:message_id",
returns: Dexcord.Message
endpoint :edit_followup_message,
:patch,
"/webhooks/:application_id/:token/messages/:message_id",
body: true,
files: true,
returns: Dexcord.Message
endpoint :delete_followup_message,
:delete,
"/webhooks/:application_id/:token/messages/:message_id", returns: nil
end

View file

@ -0,0 +1,17 @@
defmodule Dexcord.Api.Invites do
@moduledoc """
Invite endpoints, declared with the `Dexcord.Api.Endpoint` DSL mirrors the
Discord "Invite" resource page.
Invite codes are non-numeric path params; the ratelimit layer collapses them
to a bounded placeholder so codes never land in ETS
(see `Dexcord.Api.Ratelimit.route_key/2`).
"""
use Dexcord.Api.Endpoint
endpoint :get_invite, :get, "/invites/:invite_code",
query: [:with_counts, :guild_scheduled_event_id],
returns: Dexcord.Invite
endpoint :delete_invite, :delete, "/invites/:invite_code", reason: true, returns: Dexcord.Invite
end

View file

@ -0,0 +1,81 @@
defmodule Dexcord.Api.Members do
@moduledoc """
Guild member, ban, and voice-state endpoints, declared with the
`Dexcord.Api.Endpoint` DSL mirrors the member/ban/voice sections of the
Discord "Guild" resource page.
`bulk_guild_ban` returns the `{banned_users, failed_users}` envelope with no
model (`:raw`). The `edit_*_voice_state` endpoints return `nil` (204).
"""
use Dexcord.Api.Endpoint
endpoint :get_guild_member, :get, "/guilds/:guild_id/members/:user_id", returns: Dexcord.Member
endpoint :list_guild_members, :get, "/guilds/:guild_id/members",
query: [:limit, :after],
returns: [Dexcord.Member]
endpoint :search_guild_members, :get, "/guilds/:guild_id/members/search",
query: [:query, :limit],
returns: [Dexcord.Member]
endpoint :add_guild_member, :put, "/guilds/:guild_id/members/:user_id",
body: true,
returns: Dexcord.Member
endpoint :edit_guild_member, :patch, "/guilds/:guild_id/members/:user_id",
body: true,
reason: true,
returns: Dexcord.Member
endpoint :edit_current_member, :patch, "/guilds/:guild_id/members/@me",
body: true,
reason: true,
returns: Dexcord.Member
endpoint :add_guild_member_role, :put, "/guilds/:guild_id/members/:user_id/roles/:role_id",
reason: true,
returns: nil
endpoint :remove_guild_member_role,
:delete,
"/guilds/:guild_id/members/:user_id/roles/:role_id", reason: true, returns: nil
endpoint :remove_guild_member, :delete, "/guilds/:guild_id/members/:user_id",
reason: true,
returns: nil
endpoint :get_guild_bans, :get, "/guilds/:guild_id/bans",
query: [:limit, :before, :after],
returns: [Dexcord.Ban]
endpoint :get_guild_ban, :get, "/guilds/:guild_id/bans/:user_id", returns: Dexcord.Ban
endpoint :create_guild_ban, :put, "/guilds/:guild_id/bans/:user_id",
body: true,
reason: true,
returns: nil
endpoint :remove_guild_ban, :delete, "/guilds/:guild_id/bans/:user_id",
reason: true,
returns: nil
endpoint :bulk_guild_ban, :post, "/guilds/:guild_id/bulk-ban",
body: true,
reason: true,
returns: :raw
endpoint :get_current_user_voice_state, :get, "/guilds/:guild_id/voice-states/@me",
returns: Dexcord.VoiceState
endpoint :get_user_voice_state, :get, "/guilds/:guild_id/voice-states/:user_id",
returns: Dexcord.VoiceState
endpoint :edit_current_user_voice_state, :patch, "/guilds/:guild_id/voice-states/@me",
body: true,
returns: nil
endpoint :edit_user_voice_state, :patch, "/guilds/:guild_id/voice-states/:user_id",
body: true,
returns: nil
end

142
lib/dexcord/api/messages.ex Normal file
View file

@ -0,0 +1,142 @@
defmodule Dexcord.Api.Messages do
@moduledoc """
Message + reaction endpoints, declared with the `Dexcord.Api.Endpoint` DSL.
`create_message/2,3` and `edit_message/3,4` accept a binary (wrapped as
`content`), a keyword/map body, and `files:` for attachments; both return a
`Dexcord.Message`. `get_channel_messages/2` reads `:limit` plus any of
`:before` / `:after` / `:around` from its opts and encodes them into the query
string.
## Anchor precedence
Unlike the pre-DSL surface (which collapsed multiple anchors with a fixed
`before` > `after` > `around` precedence), `get_channel_messages/2` now sends
every anchor it is given. Discord expects exactly one; pass exactly one. The
Nostrum-compatible `get_channel_messages/3` locator arity enforces this by
construction.
"""
use Dexcord.Api.Endpoint
endpoint :get_channel_messages, :get, "/channels/:channel_id/messages",
query: [:limit, :before, :after, :around],
returns: [Dexcord.Message]
endpoint :create_message, :post, "/channels/:channel_id/messages",
body: true,
binary_wrap: :content,
files: true,
returns: Dexcord.Message
endpoint :edit_message, :patch, "/channels/:channel_id/messages/:message_id",
body: true,
binary_wrap: :content,
files: true,
returns: Dexcord.Message
endpoint :delete_message, :delete, "/channels/:channel_id/messages/:message_id",
reason: true,
returns: nil
endpoint :create_reaction,
:put,
"/channels/:channel_id/messages/:message_id/reactions/:emoji/@me",
returns: nil
endpoint :get_channel_message, :get, "/channels/:channel_id/messages/:message_id",
returns: Dexcord.Message
endpoint :crosspost_message, :post, "/channels/:channel_id/messages/:message_id/crosspost",
returns: Dexcord.Message
endpoint :delete_own_reaction,
:delete,
"/channels/:channel_id/messages/:message_id/reactions/:emoji/@me", returns: nil
endpoint :delete_user_reaction,
:delete,
"/channels/:channel_id/messages/:message_id/reactions/:emoji/:user_id", returns: nil
endpoint :get_reactions, :get, "/channels/:channel_id/messages/:message_id/reactions/:emoji",
query: [:type, :after, :limit],
returns: [Dexcord.User]
endpoint :delete_all_reactions, :delete, "/channels/:channel_id/messages/:message_id/reactions",
returns: nil
endpoint :delete_all_reactions_for_emoji,
:delete,
"/channels/:channel_id/messages/:message_id/reactions/:emoji", returns: nil
endpoint :bulk_delete_messages, :post, "/channels/:channel_id/messages/bulk-delete",
body: true,
reason: true,
returns: nil
endpoint :get_channel_pins, :get, "/channels/:channel_id/messages/pins",
query: [:before, :limit],
returns: :raw
endpoint :pin_message, :put, "/channels/:channel_id/messages/pins/:message_id",
reason: true,
returns: nil
endpoint :unpin_message, :delete, "/channels/:channel_id/messages/pins/:message_id",
reason: true,
returns: nil
endpoint :search_guild_messages, :get, "/guilds/:guild_id/messages/search",
query: [
:limit,
:offset,
:max_id,
:min_id,
:slop,
:content,
:channel_id,
:author_type,
:author_id,
:mentions,
:mentions_role_id,
:mention_everyone,
:replied_to_user_id,
:replied_to_message_id,
:pinned,
:has,
:embed_type,
:embed_provider,
:link_hostname,
:attachment_filename,
:attachment_extension,
:sort_by,
:sort_order,
:include_nsfw
],
returns: :raw
endpoint :get_answer_voters, :get, "/channels/:channel_id/polls/:message_id/answers/:answer_id",
query: [:after, :limit],
returns: :raw
endpoint :end_poll, :post, "/channels/:channel_id/polls/:message_id/expire",
returns: Dexcord.Message
@doc """
Nostrum-compatible locator arity for `get_channel_messages/2`.
`locator` is `{:before, id}` / `{:after, id}` / `{:around, id}` or `{}` for no
anchor; it is normalized onto the keyword form.
"""
def get_channel_messages(channel_id, limit, locator) when is_integer(limit) do
opts =
case locator do
{} ->
[limit: limit]
{anchor, id} when anchor in [:before, :after, :around] ->
[{:limit, limit}, {anchor, id}]
end
get_channel_messages(channel_id, opts)
end
end

31
lib/dexcord/api/misc.ex Normal file
View file

@ -0,0 +1,31 @@
defmodule Dexcord.Api.Misc do
@moduledoc """
Gateway / application-info endpoints, declared with the
`Dexcord.Api.Endpoint` DSL.
`get_gateway/0` and `get_gateway_bot/0` stay `:raw` their
`url` / `shards` / `session_start_limit` shapes have no model and the gateway
reads them as raw maps.
`get_current_application/0` hits the docs-canonical `GET /applications/@me`;
the older `GET /oauth2/applications/@me` route is preserved as
`get_current_bot_application/0`. Both return a `Dexcord.ApplicationInfo`.
`get_guild` moved to `Dexcord.Api.Guilds` the `Dexcord.Api` facade delegate
follows the group automatically.
"""
use Dexcord.Api.Endpoint
endpoint :get_gateway, :get, "/gateway", returns: :raw
endpoint :get_gateway_bot, :get, "/gateway/bot", returns: :raw
endpoint :get_current_application, :get, "/applications/@me", returns: Dexcord.ApplicationInfo
endpoint :edit_current_application, :patch, "/applications/@me",
body: true,
returns: Dexcord.ApplicationInfo
endpoint :get_current_bot_application, :get, "/oauth2/applications/@me",
returns: Dexcord.ApplicationInfo
endpoint :list_voice_regions, :get, "/voice/regions", returns: [Dexcord.VoiceRegion]
end

View file

@ -0,0 +1,69 @@
defmodule Dexcord.Api.Moderation do
@moduledoc """
Auto-moderation and guild-scheduled-event endpoints, declared with the
`Dexcord.Api.Endpoint` DSL mirrors the Discord "Auto Moderation" and
"Guild Scheduled Event" resource pages.
`delete_guild_scheduled_event` deliberately has NO `reason:` support (the docs
are asymmetric create/edit take a reason, delete does not).
"""
use Dexcord.Api.Endpoint
endpoint :list_auto_moderation_rules, :get, "/guilds/:guild_id/auto-moderation/rules",
returns: [Dexcord.AutoModerationRule]
endpoint :get_auto_moderation_rule,
:get,
"/guilds/:guild_id/auto-moderation/rules/:auto_moderation_rule_id",
returns: Dexcord.AutoModerationRule
endpoint :create_auto_moderation_rule, :post, "/guilds/:guild_id/auto-moderation/rules",
body: true,
reason: true,
returns: Dexcord.AutoModerationRule
endpoint :edit_auto_moderation_rule,
:patch,
"/guilds/:guild_id/auto-moderation/rules/:auto_moderation_rule_id",
body: true,
reason: true,
returns: Dexcord.AutoModerationRule
endpoint :delete_auto_moderation_rule,
:delete,
"/guilds/:guild_id/auto-moderation/rules/:auto_moderation_rule_id",
reason: true,
returns: nil
endpoint :list_guild_scheduled_events, :get, "/guilds/:guild_id/scheduled-events",
query: [:with_user_count],
returns: [Dexcord.GuildScheduledEvent]
endpoint :create_guild_scheduled_event, :post, "/guilds/:guild_id/scheduled-events",
body: true,
reason: true,
returns: Dexcord.GuildScheduledEvent
endpoint :get_guild_scheduled_event,
:get,
"/guilds/:guild_id/scheduled-events/:guild_scheduled_event_id",
query: [:with_user_count],
returns: Dexcord.GuildScheduledEvent
endpoint :edit_guild_scheduled_event,
:patch,
"/guilds/:guild_id/scheduled-events/:guild_scheduled_event_id",
body: true,
reason: true,
returns: Dexcord.GuildScheduledEvent
endpoint :delete_guild_scheduled_event,
:delete,
"/guilds/:guild_id/scheduled-events/:guild_scheduled_event_id", returns: nil
endpoint :get_guild_scheduled_event_users,
:get,
"/guilds/:guild_id/scheduled-events/:guild_scheduled_event_id/users",
query: [:limit, :with_member, :before, :after],
returns: [Dexcord.GuildScheduledEventUser]
end

View file

@ -0,0 +1,54 @@
defmodule Dexcord.Api.Paginate do
@moduledoc false
# Lazy cursor pagination over Discord's list endpoints. Each page is fetched
# only when the stream is consumed that far (so `Stream.take/2` on a fresh
# stream makes exactly one wire hit). A page shorter than the page size ends
# the stream; an `{:error, _}` from the fetcher raises `PageError` — a stream
# cannot carry a tagged-tuple failure mid-flow, so the error surfaces as a
# raise the caller can `rescue`.
defmodule PageError do
@moduledoc """
Raised when a page fetch inside a pagination stream returns
`{:error, _}`. The underlying error is on the `:error` field.
"""
defexception [:error]
@impl true
def message(%{error: e}), do: "pagination request failed: #{inspect(e)}"
end
@doc false
# `fetch_page.(cursor)` -> `{:ok, items}` | `{:error, e}`;
# `next.(last_item)` -> the cursor for the following page.
#
# `initial_cursor` seeds the first fetch (may be `nil` for an anchorless first
# page). The stream halts on the first page whose length is < `page_size`.
@spec stream(term(), pos_integer(), (term() -> {:ok, list()} | {:error, term()}), (term() ->
term())) ::
Enumerable.t()
def stream(initial_cursor, page_size, fetch_page, next)
when is_integer(page_size) and page_size > 0 and is_function(fetch_page, 1) and
is_function(next, 1) do
Stream.resource(
fn -> {initial_cursor, :go} end,
fn
{_cursor, :halt} ->
{:halt, nil}
{cursor, :go} ->
case fetch_page.(cursor) do
{:ok, items} when length(items) < page_size ->
{items, {cursor, :halt}}
{:ok, items} ->
{items, {next.(List.last(items)), :go}}
{:error, error} ->
raise PageError, error: error
end
end,
fn _ -> :ok end
)
end
end

View file

@ -108,6 +108,9 @@ defmodule Dexcord.Api.Ratelimit do
template = "/" <> Enum.join(templatize(segments), "/")
base = verb <> " " <> template
# As of the 2026-07 docs the separate message-delete bucket is no longer
# documented, but this suffix only splits buckets more finely (safe) - kept
# to avoid 429s should the server-side behavior still exist.
if method == :delete and String.ends_with?(template, "/messages/:id") do
base <> " [message-delete]"
else
@ -138,6 +141,11 @@ defmodule Dexcord.Api.Ratelimit do
defp templatize(["reactions", _emoji | rest]),
do: ["reactions", ":id" | templatize(rest)]
# invite codes are non-numeric, so they'd otherwise stay literal - collapse
# them to keep bucket keys bounded and keep invite codes out of ETS.
defp templatize(["invites", _code | rest]),
do: ["invites", ":code" | templatize(rest)]
defp templatize([seg | rest]), do: [templatize_one(seg) | templatize(rest)]
defp templatize([]), do: []

37
lib/dexcord/api/roles.ex Normal file
View file

@ -0,0 +1,37 @@
defmodule Dexcord.Api.Roles do
@moduledoc """
Guild role endpoints, declared with the `Dexcord.Api.Endpoint` DSL mirrors
the role section of the Discord "Guild" resource page.
`get_guild_role_member_counts` returns a `{role_id => count}` map with no
model (`:raw`). `edit_guild_role_positions` takes a top-level list body and
returns the full `[Dexcord.Role]` list.
"""
use Dexcord.Api.Endpoint
endpoint :get_guild_roles, :get, "/guilds/:guild_id/roles", returns: [Dexcord.Role]
endpoint :get_guild_role, :get, "/guilds/:guild_id/roles/:role_id", returns: Dexcord.Role
endpoint :get_guild_role_member_counts, :get, "/guilds/:guild_id/roles/member-counts",
returns: :raw
endpoint :create_guild_role, :post, "/guilds/:guild_id/roles",
body: true,
binary_wrap: :name,
reason: true,
returns: Dexcord.Role
endpoint :edit_guild_role_positions, :patch, "/guilds/:guild_id/roles",
body: true,
reason: true,
returns: [Dexcord.Role]
endpoint :edit_guild_role, :patch, "/guilds/:guild_id/roles/:role_id",
body: true,
reason: true,
returns: Dexcord.Role
endpoint :delete_guild_role, :delete, "/guilds/:guild_id/roles/:role_id",
reason: true,
returns: nil
end

76
lib/dexcord/api/users.ex Normal file
View file

@ -0,0 +1,76 @@
defmodule Dexcord.Api.Users do
@moduledoc """
User + DM endpoints, declared with the `Dexcord.Api.Endpoint` DSL.
`create_dm/1` keeps its historical shape: given a snowflake-ish `user_id` it
opens (or fetches) a DM channel. It is registered by hand rather than via
`endpoint/4` because the generated `create_dm(body, opts \\ [])` head would
collide with the snowflake sugar's `create_dm/1`; the manual spec still lands
in the `__endpoints__/0` route table so coverage tooling sees it.
"""
use Dexcord.Api.Endpoint
endpoint :get_current_user, :get, "/users/@me", returns: Dexcord.User
endpoint :get_user, :get, "/users/:user_id", returns: Dexcord.User
endpoint :edit_current_user, :patch, "/users/@me", body: true, returns: Dexcord.User
endpoint :get_current_user_guilds, :get, "/users/@me/guilds",
query: [:before, :after, :limit, :with_counts],
returns: [Dexcord.PartialGuild]
endpoint :get_current_user_guild_member, :get, "/users/@me/guilds/:guild_id/member",
returns: Dexcord.Member
endpoint :leave_guild, :delete, "/users/@me/guilds/:guild_id", returns: nil
endpoint :get_current_user_connections, :get, "/users/@me/connections",
returns: [Dexcord.Connection]
@create_dm_spec %{
name: :create_dm,
method: :post,
path: "/users/@me/channels",
segments: ["users", "@me", "channels"],
query: [],
body: true,
binary_wrap: nil,
files: false,
reason: false,
returns: Dexcord.Channel
}
@dexcord_endpoints @create_dm_spec
@doc """
`POST /users/@me/channels` open (or fetch) a DM channel.
Accepts a snowflake-ish `user_id` (wrapped as `recipient_id`) or an explicit
keyword/map body.
"""
def create_dm(user_id_or_body, opts \\ [])
def create_dm(body, opts) when is_list(body) or is_map(body) do
Dexcord.Api.Endpoint.run(@create_dm_spec, [], body, opts)
end
def create_dm(user_id, _opts) do
case Dexcord.Snowflake.cast(user_id) do
{:ok, id} ->
Dexcord.Api.Endpoint.run(
@create_dm_spec,
[],
[recipient_id: Integer.to_string(id)],
[]
)
:error ->
{:error,
%Dexcord.Api.Error{
status: nil,
code: nil,
message: "invalid value for path parameter :user_id",
errors: nil
}}
end
end
end

View file

@ -0,0 +1,79 @@
defmodule Dexcord.Api.Webhooks do
@moduledoc """
Webhook endpoints, declared with the `Dexcord.Api.Endpoint` DSL mirrors the
Discord "Webhook" resource page.
`execute_webhook` returns a `Dexcord.Message` only when called with
`wait: true` (otherwise Discord replies 204 and the passthrough yields
`{:ok, nil}`). The Slack/GitHub-compat executes have no model (`:raw`). The
`*_with_token` variants are token-authed; their tokens are digested out of the
ratelimit bucket key (see `Dexcord.Api.Ratelimit.route_key/2`).
"""
use Dexcord.Api.Endpoint
endpoint :create_webhook, :post, "/channels/:channel_id/webhooks",
body: true,
binary_wrap: :name,
reason: true,
returns: Dexcord.Webhook
endpoint :get_channel_webhooks, :get, "/channels/:channel_id/webhooks",
returns: [Dexcord.Webhook]
endpoint :get_guild_webhooks, :get, "/guilds/:guild_id/webhooks", returns: [Dexcord.Webhook]
endpoint :get_webhook, :get, "/webhooks/:webhook_id", returns: Dexcord.Webhook
endpoint :get_webhook_with_token, :get, "/webhooks/:webhook_id/:webhook_token",
returns: Dexcord.Webhook
endpoint :edit_webhook, :patch, "/webhooks/:webhook_id",
body: true,
reason: true,
returns: Dexcord.Webhook
endpoint :edit_webhook_with_token, :patch, "/webhooks/:webhook_id/:webhook_token",
body: true,
returns: Dexcord.Webhook
endpoint :delete_webhook, :delete, "/webhooks/:webhook_id", reason: true, returns: nil
endpoint :delete_webhook_with_token, :delete, "/webhooks/:webhook_id/:webhook_token",
returns: nil
endpoint :execute_webhook, :post, "/webhooks/:webhook_id/:webhook_token",
body: true,
binary_wrap: :content,
files: true,
query: [:wait, :thread_id, :with_components],
returns: Dexcord.Message
endpoint :execute_slack_webhook, :post, "/webhooks/:webhook_id/:webhook_token/slack",
body: true,
query: [:thread_id, :wait],
returns: :raw
endpoint :execute_github_webhook, :post, "/webhooks/:webhook_id/:webhook_token/github",
body: true,
query: [:thread_id, :wait],
returns: :raw
endpoint :get_webhook_message,
:get,
"/webhooks/:webhook_id/:webhook_token/messages/:message_id",
query: [:thread_id],
returns: Dexcord.Message
endpoint :edit_webhook_message,
:patch,
"/webhooks/:webhook_id/:webhook_token/messages/:message_id",
body: true,
files: true,
query: [:thread_id, :with_components],
returns: Dexcord.Message
endpoint :delete_webhook_message,
:delete,
"/webhooks/:webhook_id/:webhook_token/messages/:message_id",
query: [:thread_id],
returns: nil
end

View file

@ -10,7 +10,7 @@ defmodule Dexcord.Cache do
## 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
on the hot path. All writes happen through `handle_dispatch/4`, 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`, ...)
@ -21,6 +21,17 @@ defmodule Dexcord.Cache do
is always the source of truth; the cache is best-effort, and can briefly lag or
hold a duplicate after a resume gap.
### The one sanctioned writer exception: DM channels
`:dexcord_dm_channels` is the single table written from OUTSIDE the Dispatcher
the `Dexcord.Api.send/2` funnel calls `put_dm_channel/2` after lazily opening a
DM (`Dexcord.Cache.dm_channel/1` misses `POST /users/@me/channels` cache the
id). This breaks the single-writer rule on purpose, and it is safe: DM channel
ids are stable and idempotent (Discord returns the SAME channel for a given
recipient), so two processes racing to open a DM for the same user both compute
and store the same id a harmless duplicate write, never a conflicting one. The
table is `:set`/`:public` like the rest, so reads stay lock-free.
## Tables
| Table | Type | Key |
@ -36,16 +47,20 @@ defmodule Dexcord.Cache do
### 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`.
Snowflake ids are stored in table keys as **integers** - the same representation
`Dexcord.Snowflake.cast/1` produces at decode time. Reads accept anything
castable (an integer, a decimal string, or a struct with an `:id`) and normalize
it, so a caller may pass whatever id they have. Stored values are decoded model
**structs** (`%Dexcord.Guild{}`, `%Dexcord.TextChannel{}`, `%Dexcord.Member{}`,
...), with the large child arrays of a guild peeled off into their own tables. A
cached member's nested `user` is moved into the users table and the row keeps a
`user_id` back-reference. 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
require Logger
@me :dexcord_me
@guilds :dexcord_guilds
@ -55,13 +70,15 @@ defmodule Dexcord.Cache do
@roles :dexcord_roles
@presences :dexcord_presences
@voice_states :dexcord_voice_states
# Written by the `Dexcord.Api.send/2` funnel, not the Dispatcher — the one
# sanctioned exception to single-writer (see moduledoc). Key: user_id -> DM
# channel id.
@dm_channels :dexcord_dm_channels
# 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)
# Channel `type` values that denote a thread (public, private, announcement).
@thread_types [10, 11, 12]
@guild_child_fields [:channels, :threads, :roles, :members, :presences, :voice_states]
@guild_child_keys ~w(channels threads roles members presences voice_states)
# --- lifecycle ----------------------------------------------------------
@ -83,6 +100,7 @@ defmodule Dexcord.Cache do
:ets.new(@roles, oset)
:ets.new(@presences, oset)
:ets.new(@voice_states, oset)
:ets.new(@dm_channels, set)
{:ok, %{}}
end
@ -90,260 +108,385 @@ defmodule Dexcord.Cache do
# --- 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.
Folds one decoded 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.
`decoded` is the typed payload from `Dexcord.Events.decode/2`; `raw` is the
original string-keyed wire map (needed by partial-update events that merge only
the keys Discord actually sent). Write clauses pattern-match the decoded struct
type, so a decode failure (which delivers the raw map as a fallback) simply
matches no write clause and is skipped.
`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`.
consulted (presence writes are skipped entirely when it is falsey). Returns `:ok`.
"""
@spec handle_dispatch(atom() | {:unknown_event, String.t()}, term(), map()) :: :ok
def handle_dispatch(name, data, config)
@spec handle_dispatch(atom() | {:unknown_event, String.t()}, term(), term(), map()) :: :ok
def handle_dispatch(name, decoded, raw, config)
def handle_dispatch(_name, data, _config) when not is_map(data), do: :ok
def handle_dispatch(:READY, %Dexcord.Events.Ready{} = ready, _raw, _config) do
put_me(ready.user)
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})
for %Dexcord.UnavailableGuild{id: id} = stub <- ready.guilds, not is_nil(id) do
:ets.insert(@guilds, {id, stub})
end
:ok
end
def handle_dispatch(:GUILD_CREATE, guild, config) do
gid = guild["id"]
:ets.insert(@guilds, {gid, Map.drop(guild, @guild_child_arrays)})
def handle_dispatch(:GUILD_CREATE, %Dexcord.Guild{} = guild, _raw, config) do
:ets.insert(@guilds, {guild.id, without_children(guild)})
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 ch <- guild.channels ++ guild.threads, do: put_guild_channel(ch, guild.id)
for role <- guild["roles"] || [],
is_binary(role["id"]),
do: :ets.insert(@roles, {{gid, role["id"]}, role})
for %Dexcord.Role{id: rid} = role <- guild.roles, not is_nil(rid) do
:ets.insert(@roles, {{guild.id, rid}, role})
end
for vs <- guild["voice_states"] || [], do: put_voice_state(gid, vs)
for m <- guild["members"] || [], do: put_member(gid, m)
for vs <- guild.voice_states, do: put_voice_state(guild.id, vs)
for m <- guild.members, do: put_member(guild.id, m)
if cache_presences?(config) do
for p <- guild["presences"] || [], do: put_presence(gid, p)
for p <- guild.presences, do: put_presence(guild.id, 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})
# GUILD_UPDATE carries no child arrays and omits gateway-only fields like
# `joined_at`; merge only the keys Discord actually sent so those survive
# (api-surface.AC2.21). A guild we've never seen is stored decoded-minus-children.
def handle_dispatch(:GUILD_UPDATE, %Dexcord.Guild{} = guild, raw, _config) do
partial = Map.drop(raw, @guild_child_keys)
case :ets.lookup(@guilds, guild.id) do
[{_, %Dexcord.Guild{} = existing}] ->
:ets.insert(@guilds, {guild.id, Dexcord.Guild.merge_map(existing, partial)})
_ ->
:ets.insert(@guilds, {guild.id, without_children(guild)})
end
:ok
end
def handle_dispatch(:GUILD_DELETE, data, _config) do
gid = data["id"]
def handle_dispatch(
:GUILD_EMOJIS_UPDATE,
%Dexcord.Events.GuildEmojisUpdate{} = e,
_raw,
_config
) do
update_guild(e.guild_id, fn g -> %{g | emojis: e.emojis} end)
end
if data["unavailable"] == true do
def handle_dispatch(
:GUILD_STICKERS_UPDATE,
%Dexcord.Events.GuildStickersUpdate{} = e,
_raw,
_config
) do
update_guild(e.guild_id, fn g -> %{g | stickers: e.stickers} end)
end
def handle_dispatch(:GUILD_DELETE, %Dexcord.UnavailableGuild{id: id} = stub, _raw, _config)
when not is_nil(id) do
if stub.unavailable do
# Guild went unavailable (outage), not removed: keep a stub placeholder.
:ets.insert(@guilds, {gid, data})
:ets.insert(@guilds, {id, stub})
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]}])
# The bot was removed (no `unavailable` key): cascade-purge every table.
# Tuple-keyed tables match on the guild-id prefix; channels are struct
# rows selected by their `guild_id` field (api-surface.AC2.22).
:ets.delete(@guilds, id)
:ets.match_delete(@members, {{id, :_}, :_})
:ets.match_delete(@roles, {{id, :_}, :_})
:ets.match_delete(@presences, {{id, :_}, :_})
:ets.match_delete(@voice_states, {{id, :_}, :_})
:ets.select_delete(@channels, [{{:_, %{guild_id: id}}, [], [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)
def handle_dispatch(name, channel, _raw, _config)
when name in [:CHANNEL_CREATE, :CHANNEL_UPDATE, :THREAD_CREATE, :THREAD_UPDATE] and
is_struct(channel) and not is_struct(channel, Dexcord.UnknownChannel) do
put_channel(channel)
: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)
def handle_dispatch(:CHANNEL_DELETE, channel, _raw, _config)
when is_struct(channel) and not is_struct(channel, Dexcord.UnknownChannel) do
if id = Map.get(channel, :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)
def handle_dispatch(:THREAD_DELETE, %Dexcord.Events.ThreadDelete{id: id}, _raw, _config)
when not is_nil(id) do
:ets.delete(@channels, id)
: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})
def handle_dispatch(:GUILD_MEMBER_ADD, %Dexcord.Member{} = member, _raw, _config) do
put_member(member.guild_id, member)
:ok
end
# GUILD_MEMBER_UPDATE is a partial: merge only the keys it sent into the cached
# row (keeping `user: nil`/`user_id`), and refresh the separate user record.
def handle_dispatch(
:GUILD_MEMBER_UPDATE,
%Dexcord.Events.GuildMemberUpdate{guild_id: gid, user: %Dexcord.User{id: uid} = user},
raw,
_config
)
when not is_nil(gid) and not is_nil(uid) do
upsert_user(user)
partial = Map.delete(raw, "user")
merged =
case :ets.lookup(@members, {gid, uid}) do
[{_, %Dexcord.Member{} = existing}] -> Dexcord.Member.merge_map(existing, partial)
_ -> Dexcord.Member.from_map(partial)
end
:ets.insert(@members, {{gid, uid}, %{merged | user: nil, user_id: uid}})
:ok
end
def handle_dispatch(
:GUILD_MEMBER_REMOVE,
%Dexcord.Events.GuildMemberRemove{guild_id: gid, user: %Dexcord.User{id: uid}},
_raw,
_config
)
when not is_nil(gid) and not is_nil(uid) do
:ets.delete(@members, {gid, uid})
:ok
end
def handle_dispatch(
:GUILD_MEMBERS_CHUNK,
%Dexcord.Events.GuildMembersChunk{} = chunk,
_raw,
config
) do
for m <- chunk.members, do: put_member(chunk.guild_id, m)
if cache_presences?(config) do
for p <- chunk.presences, do: put_presence(chunk.guild_id, p)
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)
def handle_dispatch(name, %{guild_id: gid, role: %Dexcord.Role{id: rid} = role}, _raw, _config)
when name in [:GUILD_ROLE_CREATE, :GUILD_ROLE_UPDATE] and not is_nil(gid) and
not is_nil(rid) do
:ets.insert(@roles, {{gid, rid}, role})
:ok
end
presences = data["presences"]
def handle_dispatch(
:GUILD_ROLE_DELETE,
%Dexcord.Events.GuildRoleDelete{guild_id: gid, role_id: rid},
_raw,
_config
)
when not is_nil(gid) and not is_nil(rid) do
:ets.delete(@roles, {gid, rid})
:ok
end
if is_list(presences) and cache_presences?(config) do
for p <- presences, do: put_presence(gid, p)
def handle_dispatch(:PRESENCE_UPDATE, %Dexcord.Presence{} = presence, _raw, 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(presence.guild_id, presence)
:ok
end
def handle_dispatch(:VOICE_STATE_UPDATE, %Dexcord.VoiceState{} = vs, _raw, _config) do
cond do
is_nil(vs.guild_id) or is_nil(vs.user_id) -> :ok
is_nil(vs.channel_id) -> :ets.delete(@voice_states, {vs.guild_id, vs.user_id})
true -> :ets.insert(@voice_states, {{vs.guild_id, vs.user_id}, vs})
end
:ok
end
def handle_dispatch(name, data, _config)
when name in [:GUILD_ROLE_CREATE, :GUILD_ROLE_UPDATE] do
gid = data["guild_id"]
def handle_dispatch(:USER_UPDATE, %Dexcord.User{} = user, raw, _config) do
case :ets.lookup(@me, :me) do
[{_, %Dexcord.User{} = existing}] ->
:ets.insert(@me, {:me, Dexcord.User.merge_map(existing, raw)})
case data["role"] do
%{"id" => rid} = role -> :ets.insert(@roles, {{gid, rid}, role})
_ ->
:ets.insert(@me, {:me, user})
end
:ok
end
# Opportunistic freshness: cache the (real, non-webhook) author and, when the
# message carries a guild member fragment, fold it into the members table.
# Webhook messages (`webhook_id` set) write nothing.
def handle_dispatch(
:MESSAGE_CREATE,
%Dexcord.Message{webhook_id: nil, author: %Dexcord.User{id: aid} = author} = message,
raw,
_config
)
when is_integer(aid) do
merge_user(aid, author, raw["author"])
if message.member && message.guild_id do
gid = message.guild_id
partial = Map.delete(raw["member"] || %{}, "user")
merged =
case :ets.lookup(@members, {gid, aid}) do
[{_, %Dexcord.Member{} = existing}] -> Dexcord.Member.merge_map(existing, partial)
_ -> Dexcord.Member.from_map(partial)
end
:ets.insert(@members, {{gid, aid}, %{merged | user: nil, user_id: aid}})
end
:ok
end
# Everything else - unknown events, events with no cache mapping, and any raw
# fallback map from a failed decode (which matches no struct clause) - is a no-op.
def handle_dispatch(_name, _decoded, _raw, _config), do: :ok
# --- write helpers ------------------------------------------------------
defp without_children(%Dexcord.Guild{} = guild) do
Enum.reduce(@guild_child_fields, guild, fn field, acc -> Map.put(acc, field, []) end)
end
# Apply `fun` to an existing guild row in place; a no-op if the guild is unknown.
defp update_guild(gid, fun) do
case :ets.lookup(@guilds, gid) do
[{_, %Dexcord.Guild{} = g}] -> :ets.insert(@guilds, {gid, fun.(g)})
_ -> :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(%Dexcord.User{} = user), do: :ets.insert(@me, {:me, user})
defp put_me(_), do: :ok
defp put_channel(%{"id" => id} = ch) when is_binary(id), do: :ets.insert(@channels, {id, ch})
# A guild's inline channels arrive without a `guild_id`; stamp it on the way in.
# `%Dexcord.UnknownChannel{}` has no `guild_id` field (and no stable id), so
# unknown-typed channels are a documented, bounded gap: they are not cached.
defp put_guild_channel(%Dexcord.UnknownChannel{}, _gid) do
Logger.debug("Dexcord.Cache: skipping unknown-typed channel in GUILD_CREATE fan-out")
:ok
end
defp put_guild_channel(%_{} = channel, gid), do: put_channel(%{channel | guild_id: gid})
defp put_channel(%_{id: id} = channel) when not is_nil(id),
do: :ets.insert(@channels, {id, channel})
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
# only a `user_id` back-reference. Members without a nested user are skipped.
defp put_member(gid, %Dexcord.Member{user: %Dexcord.User{id: uid} = user} = member)
when not is_nil(gid) and not is_nil(uid) do
upsert_user(user)
row = %{member | user: nil, user_id: uid}
merged =
existing(@members, {gid, uid})
|> Map.merge(Map.delete(member, "user"))
|> Map.put("user_id", uid)
case :ets.lookup(@members, {gid, uid}) do
[{_, %Dexcord.Member{} = existing}] -> merge_non_nil(existing, row)
_ -> row
end
: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, %Dexcord.VoiceState{user_id: uid} = vs)
when not is_nil(gid) and not is_nil(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})
# `presence.user` is `:raw` (Discord guarantees only its `id`); cast it here.
defp put_presence(gid, %Dexcord.Presence{user: user} = presence) when not is_nil(gid) do
case Dexcord.Snowflake.cast(user_id(user)) do
{:ok, uid} -> :ets.insert(@presences, {{gid, uid}, presence})
:error -> :ok
end
end
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 user_id(%{"id" => id}), do: id
defp user_id(_), do: nil
defp upsert_user(%Dexcord.User{id: uid} = user) when not is_nil(uid) do
merged =
case :ets.lookup(@users, uid) do
[{_, %Dexcord.User{} = existing}] -> merge_non_nil(existing, user)
_ -> user
end
:ets.insert(@users, {uid, merged})
end
defp upsert_user(_), do: :ok
defp existing(table, key) do
case :ets.lookup(table, key) do
[{_, v}] -> v
[] -> %{}
end
# Upsert a user, merging a raw partial fragment over any existing record so the
# fields the fragment lacks (e.g. a message author fragment vs. a full user) are
# preserved. Falls back to the decoded struct when there's no existing row.
defp merge_user(uid, %Dexcord.User{} = fallback, raw_fragment) do
merged =
case :ets.lookup(@users, uid) do
[{_, %Dexcord.User{} = existing}] -> Dexcord.User.merge_map(existing, raw_fragment || %{})
_ -> fallback
end
:ets.insert(@users, {uid, merged})
end
# Overlay `new` onto `existing`, keeping `existing`'s value wherever `new`'s is
# nil - so a fuller cached row is never clobbered by a sparser struct.
defp merge_non_nil(%module{} = existing, %module{} = new) do
Map.merge(existing, Map.from_struct(new), fn _k, ev, nv ->
if is_nil(nv), do: ev, else: nv
end)
end
# --- read API -----------------------------------------------------------
@typedoc "A cached entity, as its original string-keyed payload map."
@type entity :: map()
@typedoc "A cached entity, as its decoded model struct."
@type entity :: struct()
@typedoc "Any snowflake-castable id: an integer, a decimal string, or a struct with `:id`."
@type id :: Dexcord.Snowflake.t() | String.t() | struct()
@doc "The bot's own user object (from READY / USER_UPDATE)."
@spec me() :: {:ok, entity()} | :error
@spec me() :: {:ok, Dexcord.User.t()} | :error
def me, do: fetch(@me, :me)
@doc "Bang variant of `me/0`; raises if unset."
@spec me!() :: entity()
@spec me!() :: Dexcord.User.t()
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)
@spec guild(id()) :: {:ok, entity()} | :error
def guild(id) do
with {:ok, id} <- Dexcord.Snowflake.cast(id), do: fetch(@guilds, id)
end
@doc "Bang variant of `guild/1`."
@spec guild!(String.t()) :: entity()
@spec guild!(id()) :: entity()
def guild!(id), do: unwrap(guild(id), id)
@doc "All cached guilds (including unavailable stubs)."
@ -351,84 +494,158 @@ defmodule Dexcord.Cache do
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)
@spec channel(id()) :: {:ok, entity()} | :error
def channel(id) do
with {:ok, id} <- Dexcord.Snowflake.cast(id), do: fetch(@channels, id)
end
@doc "Bang variant of `channel/1`."
@spec channel!(String.t()) :: entity()
@spec channel!(id()) :: entity()
def channel!(id), do: unwrap(channel(id), id)
@doc "All cached channels/threads belonging to a guild."
@spec channels(String.t()) :: [entity()]
@spec channels(id()) :: [entity()]
def channels(guild_id) do
@channels
|> :ets.select([{{:_, %{"guild_id" => guild_id}}, [], [:"$_"]}])
|> Enum.map(&elem(&1, 1))
case Dexcord.Snowflake.cast(guild_id) do
{:ok, gid} ->
@channels
|> :ets.select([{{:_, %{guild_id: gid}}, [], [:"$_"]}])
|> Enum.map(&elem(&1, 1))
:error ->
[]
end
end
@doc "All cached threads (channel types 10/11/12) belonging to a guild."
@spec threads(String.t()) :: [entity()]
@doc "All cached threads belonging to a guild."
@spec threads(id()) :: [entity()]
def threads(guild_id) do
guild_id |> channels() |> Enum.filter(fn ch -> ch["type"] in @thread_types end)
guild_id |> channels() |> Enum.filter(&match?(%Dexcord.Thread{}, &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})
@spec member(id(), id()) :: {:ok, entity()} | :error
def member(guild_id, user_id), do: pair_fetch(@members, guild_id, user_id)
@doc "Bang variant of `member/2`."
@spec member!(String.t(), String.t()) :: entity()
@spec member!(id(), id()) :: 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()]
@spec members(id()) :: [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)
@spec user(id()) :: {:ok, entity()} | :error
def user(id) do
with {:ok, id} <- Dexcord.Snowflake.cast(id), do: fetch(@users, id)
end
@doc "Bang variant of `user/1`."
@spec user!(String.t()) :: entity()
@spec user!(id()) :: 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})
@spec role(id(), id()) :: {:ok, entity()} | :error
def role(guild_id, role_id), do: pair_fetch(@roles, guild_id, role_id)
@doc "Bang variant of `role/2`."
@spec role!(String.t(), String.t()) :: entity()
@spec role!(id(), id()) :: 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()]
@spec roles(id()) :: [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})
@spec presence(id(), id()) :: {:ok, entity()} | :error
def presence(guild_id, user_id), do: pair_fetch(@presences, guild_id, user_id)
@doc "Bang variant of `presence/2`."
@spec presence!(String.t(), String.t()) :: entity()
@spec presence!(id(), id()) :: 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()]
@spec presences(id()) :: [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})
@spec voice_state(id(), id()) :: {:ok, entity()} | :error
def voice_state(guild_id, user_id), do: pair_fetch(@voice_states, guild_id, user_id)
@doc "Bang variant of `voice_state/2`."
@spec voice_state!(String.t(), String.t()) :: entity()
@spec voice_state!(id(), id()) :: 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()]
@spec voice_states(id()) :: [entity()]
def voice_states(guild_id), do: prefix_values(@voice_states, guild_id)
# --- DM channel cache (written by the send funnel, see moduledoc) --------
@doc """
The cached DM channel id for a user, if one has been opened this session.
Populated lazily by `Dexcord.Api.send/2` the first time it DMs a user; a miss
is `:error` (the funnel then opens the DM and caches the result).
"""
@spec dm_channel(id()) :: {:ok, Dexcord.Snowflake.t()} | :error
def dm_channel(user_id) do
with {:ok, uid} <- Dexcord.Snowflake.cast(user_id), do: fetch(@dm_channels, uid)
end
@doc false
# Written by the `Dexcord.Api.send/2` funnel — the sole sanctioned non-Dispatcher
# writer (see moduledoc). Idempotent: DM channel ids are stable per recipient, so
# racing writers store the same value.
@spec put_dm_channel(id(), Dexcord.Snowflake.t()) :: :ok
def put_dm_channel(user_id, channel_id) do
with {:ok, uid} <- Dexcord.Snowflake.cast(user_id) do
:ets.insert(@dm_channels, {uid, channel_id})
end
:ok
end
# --- hydration ----------------------------------------------------------
@doc """
Best-effort hydration of an envelope struct's declared `hydrate` slots from
the cache. Each slot is filled from ETS by the id in its `from` field; a cache
miss (or a nil source id) leaves that slot `nil`. A struct with no `hydrate`
declarations is returned unchanged.
This function reads ETS **only** - it never issues HTTP and never blocks on the
network, so it is safe to call from any process on the hot path
(api-surface.AC3.8). It is meant to be called by **user handler code** when a
handler wants the related objects inline; the `Dexcord.Dispatcher` never calls
it (hydration is opt-in, not a cost paid on every event).
Already-populated slots are left alone, so `fill/1` is idempotent.
"""
@spec fill(struct()) :: struct()
def fill(%module{} = event) do
if function_exported?(module, :__hydrations__, 0) do
Enum.reduce(module.__hydrations__(), event, fn h, acc ->
with nil <- Map.fetch!(acc, h.name),
id when not is_nil(id) <- Map.fetch!(acc, h.from),
{:ok, value} <- fill_lookup(h.type, id) do
Map.put(acc, h.name, value)
else
_ -> acc
end
end)
else
event
end
end
defp fill_lookup(Dexcord.User, id), do: user(id)
defp fill_lookup(:channel, id), do: channel(id)
defp fill_lookup(Dexcord.Guild, id), do: guild(id)
defp fill_lookup(_type, _id), do: :error
# --- read helpers -------------------------------------------------------
defp cache_presences?(config), do: Map.get(config, :cache_presences, false)
@ -440,11 +657,21 @@ defmodule Dexcord.Cache do
end
end
defp pair_fetch(table, guild_id, x_id) do
with {:ok, gid} <- Dexcord.Snowflake.cast(guild_id),
{:ok, xid} <- Dexcord.Snowflake.cast(x_id),
do: fetch(table, {gid, xid})
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 prefix_values(table, guild_id) do
case Dexcord.Snowflake.cast(guild_id) do
{:ok, gid} -> :ets.select(table, [{{{gid, :_}, :"$1"}, [], [:"$1"]}])
:error -> []
end
end
defp unwrap({:ok, value}, _key), do: value
defp unwrap(:error, key), do: raise("Dexcord.Cache: no cached entry for #{inspect(key)}")

View file

@ -44,10 +44,11 @@ defmodule Dexcord.Config do
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)
@spec put_application_id(Dexcord.Snowflake.t() | String.t()) :: :ok
def put_application_id(id) when is_binary(id) or is_integer(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
@spec application_id() :: Dexcord.Snowflake.t() | String.t() | nil
def application_id, do: :persistent_term.get(@app_id_key, nil)
end

View file

@ -35,16 +35,21 @@ defmodule Dexcord.Dispatcher do
def handle_cast({:dispatch, name, data}, state) do
config = Dexcord.Config.get()
# Decode the wire payload exactly once, here, and feed the cache BOTH forms:
# the decoded struct drives inserts, the raw partial map drives merges. Decode
# never raises - a failure falls back to the raw map (see `Dexcord.Events`).
decoded = Dexcord.Events.decode(name, data)
# 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)
Dexcord.Cache.handle_dispatch(name, decoded, data, config)
maybe_request_members(name, data, config)
maybe_route_slash(name, data, config)
maybe_route_slash(name, decoded, data, config)
Task.Supervisor.start_child(@task_supervisor, fn ->
config.handler.handle_event({name, data})
config.handler.handle_event({name, decoded})
end)
{:noreply, state}
@ -71,22 +76,44 @@ defmodule Dexcord.Dispatcher do
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
# a separate Task, IN ADDITION to the handler (which always receives the event).
# Routing gates on the DECODED `%Dexcord.Interaction{}`: only the three top-level
# interaction types handled by the slash layer are routed - `:application_command`
# (2), `:message_component` (3), `:modal_submit` (5). Type 1 (PING) never arrives
# over the gateway and type 4 (`:application_command_autocomplete`) is left to the
# handler.
#
# If decode FELL BACK to the raw map (malformed interaction), the struct gate
# can't fire; we degrade to the old integer `"type"` gate and hand the RAW map to
# `Slash.dispatch/2`'s documented degraded head so a not-quite-decodable
# interaction still reaches the slash layer. A cleanly decoded interaction routes
# the typed `%Dexcord.Interaction{}` struct.
defp maybe_route_slash(:INTERACTION_CREATE, decoded, raw, config) 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)
cond do
is_nil(slash_mod) ->
:ok
match?(%Dexcord.Interaction{}, decoded) and
decoded.type in [:application_command, :message_component, :modal_submit] ->
route_slash(decoded, slash_mod)
is_map(raw) and raw["type"] in [2, 3, 5] ->
route_slash(raw, slash_mod)
true ->
:ok
end
end
defp maybe_route_slash(_name, _decoded, _raw, _config), do: :ok
defp route_slash(interaction, slash_mod) do
Task.Supervisor.start_child(@task_supervisor, fn ->
Dexcord.Slash.dispatch(interaction, slash_mod)
end)
:ok
end
defp maybe_route_slash(_name, _data, _config), do: :ok
end

63
lib/dexcord/enum.ex Normal file
View file

@ -0,0 +1,63 @@
defmodule Dexcord.Enum do
@moduledoc """
A DSL for Discord's open integer enums.
Discord adds enum values over time; decoding must not break when it does.
`decode/1` maps a recognized integer to its named atom and any other
integer to `{:unknown, n}`; `encode/1` reverses both losslessly.
defmodule Dexcord.MessageType do
use Dexcord.Enum, default: 0, reply: 19
end
Dexcord.MessageType.decode(19) #=> :reply
Dexcord.MessageType.decode(999) #=> {:unknown, 999}
Dexcord.MessageType.encode({:unknown, 999}) #=> 999
"""
defmacro __using__(values) do
unless is_list(values) and values != [] and Keyword.keyword?(values) do
raise ArgumentError,
"use Dexcord.Enum expects a non-empty keyword list of name: integer pairs"
end
names = Keyword.keys(values)
type_union =
Enum.reduce(names, quote(do: {:unknown, integer()}), fn name, acc ->
{:|, [], [name, acc]}
end)
decode_clauses =
for {name, int} <- values do
quote do
def decode(unquote(int)), do: unquote(name)
end
end
encode_clauses =
for {name, int} <- values do
quote do
def encode(unquote(name)), do: unquote(int)
end
end
quote do
@type t :: unquote(type_union)
@doc "Decodes a wire integer; unrecognized values become `{:unknown, n}`."
@spec decode(integer()) :: t()
unquote_splicing(decode_clauses)
def decode(n) when is_integer(n), do: {:unknown, n}
@doc "Encodes a named atom or `{:unknown, n}` back to the wire integer."
@spec encode(t()) :: integer()
unquote_splicing(encode_clauses)
def encode({:unknown, n}) when is_integer(n), do: n
@doc "The full name => integer table."
@spec values() :: %{atom() => integer()}
def values, do: unquote({:%{}, [], values})
end
end
end

139
lib/dexcord/events.ex Normal file
View file

@ -0,0 +1,139 @@
defmodule Dexcord.Events do
@moduledoc """
The gateway event decode table: maps every documented dispatch event atom
to its payload type and decodes raw payloads exactly once, in the
dispatcher. Decoding NEVER raises: failures log a warning (event name
only payloads may be huge or sensitive) and fall back to the raw map.
"""
require Logger
@full_object %{
GUILD_CREATE: Dexcord.Guild,
GUILD_UPDATE: Dexcord.Guild,
GUILD_DELETE: Dexcord.UnavailableGuild,
CHANNEL_CREATE: Dexcord.Channel,
CHANNEL_UPDATE: Dexcord.Channel,
CHANNEL_DELETE: Dexcord.Channel,
THREAD_CREATE: Dexcord.Channel,
THREAD_UPDATE: Dexcord.Channel,
THREAD_MEMBER_UPDATE: Dexcord.ThreadMember,
MESSAGE_CREATE: Dexcord.Message,
MESSAGE_UPDATE: Dexcord.Message,
GUILD_MEMBER_ADD: Dexcord.Member,
GUILD_AUDIT_LOG_ENTRY_CREATE: Dexcord.AuditLogEntry,
USER_UPDATE: Dexcord.User,
INTERACTION_CREATE: Dexcord.Interaction,
APPLICATION_COMMAND_PERMISSIONS_UPDATE: Dexcord.GuildApplicationCommandPermissions,
AUTO_MODERATION_RULE_CREATE: Dexcord.AutoModerationRule,
AUTO_MODERATION_RULE_UPDATE: Dexcord.AutoModerationRule,
AUTO_MODERATION_RULE_DELETE: Dexcord.AutoModerationRule,
GUILD_SCHEDULED_EVENT_CREATE: Dexcord.GuildScheduledEvent,
GUILD_SCHEDULED_EVENT_UPDATE: Dexcord.GuildScheduledEvent,
GUILD_SCHEDULED_EVENT_DELETE: Dexcord.GuildScheduledEvent,
INTEGRATION_CREATE: Dexcord.Integration,
INTEGRATION_UPDATE: Dexcord.Integration,
VOICE_STATE_UPDATE: Dexcord.VoiceState,
PRESENCE_UPDATE: Dexcord.Presence
}
@envelopes %{
READY: Dexcord.Events.Ready,
AUTO_MODERATION_ACTION_EXECUTION: Dexcord.Events.AutoModerationActionExecution,
CHANNEL_PINS_UPDATE: Dexcord.Events.ChannelPinsUpdate,
THREAD_DELETE: Dexcord.Events.ThreadDelete,
THREAD_LIST_SYNC: Dexcord.Events.ThreadListSync,
THREAD_MEMBERS_UPDATE: Dexcord.Events.ThreadMembersUpdate,
GUILD_BAN_ADD: Dexcord.Events.GuildBanAdd,
GUILD_BAN_REMOVE: Dexcord.Events.GuildBanRemove,
GUILD_EMOJIS_UPDATE: Dexcord.Events.GuildEmojisUpdate,
GUILD_STICKERS_UPDATE: Dexcord.Events.GuildStickersUpdate,
GUILD_INTEGRATIONS_UPDATE: Dexcord.Events.GuildIntegrationsUpdate,
GUILD_MEMBER_REMOVE: Dexcord.Events.GuildMemberRemove,
GUILD_MEMBER_UPDATE: Dexcord.Events.GuildMemberUpdate,
GUILD_MEMBERS_CHUNK: Dexcord.Events.GuildMembersChunk,
GUILD_ROLE_CREATE: Dexcord.Events.GuildRoleCreate,
GUILD_ROLE_UPDATE: Dexcord.Events.GuildRoleUpdate,
GUILD_ROLE_DELETE: Dexcord.Events.GuildRoleDelete,
GUILD_SCHEDULED_EVENT_USER_ADD: Dexcord.Events.GuildScheduledEventUserAdd,
GUILD_SCHEDULED_EVENT_USER_REMOVE: Dexcord.Events.GuildScheduledEventUserRemove,
INTEGRATION_DELETE: Dexcord.Events.IntegrationDelete,
INVITE_CREATE: Dexcord.Events.InviteCreate,
INVITE_DELETE: Dexcord.Events.InviteDelete,
MESSAGE_DELETE: Dexcord.Events.MessageDelete,
MESSAGE_DELETE_BULK: Dexcord.Events.MessageDeleteBulk,
MESSAGE_REACTION_ADD: Dexcord.Events.ReactionAdd,
MESSAGE_REACTION_REMOVE: Dexcord.Events.ReactionRemove,
MESSAGE_REACTION_REMOVE_ALL: Dexcord.Events.ReactionRemoveAll,
MESSAGE_REACTION_REMOVE_EMOJI: Dexcord.Events.ReactionRemoveEmoji,
MESSAGE_POLL_VOTE_ADD: Dexcord.Events.PollVoteAdd,
MESSAGE_POLL_VOTE_REMOVE: Dexcord.Events.PollVoteRemove,
TYPING_START: Dexcord.Events.TypingStart,
VOICE_CHANNEL_EFFECT_SEND: Dexcord.Events.VoiceChannelEffectSend,
VOICE_SERVER_UPDATE: Dexcord.Events.VoiceServerUpdate,
WEBHOOKS_UPDATE: Dexcord.Events.WebhooksUpdate
}
# Documented raw passthrough: no-payload markers and out-of-scope groups
# (monetization, stage instances) whose consumers use the raw map.
@passthrough [
:RESUMED,
:ENTITLEMENT_CREATE,
:ENTITLEMENT_UPDATE,
:ENTITLEMENT_DELETE,
:STAGE_INSTANCE_CREATE,
:STAGE_INSTANCE_UPDATE,
:STAGE_INSTANCE_DELETE
]
@doc "Payload classification for an event atom (used by tests and docs)."
@spec payload_type(atom()) ::
{:full, module()} | {:envelope, module()} | :passthrough | :unknown
def payload_type(name) do
cond do
is_map_key(@full_object, name) -> {:full, @full_object[name]}
is_map_key(@envelopes, name) -> {:envelope, @envelopes[name]}
name in @passthrough -> :passthrough
true -> :unknown
end
end
@doc """
Decodes a raw dispatch payload for `name`. Returns the decoded payload,
or the raw payload unchanged for passthrough/unknown events, or if
decoding fails or produces nil the raw payload (with a warning logged).
"""
@spec decode(atom() | {:unknown_event, String.t()}, term()) :: term()
def decode({:unknown_event, _name}, raw), do: raw
def decode(name, raw) when is_atom(name) do
case payload_type(name) do
{:full, mod} -> safe_decode(mod, name, raw)
{:envelope, mod} -> safe_decode(mod, name, raw)
:passthrough -> raw
:unknown -> raw
end
end
defp safe_decode(mod, name, raw) do
case mod.from_map(raw) do
nil ->
Logger.warning("Dexcord.Events: #{name} payload decoded to nil; delivering raw map")
raw
decoded ->
decoded
end
rescue
# Deliberately untestable defensive backstop. Under the Phase 1 tolerance
# contract `from_map/1` never raises — a non-map decodes to nil and hits the
# nil→raw fallback above (which IS tested). This rescue exists only to keep
# dispatch alive if a future decoder bug ever violates that contract.
error ->
Logger.warning(
"Dexcord.Events: decode failed for #{name} (#{inspect(error.__struct__)}); delivering raw map"
)
raw
end
end

View file

@ -0,0 +1,50 @@
defmodule Dexcord.Events.ChannelPinsUpdate do
@moduledoc "CHANNEL_PINS_UPDATE. https://docs.discord.com/developers/events/gateway-events#channel-pins-update"
use Dexcord.Struct
discord_struct do
field :guild_id, :snowflake
field :channel_id, :snowflake
field :last_pin_timestamp, :datetime, tristate: true
hydrate :channel, from: :channel_id, type: :channel
hydrate :guild, from: :guild_id, type: Dexcord.Guild
end
end
defmodule Dexcord.Events.ThreadDelete do
@moduledoc "THREAD_DELETE. https://docs.discord.com/developers/events/gateway-events#thread-delete"
use Dexcord.Struct
discord_struct do
field :id, :snowflake
field :guild_id, :snowflake
field :parent_id, :snowflake
field :type, {:enum, Dexcord.ChannelType}
hydrate :guild, from: :guild_id, type: Dexcord.Guild
end
end
defmodule Dexcord.Events.ThreadListSync do
@moduledoc "THREAD_LIST_SYNC. https://docs.discord.com/developers/events/gateway-events#thread-list-sync"
use Dexcord.Struct
discord_struct do
field :guild_id, :snowflake
field :channel_ids, {:list, :snowflake}, default: []
field :threads, {:list, {:struct, Dexcord.Channel}}, default: []
field :members, {:list, {:struct, Dexcord.ThreadMember}}, default: []
end
end
defmodule Dexcord.Events.ThreadMembersUpdate do
@moduledoc "THREAD_MEMBERS_UPDATE. https://docs.discord.com/developers/events/gateway-events#thread-members-update"
use Dexcord.Struct
discord_struct do
field :id, :snowflake
field :guild_id, :snowflake
field :member_count, :integer
field :added_members, {:list, {:struct, Dexcord.ThreadMember}}, default: []
field :removed_member_ids, {:list, :snowflake}, default: []
end
end

View file

@ -0,0 +1,166 @@
defmodule Dexcord.Events.GuildBanAdd do
@moduledoc "GUILD_BAN_ADD. https://docs.discord.com/developers/events/gateway-events#guild-ban-add"
use Dexcord.Struct
discord_struct do
field :guild_id, :snowflake
field :user, {:struct, Dexcord.User}
hydrate :guild, from: :guild_id, type: Dexcord.Guild
end
end
defmodule Dexcord.Events.GuildBanRemove do
@moduledoc "GUILD_BAN_REMOVE. https://docs.discord.com/developers/events/gateway-events#guild-ban-remove"
use Dexcord.Struct
discord_struct do
field :guild_id, :snowflake
field :user, {:struct, Dexcord.User}
hydrate :guild, from: :guild_id, type: Dexcord.Guild
end
end
defmodule Dexcord.Events.GuildEmojisUpdate do
@moduledoc "GUILD_EMOJIS_UPDATE. https://docs.discord.com/developers/events/gateway-events#guild-emojis-update"
use Dexcord.Struct
discord_struct do
field :guild_id, :snowflake
field :emojis, {:list, {:struct, Dexcord.Emoji}}, default: []
hydrate :guild, from: :guild_id, type: Dexcord.Guild
end
end
defmodule Dexcord.Events.GuildStickersUpdate do
@moduledoc "GUILD_STICKERS_UPDATE. https://docs.discord.com/developers/events/gateway-events#guild-stickers-update"
use Dexcord.Struct
discord_struct do
field :guild_id, :snowflake
field :stickers, {:list, {:struct, Dexcord.Sticker}}, default: []
end
end
defmodule Dexcord.Events.GuildIntegrationsUpdate do
@moduledoc "GUILD_INTEGRATIONS_UPDATE. https://docs.discord.com/developers/events/gateway-events#guild-integrations-update"
use Dexcord.Struct
discord_struct do
field :guild_id, :snowflake
end
end
defmodule Dexcord.Events.GuildMemberRemove do
@moduledoc "GUILD_MEMBER_REMOVE. https://docs.discord.com/developers/events/gateway-events#guild-member-remove"
use Dexcord.Struct
discord_struct do
field :guild_id, :snowflake
field :user, {:struct, Dexcord.User}
hydrate :guild, from: :guild_id, type: Dexcord.Guild
end
end
defmodule Dexcord.Events.GuildMemberUpdate do
@moduledoc """
GUILD_MEMBER_UPDATE.
This is NOT a `Dexcord.Member`: `joined_at` is nullable, there is no `flags`,
and several fields carry PATCH tristate semantics for the Phase 6 cache merge
(`:absent` means the key was not sent, distinct from an explicit `null`).
https://docs.discord.com/developers/events/gateway-events#guild-member-update
"""
use Dexcord.Struct
discord_struct do
field :guild_id, :snowflake
field :roles, {:list, :snowflake}, default: []
field :user, {:struct, Dexcord.User}
field :nick, :string, tristate: true
field :avatar, :string
field :banner, :string
field :joined_at, :datetime
field :premium_since, :datetime, tristate: true
field :deaf, :boolean
field :mute, :boolean
field :pending, :boolean
field :communication_disabled_until, :datetime, tristate: true
field :avatar_decoration_data, :raw, tristate: true
field :collectibles, :raw, tristate: true
end
end
defmodule Dexcord.Events.GuildMembersChunk do
@moduledoc "GUILD_MEMBERS_CHUNK. https://docs.discord.com/developers/events/gateway-events#guild-members-chunk"
use Dexcord.Struct
discord_struct do
field :guild_id, :snowflake
field :members, {:list, {:struct, Dexcord.Member}}, default: []
field :chunk_index, :integer
field :chunk_count, :integer
field :not_found, {:list, :raw}, default: []
field :presences, {:list, {:struct, Dexcord.Presence}}, default: []
field :nonce, :string
end
end
defmodule Dexcord.Events.GuildRoleCreate do
@moduledoc "GUILD_ROLE_CREATE. https://docs.discord.com/developers/events/gateway-events#guild-role-create"
use Dexcord.Struct
discord_struct do
field :guild_id, :snowflake
field :role, {:struct, Dexcord.Role}
hydrate :guild, from: :guild_id, type: Dexcord.Guild
end
end
defmodule Dexcord.Events.GuildRoleUpdate do
@moduledoc "GUILD_ROLE_UPDATE. https://docs.discord.com/developers/events/gateway-events#guild-role-update"
use Dexcord.Struct
discord_struct do
field :guild_id, :snowflake
field :role, {:struct, Dexcord.Role}
hydrate :guild, from: :guild_id, type: Dexcord.Guild
end
end
defmodule Dexcord.Events.GuildRoleDelete do
@moduledoc "GUILD_ROLE_DELETE. https://docs.discord.com/developers/events/gateway-events#guild-role-delete"
use Dexcord.Struct
discord_struct do
field :guild_id, :snowflake
field :role_id, :snowflake
hydrate :guild, from: :guild_id, type: Dexcord.Guild
end
end
defmodule Dexcord.Events.GuildScheduledEventUserAdd do
@moduledoc "GUILD_SCHEDULED_EVENT_USER_ADD. https://docs.discord.com/developers/events/gateway-events#guild-scheduled-event-user-add"
use Dexcord.Struct
discord_struct do
field :guild_scheduled_event_id, :snowflake
field :user_id, :snowflake
field :guild_id, :snowflake
hydrate :user, from: :user_id, type: Dexcord.User
hydrate :guild, from: :guild_id, type: Dexcord.Guild
end
end
defmodule Dexcord.Events.GuildScheduledEventUserRemove do
@moduledoc "GUILD_SCHEDULED_EVENT_USER_REMOVE. https://docs.discord.com/developers/events/gateway-events#guild-scheduled-event-user-remove"
use Dexcord.Struct
discord_struct do
field :guild_scheduled_event_id, :snowflake
field :user_id, :snowflake
field :guild_id, :snowflake
hydrate :user, from: :user_id, type: Dexcord.User
hydrate :guild, from: :guild_id, type: Dexcord.Guild
end
end

View file

@ -0,0 +1,123 @@
defmodule Dexcord.Events.MessageDelete do
@moduledoc "MESSAGE_DELETE. https://docs.discord.com/developers/events/gateway-events#message-delete"
use Dexcord.Struct
discord_struct do
field :id, :snowflake
field :channel_id, :snowflake
field :guild_id, :snowflake
hydrate :channel, from: :channel_id, type: :channel
hydrate :guild, from: :guild_id, type: Dexcord.Guild
end
end
defmodule Dexcord.Events.MessageDeleteBulk do
@moduledoc "MESSAGE_DELETE_BULK. https://docs.discord.com/developers/events/gateway-events#message-delete-bulk"
use Dexcord.Struct
discord_struct do
field :ids, {:list, :snowflake}, default: []
field :channel_id, :snowflake
field :guild_id, :snowflake
hydrate :channel, from: :channel_id, type: :channel
hydrate :guild, from: :guild_id, type: Dexcord.Guild
end
end
defmodule Dexcord.Events.ReactionAdd do
@moduledoc "MESSAGE_REACTION_ADD. https://docs.discord.com/developers/events/gateway-events#message-reaction-add"
use Dexcord.Struct
discord_struct do
field :user_id, :snowflake
field :channel_id, :snowflake
field :message_id, :snowflake
field :guild_id, :snowflake
field :member, {:struct, Dexcord.Member}
field :emoji, {:struct, Dexcord.PartialEmoji}
field :message_author_id, :snowflake
field :burst, :boolean
field :burst_colors, {:list, :string}, default: []
field :type, {:enum, Dexcord.ReactionType}
hydrate :user, from: :user_id, type: Dexcord.User
hydrate :channel, from: :channel_id, type: :channel
hydrate :guild, from: :guild_id, type: Dexcord.Guild
end
end
defmodule Dexcord.Events.ReactionRemove do
@moduledoc "MESSAGE_REACTION_REMOVE. https://docs.discord.com/developers/events/gateway-events#message-reaction-remove"
use Dexcord.Struct
discord_struct do
field :user_id, :snowflake
field :channel_id, :snowflake
field :message_id, :snowflake
field :guild_id, :snowflake
field :emoji, {:struct, Dexcord.PartialEmoji}
field :burst, :boolean
field :type, {:enum, Dexcord.ReactionType}
hydrate :user, from: :user_id, type: Dexcord.User
hydrate :channel, from: :channel_id, type: :channel
hydrate :guild, from: :guild_id, type: Dexcord.Guild
end
end
defmodule Dexcord.Events.ReactionRemoveAll do
@moduledoc "MESSAGE_REACTION_REMOVE_ALL. https://docs.discord.com/developers/events/gateway-events#message-reaction-remove-all"
use Dexcord.Struct
discord_struct do
field :channel_id, :snowflake
field :message_id, :snowflake
field :guild_id, :snowflake
hydrate :channel, from: :channel_id, type: :channel
hydrate :guild, from: :guild_id, type: Dexcord.Guild
end
end
defmodule Dexcord.Events.ReactionRemoveEmoji do
@moduledoc "MESSAGE_REACTION_REMOVE_EMOJI. https://docs.discord.com/developers/events/gateway-events#message-reaction-remove-emoji"
use Dexcord.Struct
discord_struct do
field :channel_id, :snowflake
field :guild_id, :snowflake
field :message_id, :snowflake
field :emoji, {:struct, Dexcord.PartialEmoji}
hydrate :channel, from: :channel_id, type: :channel
hydrate :guild, from: :guild_id, type: Dexcord.Guild
end
end
defmodule Dexcord.Events.PollVoteAdd do
@moduledoc "MESSAGE_POLL_VOTE_ADD. https://docs.discord.com/developers/events/gateway-events#message-poll-vote-add"
use Dexcord.Struct
discord_struct do
field :user_id, :snowflake
field :channel_id, :snowflake
field :message_id, :snowflake
field :guild_id, :snowflake
field :answer_id, :integer
hydrate :user, from: :user_id, type: Dexcord.User
hydrate :channel, from: :channel_id, type: :channel
hydrate :guild, from: :guild_id, type: Dexcord.Guild
end
end
defmodule Dexcord.Events.PollVoteRemove do
@moduledoc "MESSAGE_POLL_VOTE_REMOVE. https://docs.discord.com/developers/events/gateway-events#message-poll-vote-remove"
use Dexcord.Struct
discord_struct do
field :user_id, :snowflake
field :channel_id, :snowflake
field :message_id, :snowflake
field :guild_id, :snowflake
field :answer_id, :integer
hydrate :user, from: :user_id, type: Dexcord.User
hydrate :channel, from: :channel_id, type: :channel
hydrate :guild, from: :guild_id, type: Dexcord.Guild
end
end

View file

@ -0,0 +1,124 @@
defmodule Dexcord.Events.AutoModerationActionExecution do
@moduledoc "AUTO_MODERATION_ACTION_EXECUTION. https://docs.discord.com/developers/events/gateway-events#auto-moderation-action-execution"
use Dexcord.Struct
discord_struct do
field :guild_id, :snowflake
field :action, {:struct, Dexcord.AutoModerationAction}
field :rule_id, :snowflake
field :rule_trigger_type, {:enum, Dexcord.AutoModerationTriggerType}
field :user_id, :snowflake
field :channel_id, :snowflake
field :message_id, :snowflake
field :alert_system_message_id, :snowflake
field :content, :string
field :matched_keyword, :string
field :matched_content, :string
hydrate :user, from: :user_id, type: Dexcord.User
hydrate :channel, from: :channel_id, type: :channel
hydrate :guild, from: :guild_id, type: Dexcord.Guild
end
end
defmodule Dexcord.Events.IntegrationDelete do
@moduledoc "INTEGRATION_DELETE. https://docs.discord.com/developers/events/gateway-events#integration-delete"
use Dexcord.Struct
discord_struct do
field :id, :snowflake
field :guild_id, :snowflake
field :application_id, :snowflake
end
end
defmodule Dexcord.Events.InviteCreate do
@moduledoc "INVITE_CREATE. https://docs.discord.com/developers/events/gateway-events#invite-create"
use Dexcord.Struct
discord_struct do
field :channel_id, :snowflake
field :code, :string
field :created_at, :datetime
field :guild_id, :snowflake
field :inviter, {:struct, Dexcord.User}
field :max_age, :integer
field :max_uses, :integer
field :target_type, {:enum, Dexcord.InviteTargetType}
field :target_user, {:struct, Dexcord.User}
field :target_application, :raw
field :temporary, :boolean
field :uses, :integer
field :expires_at, :datetime
field :role_ids, {:list, :snowflake}, default: []
hydrate :channel, from: :channel_id, type: :channel
hydrate :guild, from: :guild_id, type: Dexcord.Guild
end
end
defmodule Dexcord.Events.InviteDelete do
@moduledoc "INVITE_DELETE. https://docs.discord.com/developers/events/gateway-events#invite-delete"
use Dexcord.Struct
discord_struct do
field :channel_id, :snowflake
field :guild_id, :snowflake
field :code, :string
hydrate :channel, from: :channel_id, type: :channel
hydrate :guild, from: :guild_id, type: Dexcord.Guild
end
end
defmodule Dexcord.Events.TypingStart do
@moduledoc "TYPING_START. `timestamp` is unix SECONDS (integer), NOT a datetime. https://docs.discord.com/developers/events/gateway-events#typing-start"
use Dexcord.Struct
discord_struct do
field :channel_id, :snowflake
field :guild_id, :snowflake
field :user_id, :snowflake
field :timestamp, :integer
field :member, {:struct, Dexcord.Member}
hydrate :user, from: :user_id, type: Dexcord.User
hydrate :channel, from: :channel_id, type: :channel
hydrate :guild, from: :guild_id, type: Dexcord.Guild
end
end
defmodule Dexcord.Events.VoiceChannelEffectSend do
@moduledoc "VOICE_CHANNEL_EFFECT_SEND. https://docs.discord.com/developers/events/gateway-events#voice-channel-effect-send"
use Dexcord.Struct
discord_struct do
field :channel_id, :snowflake
field :guild_id, :snowflake
field :user_id, :snowflake
field :emoji, :raw
field :animation_type, {:enum, Dexcord.VoiceAnimationType}
field :animation_id, :integer
field :sound_id, :raw
field :sound_volume, :number
end
end
defmodule Dexcord.Events.VoiceServerUpdate do
@moduledoc "VOICE_SERVER_UPDATE. A null `endpoint` means the server was lost; await reallocation. https://docs.discord.com/developers/events/gateway-events#voice-server-update"
use Dexcord.Struct
discord_struct do
field :token, :string
field :guild_id, :snowflake
field :endpoint, :string
end
end
defmodule Dexcord.Events.WebhooksUpdate do
@moduledoc "WEBHOOKS_UPDATE. https://docs.discord.com/developers/events/gateway-events#webhooks-update"
use Dexcord.Struct
discord_struct do
field :guild_id, :snowflake
field :channel_id, :snowflake
hydrate :channel, from: :channel_id, type: :channel
hydrate :guild, from: :guild_id, type: Dexcord.Guild
end
end

View file

@ -0,0 +1,14 @@
defmodule Dexcord.Events.Ready do
@moduledoc "The READY dispatch payload. https://docs.discord.com/developers/events/gateway-events#ready"
use Dexcord.Struct
discord_struct do
field :v, :integer
field :user, {:struct, Dexcord.User}
field :guilds, {:list, {:struct, Dexcord.UnavailableGuild}}, default: []
field :session_id, :string
field :resume_gateway_url, :string
field :shard, {:list, :integer}
field :application, :raw
end
end

60
lib/dexcord/flags.ex Normal file
View file

@ -0,0 +1,60 @@
defmodule Dexcord.Flags do
@moduledoc """
A DSL for Discord bitfields.
Struct fields declared `{:flags, Module}` store the **raw integer** so
unknown bits are never lost; the generated module provides named views:
defmodule Dexcord.MessageFlags do
use Dexcord.Flags, crossposted: 0, suppress_embeds: 2
end
Dexcord.MessageFlags.has?(4, :suppress_embeds) #=> true
Dexcord.MessageFlags.to_list(5) #=> [:crossposted, :suppress_embeds]
Dexcord.MessageFlags.from_list([:crossposted]) #=> 1
"""
defmacro __using__(bits) do
unless is_list(bits) and bits != [] and Keyword.keyword?(bits) do
raise ArgumentError,
"use Dexcord.Flags expects a non-empty keyword list of name: bit_position pairs"
end
flag_values =
for {name, pos} <- bits do
{name, Bitwise.bsl(1, pos)}
end
flag_map = {:%{}, [], flag_values}
quote do
@dexcord_flag_values unquote(flag_map)
@doc "All named flags as a name => integer-value map."
@spec all() :: %{atom() => pos_integer()}
def all, do: @dexcord_flag_values
@doc "Whether `int` has the named flag set."
@spec has?(integer(), atom()) :: boolean()
def has?(int, flag) when is_integer(int) and is_map_key(@dexcord_flag_values, flag),
do: Bitwise.band(int, @dexcord_flag_values[flag]) != 0
@doc "Named flags set in `int`, ordered by bit value. Unknown bits are omitted."
@spec to_list(integer()) :: [atom()]
def to_list(int) when is_integer(int) do
@dexcord_flag_values
|> Enum.filter(fn {_name, val} -> Bitwise.band(int, val) != 0 end)
|> Enum.sort_by(fn {_name, val} -> val end)
|> Enum.map(fn {name, _val} -> name end)
end
@doc "Combines named flags into an integer. Raises `KeyError` on unknown names."
@spec from_list([atom()]) :: non_neg_integer()
def from_list(flags) when is_list(flags) do
Enum.reduce(flags, 0, fn flag, acc ->
Bitwise.bor(acc, Map.fetch!(@dexcord_flag_values, flag))
end)
end
end
end
end

View file

@ -7,18 +7,28 @@ defmodule Dexcord.Handler do
injects a catch-all clause (via `@before_compile`) so unmatched events are
silently ignored - the user only writes the clauses they want.
The argument is a `{name, data}` tuple where `name` is a dispatch event atom
(e.g. `:MESSAGE_CREATE`) or `{:unknown_event, name}` for undocumented events,
and `data` is the raw string-keyed payload map.
The argument is a `{name, payload}` tuple where `name` is a dispatch event atom
(e.g. `:MESSAGE_CREATE`) or `{:unknown_event, name}` for undocumented events. The
`payload` is decoded exactly once, in the dispatcher, and its shape depends on the
event:
* **full-object events** (e.g. `:MESSAGE_CREATE`, `:GUILD_CREATE`) arrive as the
corresponding model struct - `%Dexcord.Message{}`, `%Dexcord.Guild{}`, etc.
* **envelope events** (e.g. `:GUILD_MEMBERS_CHUNK`, `:MESSAGE_REACTION_ADD`)
arrive as a `Dexcord.Events.*` struct.
* **passthrough, unknown, and decode-failure** payloads arrive as the raw
string-keyed map. A malformed payload that fails to decode logs a warning and
falls back to this raw form so dispatch never stalls.
defmodule MyBot.Handler do
use Dexcord.Handler
def handle_event({:MESSAGE_CREATE, msg}), do: IO.inspect(msg["content"])
def handle_event({:MESSAGE_CREATE, %Dexcord.Message{} = msg}),
do: IO.inspect(msg.content)
end
"""
@callback handle_event({atom() | {:unknown_event, String.t()}, map()}) :: any()
@callback handle_event({atom() | {:unknown_event, String.t()}, term()}) :: any()
defmacro __using__(_opts) do
quote do

View file

@ -0,0 +1,45 @@
defprotocol Dexcord.Messageable do
@moduledoc """
Anything a message can be sent to. `Dexcord.Api.send/2` resolves its target
through this protocol. Category/forum/media/directory channels deliberately
do NOT implement it sending to one fails with `Protocol.UndefinedError`
at resolve time, before any HTTP.
"""
@spec resolve(t) :: {:channel, Dexcord.Snowflake.t()} | {:dm_user, Dexcord.Snowflake.t()}
def resolve(target)
end
defimpl Dexcord.Messageable,
for: [
Dexcord.TextChannel,
Dexcord.AnnouncementChannel,
Dexcord.VoiceChannel,
Dexcord.StageChannel,
Dexcord.Thread,
Dexcord.DMChannel,
Dexcord.GroupDMChannel
] do
def resolve(%{id: id}), do: {:channel, id}
end
defimpl Dexcord.Messageable, for: Dexcord.Message do
def resolve(%{channel_id: id}), do: {:channel, id}
end
defimpl Dexcord.Messageable, for: Dexcord.Interaction do
def resolve(%{channel_id: id}) when not is_nil(id), do: {:channel, id}
end
defimpl Dexcord.Messageable, for: Dexcord.User do
def resolve(%{id: id}), do: {:dm_user, id}
end
defimpl Dexcord.Messageable, for: Dexcord.Member do
def resolve(%{user: %Dexcord.User{id: id}}), do: {:dm_user, id}
def resolve(%{user_id: id}) when not is_nil(id), do: {:dm_user, id}
end
defimpl Dexcord.Messageable, for: Integer do
def resolve(id) when id >= 0, do: {:channel, id}
end

View file

@ -0,0 +1,34 @@
defmodule Dexcord.AllowedMentions do
@moduledoc "Send-side allowed-mentions control. https://docs.discord.com/developers/resources/message"
use Dexcord.Struct
discord_struct do
field :parse, {:list, :string}, default: []
field :roles, {:list, :snowflake}
field :users, {:list, :snowflake}
field :replied_user, :boolean, default: false
end
@doc false
# Normalizes any allowed-mentions spec to a string-keyed wire map carrying only
# explicitly-provided keys (an absent key means "not set" for merge/2). A struct
# goes through to_map/1, so every non-nil field is explicit.
@spec normalize(nil | struct() | keyword() | map()) :: map() | nil
def normalize(nil), do: nil
def normalize(%{__struct__: __MODULE__} = am), do: to_map(am)
def normalize(kw) when is_list(kw), do: Map.new(kw, fn {k, v} -> {to_string(k), v} end)
def normalize(map) when is_map(map), do: Map.new(map, fn {k, v} -> {to_string(k), v} end)
@doc """
Field-wise merge of two normalized allowed-mentions maps: keys present in
`per_send` win; `default` fills the rest (discord.py's documented contract).
Callers own category-exclusivity like discord.py, this happily builds
combinations Discord would reject (e.g. `parse: ["users"]` alongside an explicit
`users:` list); it does not validate that.
"""
@spec merge(map() | nil, map() | nil) :: map() | nil
def merge(nil, per_send), do: per_send
def merge(default, nil), do: default
def merge(default, per_send), do: Map.merge(default, per_send)
end

View file

@ -0,0 +1,80 @@
defmodule Dexcord.ApplicationCommand do
@moduledoc "An application command. https://docs.discord.com/developers/interactions/application-commands"
use Dexcord.Struct
discord_struct do
field :id, :snowflake
field :type, {:enum, Dexcord.ApplicationCommandType}, default: :chat_input
field :application_id, :snowflake
field :guild_id, :snowflake
field :name, :string
field :name_localizations, :raw
field :description, :string
field :description_localizations, :raw
field :options, {:list, {:struct, Dexcord.ApplicationCommand.Option}}, default: []
field :default_member_permissions, {:flags, Dexcord.Permissions}, wire_string: true
field :dm_permission, :boolean
field :default_permission, :boolean
field :nsfw, :boolean, default: false
field :integration_types, {:list, {:enum, Dexcord.ApplicationIntegrationType}}
field :contexts, {:list, {:enum, Dexcord.InteractionContextType}}
field :version, :snowflake
field :handler, {:enum, Dexcord.EntryPointHandlerType}
end
end
defmodule Dexcord.ApplicationCommand.Option do
@moduledoc "A command option (recursive — sub-command groups nest options)."
use Dexcord.Struct
discord_struct do
field :type, {:enum, Dexcord.ApplicationCommandOptionType}
field :name, :string
field :name_localizations, :raw
field :description, :string
field :description_localizations, :raw
field :required, :boolean, default: false
field :choices, {:list, {:struct, Dexcord.ApplicationCommand.OptionChoice}}, default: []
field :options, {:list, {:struct, __MODULE__}}, default: []
field :channel_types, {:list, :integer}
field :min_value, :number
field :max_value, :number
field :min_length, :integer
field :max_length, :integer
field :autocomplete, :boolean
end
end
defmodule Dexcord.ApplicationCommand.OptionChoice do
@moduledoc "A choice for a command option."
use Dexcord.Struct
discord_struct do
field :name, :string
field :name_localizations, :raw
field :value, :raw
end
end
defmodule Dexcord.GuildApplicationCommandPermissions do
@moduledoc "The permissions for a command in a guild."
use Dexcord.Struct
discord_struct do
field :id, :snowflake
field :application_id, :snowflake
field :guild_id, :snowflake
field :permissions, {:list, {:struct, Dexcord.ApplicationCommandPermission}}, default: []
end
end
defmodule Dexcord.ApplicationCommandPermission do
@moduledoc "A single command permission override (role/user/channel target)."
use Dexcord.Struct
discord_struct do
field :id, :snowflake
field :type, {:enum, Dexcord.ApplicationCommandPermissionType}
field :permission, :boolean
end
end

View file

@ -0,0 +1,56 @@
defmodule Dexcord.InstallParams do
@moduledoc "OAuth2 install parameters for an application."
use Dexcord.Struct
discord_struct do
field :scopes, {:list, :string}, default: []
field :permissions, {:flags, Dexcord.Permissions}, wire_string: true
end
end
defmodule Dexcord.ApplicationInfo do
@moduledoc """
A Discord application object.
Named `ApplicationInfo` (not `Application`) to avoid colliding with the OTP
application module `Dexcord.Application`.
https://docs.discord.com/developers/resources/application
"""
use Dexcord.Struct
discord_struct do
field :id, :snowflake
field :name, :string
field :icon, :string
field :description, :string
field :rpc_origins, {:list, :string}, default: []
field :bot_public, :boolean
field :bot_require_code_grant, :boolean
field :bot, {:struct, Dexcord.User}
field :terms_of_service_url, :string
field :privacy_policy_url, :string
field :owner, {:struct, Dexcord.User}
field :verify_key, :string
field :team, {:struct, Dexcord.Team}
field :guild_id, :snowflake
field :guild, {:struct, Dexcord.PartialGuild}
field :primary_sku_id, :snowflake
field :slug, :string
field :cover_image, :string
field :flags, {:flags, Dexcord.ApplicationFlags}
field :approximate_guild_count, :integer
field :approximate_user_install_count, :integer
field :approximate_user_authorization_count, :integer
field :redirect_uris, {:list, :string}, default: []
field :interactions_endpoint_url, :string
field :role_connections_verification_url, :string
field :event_webhooks_url, :string
field :event_webhooks_status, {:enum, Dexcord.EventWebhooksStatus}
field :event_webhooks_types, {:list, :string}, default: []
field :tags, {:list, :string}, default: []
field :install_params, {:struct, Dexcord.InstallParams}
field :integration_types_config, :raw
field :custom_install_url, :string
end
end

View file

@ -0,0 +1,26 @@
defmodule Dexcord.Attachment do
@moduledoc "A message attachment. https://docs.discord.com/developers/resources/message"
use Dexcord.Struct
discord_struct do
field :id, :snowflake
field :filename, :string
field :title, :string
field :description, :string
field :content_type, :string
field :size, :integer
field :url, :string
field :proxy_url, :string
field :height, :integer
field :width, :integer
field :placeholder, :string
field :placeholder_version, :integer
field :ephemeral, :boolean
field :duration_secs, :number
field :waveform, :string
field :flags, {:flags, Dexcord.AttachmentFlags}
field :clip_participants, {:list, {:struct, Dexcord.User}}
field :clip_created_at, :datetime
field :application, :raw
end
end

View file

@ -0,0 +1,71 @@
defmodule Dexcord.AuditLogChange do
@moduledoc "A single changed key in an audit-log entry."
use Dexcord.Struct
discord_struct do
field :new_value, :raw
field :old_value, :raw
field :key, :string
end
end
defmodule Dexcord.AuditLogEntryInfo do
@moduledoc """
Optional extra info for certain audit-log actions.
Several numeric-looking fields (`count`, `delete_member_days`,
`members_removed`) are typed `:string` on purpose Discord genuinely
sends them as strings on the wire.
"""
use Dexcord.Struct
discord_struct do
field :application_id, :snowflake
field :auto_moderation_rule_name, :string
field :auto_moderation_rule_trigger_type, :string
field :channel_id, :snowflake
field :count, :string
field :delete_member_days, :string
field :id, :snowflake
field :members_removed, :string
field :message_id, :snowflake
field :role_name, :string
field :type, :string
field :integration_type, :string
field :status, :string
end
end
defmodule Dexcord.AuditLogEntry do
@moduledoc "A single audit-log entry. https://docs.discord.com/developers/resources/audit-log"
use Dexcord.Struct
discord_struct do
# String, not snowflake — can hold non-snowflake target ids.
field :target_id, :string
field :changes, {:list, {:struct, Dexcord.AuditLogChange}}, default: []
field :user_id, :snowflake
field :id, :snowflake
field :action_type, {:enum, Dexcord.AuditLogEvent}
field :options, {:struct, Dexcord.AuditLogEntryInfo}
field :reason, :string
# Gateway extra from GUILD_AUDIT_LOG_ENTRY_CREATE.
field :guild_id, :snowflake
end
end
defmodule Dexcord.AuditLog do
@moduledoc "A guild audit log. https://docs.discord.com/developers/resources/audit-log"
use Dexcord.Struct
discord_struct do
field :application_commands, {:list, {:struct, Dexcord.ApplicationCommand}}, default: []
field :audit_log_entries, {:list, {:struct, Dexcord.AuditLogEntry}}, default: []
field :auto_moderation_rules, {:list, {:struct, Dexcord.AutoModerationRule}}, default: []
field :guild_scheduled_events, {:list, {:struct, Dexcord.GuildScheduledEvent}}, default: []
field :integrations, {:list, {:struct, Dexcord.Integration}}, default: []
field :threads, {:list, {:struct, Dexcord.Channel}}, default: []
field :users, {:list, {:struct, Dexcord.User}}, default: []
field :webhooks, {:list, {:struct, Dexcord.Webhook}}, default: []
end
end

View file

@ -0,0 +1,53 @@
defmodule Dexcord.AutoModerationTriggerMetadata do
@moduledoc "Additional data used to determine whether an auto-moderation rule triggers."
use Dexcord.Struct
discord_struct do
field :keyword_filter, {:list, :string}, default: []
field :regex_patterns, {:list, :string}, default: []
field :presets, {:list, {:enum, Dexcord.KeywordPresetType}}, default: []
field :allow_list, {:list, :string}, default: []
field :mention_total_limit, :integer
field :mention_raid_protection_enabled, :boolean
end
end
defmodule Dexcord.AutoModerationActionMetadata do
@moduledoc "Additional data used when an auto-moderation action is executed."
use Dexcord.Struct
discord_struct do
field :channel_id, :snowflake
field :duration_seconds, :integer
field :custom_message, :string
end
end
defmodule Dexcord.AutoModerationAction do
@moduledoc "An action taken when an auto-moderation rule is triggered."
use Dexcord.Struct
discord_struct do
field :type, {:enum, Dexcord.AutoModerationActionType}
field :metadata, {:struct, Dexcord.AutoModerationActionMetadata}
end
end
defmodule Dexcord.AutoModerationRule do
@moduledoc "An auto-moderation rule. https://docs.discord.com/developers/resources/auto-moderation"
use Dexcord.Struct
discord_struct do
field :id, :snowflake
field :guild_id, :snowflake
field :name, :string
field :creator_id, :snowflake
field :event_type, {:enum, Dexcord.AutoModerationEventType}
field :trigger_type, {:enum, Dexcord.AutoModerationTriggerType}
field :trigger_metadata, {:struct, Dexcord.AutoModerationTriggerMetadata}
field :actions, {:list, {:struct, Dexcord.AutoModerationAction}}, default: []
field :enabled, :boolean
field :exempt_roles, {:list, :snowflake}, default: []
field :exempt_channels, {:list, :snowflake}, default: []
end
end

View file

@ -0,0 +1,329 @@
defmodule Dexcord.Model.ChannelShared do
@moduledoc false
# Field group shared by the guild-channel structs (everything with a guild).
def __included_fields__ do
[
{:id, :snowflake, []},
{:type, {:enum, Dexcord.ChannelType}, []},
{:guild_id, :snowflake, []},
{:name, :string, []},
{:position, :integer, []},
{:permission_overwrites, {:list, {:struct, Dexcord.Overwrite}}, [default: []]},
{:parent_id, :snowflake, []},
{:nsfw, :boolean, [default: false]},
{:flags, {:flags, Dexcord.ChannelFlags}, []}
]
end
end
defmodule Dexcord.TextChannel do
@moduledoc "A guild text channel (type 0)."
use Dexcord.Struct
discord_struct do
include_fields Dexcord.Model.ChannelShared
field :topic, :string
field :last_message_id, :snowflake
field :rate_limit_per_user, :integer
field :last_pin_timestamp, :datetime
field :default_auto_archive_duration, :integer
field :default_thread_rate_limit_per_user, :integer
end
@doc "The creation `DateTime` encoded in this channel's id, or `:error`."
@spec created_at(t()) :: {:ok, DateTime.t()} | :error
def created_at(%{id: id}), do: Dexcord.Snowflake.to_datetime(id)
end
defmodule Dexcord.AnnouncementChannel do
@moduledoc "A guild announcement channel (type 5)."
use Dexcord.Struct
discord_struct do
include_fields Dexcord.Model.ChannelShared
field :topic, :string
field :last_message_id, :snowflake
field :rate_limit_per_user, :integer
field :last_pin_timestamp, :datetime
field :default_auto_archive_duration, :integer
field :default_thread_rate_limit_per_user, :integer
end
@doc "The creation `DateTime` encoded in this channel's id, or `:error`."
@spec created_at(t()) :: {:ok, DateTime.t()} | :error
def created_at(%{id: id}), do: Dexcord.Snowflake.to_datetime(id)
end
defmodule Dexcord.VoiceChannel do
@moduledoc "A guild voice channel (type 2)."
use Dexcord.Struct
discord_struct do
include_fields Dexcord.Model.ChannelShared
field :bitrate, :integer
field :user_limit, :integer
field :rtc_region, :string
field :video_quality_mode, {:enum, Dexcord.VideoQualityMode}
field :last_message_id, :snowflake
field :rate_limit_per_user, :integer
end
@doc "The creation `DateTime` encoded in this channel's id, or `:error`."
@spec created_at(t()) :: {:ok, DateTime.t()} | :error
def created_at(%{id: id}), do: Dexcord.Snowflake.to_datetime(id)
end
defmodule Dexcord.StageChannel do
@moduledoc "A guild stage voice channel (type 13)."
use Dexcord.Struct
discord_struct do
include_fields Dexcord.Model.ChannelShared
field :bitrate, :integer
field :user_limit, :integer
field :rtc_region, :string
field :video_quality_mode, {:enum, Dexcord.VideoQualityMode}
field :last_message_id, :snowflake
field :rate_limit_per_user, :integer
end
@doc "The creation `DateTime` encoded in this channel's id, or `:error`."
@spec created_at(t()) :: {:ok, DateTime.t()} | :error
def created_at(%{id: id}), do: Dexcord.Snowflake.to_datetime(id)
end
defmodule Dexcord.CategoryChannel do
@moduledoc "A guild category channel (type 4)."
use Dexcord.Struct
discord_struct do
include_fields Dexcord.Model.ChannelShared
end
@doc "The creation `DateTime` encoded in this channel's id, or `:error`."
@spec created_at(t()) :: {:ok, DateTime.t()} | :error
def created_at(%{id: id}), do: Dexcord.Snowflake.to_datetime(id)
end
defmodule Dexcord.DirectoryChannel do
@moduledoc "A guild directory channel (type 14)."
use Dexcord.Struct
discord_struct do
include_fields Dexcord.Model.ChannelShared
end
@doc "The creation `DateTime` encoded in this channel's id, or `:error`."
@spec created_at(t()) :: {:ok, DateTime.t()} | :error
def created_at(%{id: id}), do: Dexcord.Snowflake.to_datetime(id)
end
defmodule Dexcord.ForumChannel do
@moduledoc "A guild forum channel (type 15)."
use Dexcord.Struct
discord_struct do
include_fields Dexcord.Model.ChannelShared
field :topic, :string
field :last_message_id, :snowflake
field :rate_limit_per_user, :integer
field :default_auto_archive_duration, :integer
field :default_thread_rate_limit_per_user, :integer
field :available_tags, {:list, {:struct, Dexcord.ForumTag}}, default: []
field :default_reaction_emoji, {:struct, Dexcord.DefaultReaction}
field :default_sort_order, {:enum, Dexcord.SortOrderType}
field :default_forum_layout, {:enum, Dexcord.ForumLayoutType}
end
@doc "The creation `DateTime` encoded in this channel's id, or `:error`."
@spec created_at(t()) :: {:ok, DateTime.t()} | :error
def created_at(%{id: id}), do: Dexcord.Snowflake.to_datetime(id)
end
defmodule Dexcord.MediaChannel do
@moduledoc "A guild media channel (type 16) — like a forum, without a layout mode."
use Dexcord.Struct
discord_struct do
include_fields Dexcord.Model.ChannelShared
field :topic, :string
field :last_message_id, :snowflake
field :rate_limit_per_user, :integer
field :default_auto_archive_duration, :integer
field :default_thread_rate_limit_per_user, :integer
field :available_tags, {:list, {:struct, Dexcord.ForumTag}}, default: []
field :default_reaction_emoji, {:struct, Dexcord.DefaultReaction}
field :default_sort_order, {:enum, Dexcord.SortOrderType}
end
@doc "The creation `DateTime` encoded in this channel's id, or `:error`."
@spec created_at(t()) :: {:ok, DateTime.t()} | :error
def created_at(%{id: id}), do: Dexcord.Snowflake.to_datetime(id)
end
defmodule Dexcord.DMChannel do
@moduledoc "A one-to-one DM channel (type 1). No guild fields."
use Dexcord.Struct
discord_struct do
field :id, :snowflake
field :type, {:enum, Dexcord.ChannelType}
field :flags, {:flags, Dexcord.ChannelFlags}
field :last_message_id, :snowflake
field :recipients, {:list, {:struct, Dexcord.User}}, default: []
field :last_pin_timestamp, :datetime
end
@doc "The creation `DateTime` encoded in this channel's id, or `:error`."
@spec created_at(t()) :: {:ok, DateTime.t()} | :error
def created_at(%{id: id}), do: Dexcord.Snowflake.to_datetime(id)
end
defmodule Dexcord.GroupDMChannel do
@moduledoc "A group DM channel (type 3)."
use Dexcord.Struct
discord_struct do
field :id, :snowflake
field :type, {:enum, Dexcord.ChannelType}
field :flags, {:flags, Dexcord.ChannelFlags}
field :last_message_id, :snowflake
field :recipients, {:list, {:struct, Dexcord.User}}, default: []
field :last_pin_timestamp, :datetime
field :name, :string
field :icon, :string
field :owner_id, :snowflake
field :application_id, :snowflake
field :managed, :boolean
end
@doc "The creation `DateTime` encoded in this channel's id, or `:error`."
@spec created_at(t()) :: {:ok, DateTime.t()} | :error
def created_at(%{id: id}), do: Dexcord.Snowflake.to_datetime(id)
end
defmodule Dexcord.Thread do
@moduledoc "A thread channel (types 10 announcement, 11 public, 12 private)."
use Dexcord.Struct
discord_struct do
field :id, :snowflake
field :type, {:enum, Dexcord.ChannelType}
field :guild_id, :snowflake
field :name, :string
field :flags, {:flags, Dexcord.ChannelFlags}
field :parent_id, :snowflake
field :owner_id, :snowflake
field :last_message_id, :snowflake
field :rate_limit_per_user, :integer
field :message_count, :integer
field :member_count, :integer
field :total_message_sent, :integer
field :thread_metadata, {:struct, Dexcord.ThreadMetadata}
field :member, {:struct, Dexcord.ThreadMember}
field :applied_tags, {:list, :snowflake}, default: []
field :last_pin_timestamp, :datetime
field :newly_created, :boolean, default: false
end
@doc "The creation `DateTime` encoded in this thread's id, or `:error`."
@spec created_at(t()) :: {:ok, DateTime.t()} | :error
def created_at(%{id: id}), do: Dexcord.Snowflake.to_datetime(id)
end
defmodule Dexcord.UnknownChannel do
@moduledoc "Fallback for channel types Dexcord doesn't recognize. Raw payload preserved."
defstruct [:type, :raw]
@type t :: %__MODULE__{type: {:unknown, integer()} | nil, raw: map()}
@doc false
def from_map(%{"type" => n} = map) when is_integer(n),
do: %__MODULE__{type: {:unknown, n}, raw: map}
def from_map(map) when is_map(map), do: %__MODULE__{type: nil, raw: map}
def from_map(_), do: nil
@doc false
# No __fields__/0 here (hand-written struct): re-encoding yields the raw
# payload verbatim. The Codec routes struct encodes through the struct's
# own to_map/1, so this keeps nested unknown channels encodable.
def to_map(%__MODULE__{raw: raw}), do: raw
end
defmodule Dexcord.Channel do
@moduledoc """
Channel factory: decodes a wire channel map into the struct for its type.
Capability is struct identity a `%Dexcord.CategoryChannel{}` is not
Messageable; sending to it fails at match time, by design.
"""
@type_map %{
0 => Dexcord.TextChannel,
1 => Dexcord.DMChannel,
2 => Dexcord.VoiceChannel,
3 => Dexcord.GroupDMChannel,
4 => Dexcord.CategoryChannel,
5 => Dexcord.AnnouncementChannel,
10 => Dexcord.Thread,
11 => Dexcord.Thread,
12 => Dexcord.Thread,
13 => Dexcord.StageChannel,
14 => Dexcord.DirectoryChannel,
15 => Dexcord.ForumChannel,
16 => Dexcord.MediaChannel
}
@type t ::
Dexcord.TextChannel.t()
| Dexcord.DMChannel.t()
| Dexcord.VoiceChannel.t()
| Dexcord.GroupDMChannel.t()
| Dexcord.CategoryChannel.t()
| Dexcord.AnnouncementChannel.t()
| Dexcord.Thread.t()
| Dexcord.StageChannel.t()
| Dexcord.DirectoryChannel.t()
| Dexcord.ForumChannel.t()
| Dexcord.MediaChannel.t()
| Dexcord.UnknownChannel.t()
@doc "Decodes a wire channel map into the struct for its `\"type\"`."
@spec from_map(term()) :: t() | nil
def from_map(%{"type" => type} = map) when is_map_key(@type_map, type),
do: @type_map[type].from_map(map)
def from_map(map) when is_map(map), do: Dexcord.UnknownChannel.from_map(map)
def from_map(_), do: nil
@doc """
The mention string (`<#id>`) for any channel struct with an integer `id` —
guild channels, threads, and DM/group-DM channels alike.
"""
@spec mention(%{id: Dexcord.Snowflake.t()}) :: String.t()
def mention(%{id: id}) when is_integer(id), do: "<##{id}>"
@doc false
def __type_map__, do: @type_map
end
# `String.Chars` for every channel struct: interpolating a channel produces a
# real `<#id>` mention (delegating to `Dexcord.Channel.mention/1`).
defimpl String.Chars,
for: [
Dexcord.TextChannel,
Dexcord.AnnouncementChannel,
Dexcord.VoiceChannel,
Dexcord.StageChannel,
Dexcord.CategoryChannel,
Dexcord.DirectoryChannel,
Dexcord.ForumChannel,
Dexcord.MediaChannel,
Dexcord.DMChannel,
Dexcord.GroupDMChannel,
Dexcord.Thread
] do
def to_string(channel), do: Dexcord.Channel.mention(channel)
end

View file

@ -0,0 +1,50 @@
defmodule Dexcord.ThreadMetadata do
@moduledoc false
use Dexcord.Struct
discord_struct do
field :archived, :boolean
field :auto_archive_duration, :integer
field :archive_timestamp, :datetime
field :locked, :boolean
field :invitable, :boolean
field :create_timestamp, :datetime
end
end
defmodule Dexcord.ThreadMember do
@moduledoc false
use Dexcord.Struct
discord_struct do
field :id, :snowflake
field :user_id, :snowflake
field :join_timestamp, :datetime
field :flags, :integer
field :member, {:struct, Dexcord.Member}
field :guild_id, :snowflake
end
end
defmodule Dexcord.ForumTag do
@moduledoc false
use Dexcord.Struct
discord_struct do
field :id, :snowflake
field :name, :string
field :moderated, :boolean
field :emoji_id, :snowflake
field :emoji_name, :string
end
end
defmodule Dexcord.DefaultReaction do
@moduledoc false
use Dexcord.Struct
discord_struct do
field :emoji_id, :snowflake
field :emoji_name, :string
end
end

View file

@ -0,0 +1,383 @@
defmodule Dexcord.Component.ActionRow do
@moduledoc "An action row (type 1): a container for a set of interactive components."
use Dexcord.Struct
discord_struct do
field :type, :integer, default: 1
field :id, :integer
field :components, {:list, {:struct, Dexcord.Component}}, default: []
end
end
defmodule Dexcord.Component.Button do
@moduledoc "A button (type 2)."
use Dexcord.Struct
discord_struct do
field :type, :integer, default: 2
field :id, :integer
field :style, {:enum, Dexcord.ButtonStyle}
field :label, :string
field :emoji, {:struct, Dexcord.PartialEmoji}
field :custom_id, :string
field :sku_id, :snowflake
field :url, :string
field :disabled, :boolean, default: false
end
end
defmodule Dexcord.Component.SelectOption do
@moduledoc "An option in a string select (type 3)."
use Dexcord.Struct
discord_struct do
field :label, :string
field :value, :string
field :description, :string
field :emoji, {:struct, Dexcord.PartialEmoji}
field :default, :boolean
end
end
defmodule Dexcord.Component.StringSelect do
@moduledoc "A string select menu (type 3)."
use Dexcord.Struct
discord_struct do
field :type, :integer, default: 3
field :id, :integer
field :custom_id, :string
field :options, {:list, {:struct, Dexcord.Component.SelectOption}}, default: []
field :placeholder, :string
field :min_values, :integer
field :max_values, :integer
field :required, :boolean
field :disabled, :boolean, default: false
end
end
defmodule Dexcord.Component.TextInput do
@moduledoc "A text input (type 4), used in modals."
use Dexcord.Struct
discord_struct do
field :type, :integer, default: 4
field :id, :integer
field :custom_id, :string
field :style, {:enum, Dexcord.TextInputStyle}
field :min_length, :integer
field :max_length, :integer
field :required, :boolean
field :value, :string
field :placeholder, :string
end
end
defmodule Dexcord.Component.SelectDefaultValue do
@moduledoc "A default value for an entity select (types 58)."
use Dexcord.Struct
discord_struct do
field :id, :snowflake
field :type, :string
end
end
defmodule Dexcord.Component.EntitySelect do
@moduledoc """
An entity select menu one struct for user (5), role (6), mentionable (7)
and channel (8) selects. They differ only by `type` and channel select's
extra `channel_types`.
"""
use Dexcord.Struct
discord_struct do
field :type, :integer
field :id, :integer
field :custom_id, :string
field :channel_types, {:list, :integer}
field :placeholder, :string
field :default_values, {:list, {:struct, Dexcord.Component.SelectDefaultValue}}, default: []
field :min_values, :integer
field :max_values, :integer
field :required, :boolean
field :disabled, :boolean, default: false
end
end
defmodule Dexcord.Component.UnfurledMediaItem do
@moduledoc "An unfurled media item, referenced by Components V2 media components."
use Dexcord.Struct
discord_struct do
field :url, :string
field :proxy_url, :string
field :height, :integer
field :width, :integer
field :placeholder, :string
field :placeholder_version, :integer
field :content_type, :string
field :flags, :integer
field :attachment_id, :snowflake
end
end
defmodule Dexcord.Component.Section do
@moduledoc "A section (type 9): text components with an accessory."
use Dexcord.Struct
discord_struct do
field :type, :integer, default: 9
field :id, :integer
field :components, {:list, {:struct, Dexcord.Component}}, default: []
field :accessory, {:struct, Dexcord.Component}
end
end
defmodule Dexcord.Component.TextDisplay do
@moduledoc "A text display (type 10)."
use Dexcord.Struct
discord_struct do
field :type, :integer, default: 10
field :id, :integer
field :content, :string
end
end
defmodule Dexcord.Component.Thumbnail do
@moduledoc "A thumbnail (type 11), used as a section accessory."
use Dexcord.Struct
discord_struct do
field :type, :integer, default: 11
field :id, :integer
field :media, {:struct, Dexcord.Component.UnfurledMediaItem}
field :description, :string
field :spoiler, :boolean, default: false
end
end
defmodule Dexcord.Component.MediaGalleryItem do
@moduledoc "An item in a media gallery (type 12)."
use Dexcord.Struct
discord_struct do
field :media, {:struct, Dexcord.Component.UnfurledMediaItem}
field :description, :string
field :spoiler, :boolean, default: false
end
end
defmodule Dexcord.Component.MediaGallery do
@moduledoc "A media gallery (type 12)."
use Dexcord.Struct
discord_struct do
field :type, :integer, default: 12
field :id, :integer
field :items, {:list, {:struct, Dexcord.Component.MediaGalleryItem}}, default: []
end
end
defmodule Dexcord.Component.File do
@moduledoc "A file (type 13)."
use Dexcord.Struct
discord_struct do
field :type, :integer, default: 13
field :id, :integer
field :file, {:struct, Dexcord.Component.UnfurledMediaItem}
field :spoiler, :boolean, default: false
field :name, :string
field :size, :integer
end
end
defmodule Dexcord.Component.Separator do
@moduledoc "A separator (type 14)."
use Dexcord.Struct
discord_struct do
field :type, :integer, default: 14
field :id, :integer
field :divider, :boolean, default: true
field :spacing, :integer
end
end
defmodule Dexcord.Component.Container do
@moduledoc "A container (type 17): a Components V2 grouping with an accent color."
use Dexcord.Struct
discord_struct do
field :type, :integer, default: 17
field :id, :integer
field :components, {:list, {:struct, Dexcord.Component}}, default: []
field :accent_color, :integer
field :spoiler, :boolean, default: false
end
end
defmodule Dexcord.Component.Label do
@moduledoc "A label (type 18): wraps a component with a label and description."
use Dexcord.Struct
discord_struct do
field :type, :integer, default: 18
field :id, :integer
field :label, :string
field :description, :string
field :component, {:struct, Dexcord.Component}
end
end
defmodule Dexcord.Component.FileUpload do
@moduledoc "A file upload (type 19)."
use Dexcord.Struct
discord_struct do
field :type, :integer, default: 19
field :id, :integer
field :custom_id, :string
field :min_values, :integer
field :max_values, :integer
field :required, :boolean
end
end
defmodule Dexcord.Component.GroupOption do
@moduledoc "An option in a radio group (21) or checkbox group (22)."
use Dexcord.Struct
discord_struct do
field :value, :string
field :label, :string
field :description, :string
field :default, :boolean
end
end
defmodule Dexcord.Component.RadioGroup do
@moduledoc "A radio group (type 21)."
use Dexcord.Struct
discord_struct do
field :type, :integer, default: 21
field :id, :integer
field :custom_id, :string
field :options, {:list, {:struct, Dexcord.Component.GroupOption}}, default: []
field :required, :boolean
end
end
defmodule Dexcord.Component.CheckboxGroup do
@moduledoc "A checkbox group (type 22)."
use Dexcord.Struct
discord_struct do
field :type, :integer, default: 22
field :id, :integer
field :custom_id, :string
field :options, {:list, {:struct, Dexcord.Component.GroupOption}}, default: []
field :min_values, :integer
field :max_values, :integer
field :required, :boolean
end
end
defmodule Dexcord.Component.Checkbox do
@moduledoc "A checkbox (type 23)."
use Dexcord.Struct
discord_struct do
field :type, :integer, default: 23
field :id, :integer
field :custom_id, :string
field :default, :boolean
end
end
defmodule Dexcord.Component.Unknown do
@moduledoc "Fallback for component types Dexcord doesn't recognize. Raw payload preserved."
defstruct [:type, :raw]
@type t :: %__MODULE__{type: {:unknown, integer()} | nil, raw: map()}
@doc false
def from_map(%{"type" => n} = map) when is_integer(n),
do: %__MODULE__{type: {:unknown, n}, raw: map}
def from_map(map) when is_map(map), do: %__MODULE__{type: nil, raw: map}
def from_map(_), do: nil
@doc false
# No __fields__/0 here (hand-written struct): re-encoding yields the raw
# payload verbatim. The Codec routes struct encodes through the struct's
# own to_map/1, so this keeps nested unknown components encodable.
def to_map(%__MODULE__{raw: raw}), do: raw
end
defmodule Dexcord.Component do
@moduledoc """
Component factory: decodes a wire component map into the struct for its type.
Components dispatch on the integer `"type"` field. Types 15, 16 and 20 are
unassigned in the current docs (real gaps) and fall through to
`Dexcord.Component.Unknown` along with any other unrecognized type.
"""
@type_map %{
1 => Dexcord.Component.ActionRow,
2 => Dexcord.Component.Button,
3 => Dexcord.Component.StringSelect,
4 => Dexcord.Component.TextInput,
5 => Dexcord.Component.EntitySelect,
6 => Dexcord.Component.EntitySelect,
7 => Dexcord.Component.EntitySelect,
8 => Dexcord.Component.EntitySelect,
9 => Dexcord.Component.Section,
10 => Dexcord.Component.TextDisplay,
11 => Dexcord.Component.Thumbnail,
12 => Dexcord.Component.MediaGallery,
13 => Dexcord.Component.File,
14 => Dexcord.Component.Separator,
17 => Dexcord.Component.Container,
18 => Dexcord.Component.Label,
19 => Dexcord.Component.FileUpload,
21 => Dexcord.Component.RadioGroup,
22 => Dexcord.Component.CheckboxGroup,
23 => Dexcord.Component.Checkbox
}
@type t ::
Dexcord.Component.ActionRow.t()
| Dexcord.Component.Button.t()
| Dexcord.Component.StringSelect.t()
| Dexcord.Component.TextInput.t()
| Dexcord.Component.EntitySelect.t()
| Dexcord.Component.Section.t()
| Dexcord.Component.TextDisplay.t()
| Dexcord.Component.Thumbnail.t()
| Dexcord.Component.MediaGallery.t()
| Dexcord.Component.File.t()
| Dexcord.Component.Separator.t()
| Dexcord.Component.Container.t()
| Dexcord.Component.Label.t()
| Dexcord.Component.FileUpload.t()
| Dexcord.Component.RadioGroup.t()
| Dexcord.Component.CheckboxGroup.t()
| Dexcord.Component.Checkbox.t()
| Dexcord.Component.Unknown.t()
@doc "Decodes a wire component map into the struct for its `\"type\"`."
@spec from_map(term()) :: t() | nil
def from_map(%{"type" => type} = map) when is_map_key(@type_map, type),
do: @type_map[type].from_map(map)
def from_map(map) when is_map(map), do: Dexcord.Component.Unknown.from_map(map)
def from_map(_), do: nil
@doc false
def __type_map__, do: @type_map
end

158
lib/dexcord/model/embed.ex Normal file
View file

@ -0,0 +1,158 @@
defmodule Dexcord.EmbedFooter do
@moduledoc false
use Dexcord.Struct
discord_struct do
field :text, :string
field :icon_url, :string
field :proxy_icon_url, :string
end
end
defmodule Dexcord.EmbedImage do
@moduledoc false
use Dexcord.Struct
discord_struct do
field :url, :string
field :proxy_url, :string
field :height, :integer
field :width, :integer
field :content_type, :string
field :placeholder, :string
field :placeholder_version, :integer
field :description, :string
field :flags, :integer
end
end
defmodule Dexcord.EmbedThumbnail do
@moduledoc false
use Dexcord.Struct
discord_struct do
field :url, :string
field :proxy_url, :string
field :height, :integer
field :width, :integer
field :content_type, :string
field :placeholder, :string
field :placeholder_version, :integer
field :description, :string
field :flags, :integer
end
end
defmodule Dexcord.EmbedVideo do
@moduledoc false
use Dexcord.Struct
discord_struct do
field :url, :string
field :proxy_url, :string
field :height, :integer
field :width, :integer
field :content_type, :string
field :placeholder, :string
field :placeholder_version, :integer
field :description, :string
field :flags, :integer
end
end
defmodule Dexcord.EmbedProvider do
@moduledoc false
use Dexcord.Struct
discord_struct do
field :name, :string
field :url, :string
end
end
defmodule Dexcord.EmbedAuthor do
@moduledoc false
use Dexcord.Struct
discord_struct do
field :name, :string
field :url, :string
field :icon_url, :string
field :proxy_icon_url, :string
end
end
defmodule Dexcord.EmbedField do
@moduledoc false
use Dexcord.Struct
discord_struct do
field :name, :string
field :value, :string
field :inline, :boolean, default: false
end
end
defmodule Dexcord.Embed do
@moduledoc "A message embed. https://docs.discord.com/developers/resources/message"
use Dexcord.Struct
discord_struct do
field :title, :string
field :type, :string
field :description, :string
field :url, :string
field :timestamp, :datetime
field :color, :integer
field :footer, {:struct, Dexcord.EmbedFooter}
field :image, {:struct, Dexcord.EmbedImage}
field :thumbnail, {:struct, Dexcord.EmbedThumbnail}
field :video, {:struct, Dexcord.EmbedVideo}
field :provider, {:struct, Dexcord.EmbedProvider}
field :author, {:struct, Dexcord.EmbedAuthor}
field :fields, {:list, {:struct, Dexcord.EmbedField}}, default: []
field :flags, :integer
end
@doc "A new embed. `opts` seed struct fields directly (e.g. `title:`, `color:`)."
@spec new(keyword()) :: t()
def new(opts \\ []), do: struct!(__MODULE__, opts)
@doc "Sets the embed title."
def title(embed, title), do: %{embed | title: title}
@doc "Sets the embed description."
def description(embed, description), do: %{embed | description: description}
@doc "Sets the embed url."
def url(embed, url), do: %{embed | url: url}
@doc "Sets the embed color (an integer)."
def color(embed, color) when is_integer(color), do: %{embed | color: color}
@doc "Sets the embed timestamp."
def timestamp(embed, %DateTime{} = dt), do: %{embed | timestamp: dt}
@doc "Appends a field. `inline:` defaults to `false`."
def field(embed, name, value, opts \\ []) do
f = %Dexcord.EmbedField{name: name, value: value, inline: Keyword.get(opts, :inline, false)}
%{embed | fields: embed.fields ++ [f]}
end
@doc "Sets the embed footer. `icon_url:` optional."
def footer(embed, text, opts \\ []),
do: %{embed | footer: %Dexcord.EmbedFooter{text: text, icon_url: opts[:icon_url]}}
@doc "Sets the embed image by url."
def image(embed, url), do: %{embed | image: %Dexcord.EmbedImage{url: url}}
@doc "Sets the embed thumbnail by url."
def thumbnail(embed, url), do: %{embed | thumbnail: %Dexcord.EmbedThumbnail{url: url}}
@doc "Sets the embed author. `url:`/`icon_url:` optional."
def author(embed, name, opts \\ []),
do: %{
embed
| author: %Dexcord.EmbedAuthor{name: name, url: opts[:url], icon_url: opts[:icon_url]}
}
end

View file

@ -0,0 +1,52 @@
defmodule Dexcord.Emoji do
@moduledoc "A guild emoji. https://docs.discord.com/developers/resources/emoji"
use Dexcord.Struct
discord_struct do
field :id, :snowflake
field :name, :string
field :roles, {:list, :snowflake}, default: []
field :user, {:struct, Dexcord.User}
field :require_colons, :boolean
field :managed, :boolean
field :animated, :boolean, default: false
field :available, :boolean
end
@doc "The creation `DateTime` encoded in this emoji's id, or `:error` (unicode/nil id)."
@spec created_at(t()) :: {:ok, DateTime.t()} | :error
def created_at(%{id: id}), do: Dexcord.Snowflake.to_datetime(id)
end
defmodule Dexcord.PartialEmoji do
@moduledoc """
The minimal emoji shape used in reactions, components, and poll media.
Unicode emoji: `id` nil, `name` the character. Custom: `id` set; `name`
may be nil when the emoji's data is unavailable (deleted custom emoji).
"""
use Dexcord.Struct
# `to_string/1` below shadows the auto-imported `Kernel.to_string/1`.
import Kernel, except: [to_string: 1]
discord_struct do
field :id, :snowflake
field :name, :string
field :animated, :boolean, default: false
end
@doc """
The send-format string for this emoji.
Custom static: `<:name:id>`; animated: `<a:name:id>`; unicode (nil id): the
raw `name` character.
"""
@spec to_string(t()) :: String.t()
def to_string(%{id: nil, name: name}), do: name
def to_string(%{id: id, name: name, animated: true}), do: "<a:#{name}:#{id}>"
def to_string(%{id: id, name: name}), do: "<:#{name}:#{id}>"
end
defimpl String.Chars, for: Dexcord.PartialEmoji do
def to_string(emoji), do: Dexcord.PartialEmoji.to_string(emoji)
end

398
lib/dexcord/model/enums.ex Normal file
View file

@ -0,0 +1,398 @@
# Model enums — all `Dexcord.Enum` declarations for the Phase 2 object graph.
#
# Tables transcribed verbatim from official Discord docs source (2026-07-05).
# Gaps in the numbering are REAL (Discord removed/reserved values) and must
# not be "fixed". Real gaps: MessageType 13, 30, 3335, 4043, 45;
# ChannelType 69.
defmodule Dexcord.ChannelType do
use Dexcord.Enum,
guild_text: 0,
dm: 1,
guild_voice: 2,
group_dm: 3,
guild_category: 4,
guild_announcement: 5,
announcement_thread: 10,
public_thread: 11,
private_thread: 12,
guild_stage_voice: 13,
guild_directory: 14,
guild_forum: 15,
guild_media: 16
end
defmodule Dexcord.MessageType do
use Dexcord.Enum,
default: 0,
recipient_add: 1,
recipient_remove: 2,
call: 3,
channel_name_change: 4,
channel_icon_change: 5,
channel_pinned_message: 6,
user_join: 7,
guild_boost: 8,
guild_boost_tier_1: 9,
guild_boost_tier_2: 10,
guild_boost_tier_3: 11,
channel_follow_add: 12,
guild_discovery_disqualified: 14,
guild_discovery_requalified: 15,
guild_discovery_grace_period_initial_warning: 16,
guild_discovery_grace_period_final_warning: 17,
thread_created: 18,
reply: 19,
chat_input_command: 20,
thread_starter_message: 21,
guild_invite_reminder: 22,
context_menu_command: 23,
auto_moderation_action: 24,
role_subscription_purchase: 25,
interaction_premium_upsell: 26,
stage_start: 27,
stage_end: 28,
stage_speaker: 29,
stage_topic: 31,
guild_application_premium_subscription: 32,
guild_incident_alert_mode_enabled: 36,
guild_incident_alert_mode_disabled: 37,
guild_incident_report_raid: 38,
guild_incident_report_false_alarm: 39,
purchase_notification: 44,
poll_result: 46
end
defmodule Dexcord.MessageReferenceType do
use Dexcord.Enum, default: 0, forward: 1
end
defmodule Dexcord.PremiumType do
use Dexcord.Enum, none: 0, nitro_classic: 1, nitro: 2, nitro_basic: 3
end
defmodule Dexcord.StickerType do
use Dexcord.Enum, standard: 1, guild: 2
end
defmodule Dexcord.StickerFormatType do
use Dexcord.Enum, png: 1, apng: 2, lottie: 3, gif: 4
end
defmodule Dexcord.VerificationLevel do
use Dexcord.Enum, none: 0, low: 1, medium: 2, high: 3, very_high: 4
end
defmodule Dexcord.DefaultMessageNotificationLevel do
use Dexcord.Enum, all_messages: 0, only_mentions: 1
end
defmodule Dexcord.ExplicitContentFilterLevel do
use Dexcord.Enum, disabled: 0, members_without_roles: 1, all_members: 2
end
defmodule Dexcord.MfaLevel do
use Dexcord.Enum, none: 0, elevated: 1
end
defmodule Dexcord.GuildNsfwLevel do
use Dexcord.Enum, default: 0, explicit: 1, safe: 2, age_restricted: 3
end
defmodule Dexcord.PremiumTier do
use Dexcord.Enum, none: 0, tier_1: 1, tier_2: 2, tier_3: 3
end
defmodule Dexcord.SortOrderType do
use Dexcord.Enum, latest_activity: 0, creation_date: 1
end
defmodule Dexcord.ForumLayoutType do
use Dexcord.Enum, not_set: 0, list_view: 1, gallery_view: 2
end
defmodule Dexcord.VideoQualityMode do
use Dexcord.Enum, auto: 1, full: 2
end
defmodule Dexcord.OverwriteType do
use Dexcord.Enum, role: 0, member: 1
end
defmodule Dexcord.ReactionType do
use Dexcord.Enum, normal: 0, burst: 1
end
defmodule Dexcord.InteractionType do
use Dexcord.Enum,
ping: 1,
application_command: 2,
message_component: 3,
application_command_autocomplete: 4,
modal_submit: 5
end
defmodule Dexcord.InteractionContextType do
use Dexcord.Enum, guild: 0, bot_dm: 1, private_channel: 2
end
defmodule Dexcord.ApplicationCommandType do
use Dexcord.Enum, chat_input: 1, user: 2, message: 3, primary_entry_point: 4
end
defmodule Dexcord.ApplicationCommandOptionType do
use Dexcord.Enum,
sub_command: 1,
sub_command_group: 2,
string: 3,
integer: 4,
boolean: 5,
user: 6,
channel: 7,
role: 8,
mentionable: 9,
number: 10,
attachment: 11
end
defmodule Dexcord.EntryPointHandlerType do
use Dexcord.Enum, app_handler: 1, discord_launch_activity: 2
end
defmodule Dexcord.ApplicationCommandPermissionType do
use Dexcord.Enum, role: 1, user: 2, channel: 3
end
# Component types 15, 16, 20 are unassigned in the current docs — real gaps.
defmodule Dexcord.ComponentType do
use Dexcord.Enum,
action_row: 1,
button: 2,
string_select: 3,
text_input: 4,
user_select: 5,
role_select: 6,
mentionable_select: 7,
channel_select: 8,
section: 9,
text_display: 10,
thumbnail: 11,
media_gallery: 12,
file: 13,
separator: 14,
container: 17,
label: 18,
file_upload: 19,
radio_group: 21,
checkbox_group: 22,
checkbox: 23
end
defmodule Dexcord.ButtonStyle do
use Dexcord.Enum, primary: 1, secondary: 2, success: 3, danger: 4, link: 5, premium: 6
end
defmodule Dexcord.TextInputStyle do
use Dexcord.Enum, short: 1, paragraph: 2
end
defmodule Dexcord.WebhookType do
use Dexcord.Enum, incoming: 1, channel_follower: 2, application: 3
end
defmodule Dexcord.InviteType do
use Dexcord.Enum, guild: 0, group_dm: 1, friend: 2
end
defmodule Dexcord.InviteTargetType do
use Dexcord.Enum, stream: 1, embedded_application: 2
end
# The complete AuditLogEvent table. The sparse ranges are REAL — Discord
# reserves whole numeric bands per object family, leaving large gaps.
defmodule Dexcord.AuditLogEvent do
use Dexcord.Enum,
guild_update: 1,
channel_create: 10,
channel_update: 11,
channel_delete: 12,
channel_overwrite_create: 13,
channel_overwrite_update: 14,
channel_overwrite_delete: 15,
member_kick: 20,
member_prune: 21,
member_ban_add: 22,
member_ban_remove: 23,
member_update: 24,
member_role_update: 25,
member_move: 26,
member_disconnect: 27,
bot_add: 28,
role_create: 30,
role_update: 31,
role_delete: 32,
invite_create: 40,
invite_update: 41,
invite_delete: 42,
webhook_create: 50,
webhook_update: 51,
webhook_delete: 52,
emoji_create: 60,
emoji_update: 61,
emoji_delete: 62,
message_delete: 72,
message_bulk_delete: 73,
message_pin: 74,
message_unpin: 75,
integration_create: 80,
integration_update: 81,
integration_delete: 82,
stage_instance_create: 83,
stage_instance_update: 84,
stage_instance_delete: 85,
sticker_create: 90,
sticker_update: 91,
sticker_delete: 92,
guild_scheduled_event_create: 100,
guild_scheduled_event_update: 101,
guild_scheduled_event_delete: 102,
thread_create: 110,
thread_update: 111,
thread_delete: 112,
application_command_permission_update: 121,
soundboard_sound_create: 130,
soundboard_sound_update: 131,
soundboard_sound_delete: 132,
auto_moderation_rule_create: 140,
auto_moderation_rule_update: 141,
auto_moderation_rule_delete: 142,
auto_moderation_block_message: 143,
auto_moderation_flag_to_channel: 144,
auto_moderation_user_communication_disabled: 145,
auto_moderation_quarantine_user: 146,
creator_monetization_request_created: 150,
creator_monetization_terms_accepted: 151,
onboarding_prompt_create: 163,
onboarding_prompt_update: 164,
onboarding_prompt_delete: 165,
onboarding_create: 166,
onboarding_update: 167,
home_settings_create: 190,
home_settings_update: 191,
voice_channel_status_create: 192,
voice_channel_status_delete: 193
end
# Trigger type 2 was removed — real gap.
defmodule Dexcord.AutoModerationTriggerType do
use Dexcord.Enum, keyword: 1, spam: 3, keyword_preset: 4, mention_spam: 5, member_profile: 6
end
defmodule Dexcord.KeywordPresetType do
use Dexcord.Enum, profanity: 1, sexual_content: 2, slurs: 3
end
defmodule Dexcord.AutoModerationEventType do
use Dexcord.Enum, message_send: 1, member_update: 2
end
defmodule Dexcord.AutoModerationActionType do
use Dexcord.Enum,
block_message: 1,
send_alert_message: 2,
timeout: 3,
block_member_interaction: 4
end
defmodule Dexcord.GuildScheduledEventPrivacyLevel do
use Dexcord.Enum, guild_only: 2
end
defmodule Dexcord.GuildScheduledEventEntityType do
use Dexcord.Enum, stage_instance: 1, voice: 2, external: 3
end
defmodule Dexcord.GuildScheduledEventStatus do
use Dexcord.Enum, scheduled: 1, active: 2, completed: 3, canceled: 4
end
defmodule Dexcord.RecurrenceRuleFrequency do
use Dexcord.Enum, yearly: 0, monthly: 1, weekly: 2, daily: 3
end
defmodule Dexcord.RecurrenceRuleWeekday do
use Dexcord.Enum,
monday: 0,
tuesday: 1,
wednesday: 2,
thursday: 3,
friday: 4,
saturday: 5,
sunday: 6
end
defmodule Dexcord.RecurrenceRuleMonth do
use Dexcord.Enum,
january: 1,
february: 2,
march: 3,
april: 4,
may: 5,
june: 6,
july: 7,
august: 8,
september: 9,
october: 10,
november: 11,
december: 12
end
defmodule Dexcord.PollLayoutType do
use Dexcord.Enum, default: 1
end
defmodule Dexcord.ApplicationIntegrationType do
use Dexcord.Enum, guild_install: 0, user_install: 1
end
defmodule Dexcord.EventWebhooksStatus do
use Dexcord.Enum, disabled: 1, enabled: 2, disabled_by_discord: 3
end
defmodule Dexcord.TeamMembershipState do
use Dexcord.Enum, invited: 1, accepted: 2
end
defmodule Dexcord.OnboardingMode do
use Dexcord.Enum, onboarding_default: 0, onboarding_advanced: 1
end
defmodule Dexcord.OnboardingPromptType do
use Dexcord.Enum, multiple_choice: 0, dropdown: 1
end
defmodule Dexcord.IntegrationExpireBehavior do
use Dexcord.Enum, remove_role: 0, kick: 1
end
defmodule Dexcord.ConnectionVisibility do
use Dexcord.Enum, none: 0, everyone: 1
end
defmodule Dexcord.ActivityType do
use Dexcord.Enum,
playing: 0,
streaming: 1,
listening: 2,
watching: 3,
custom: 4,
competing: 5
end
defmodule Dexcord.StatusDisplayType do
use Dexcord.Enum, name: 0, state: 1, details: 2
end
defmodule Dexcord.VoiceAnimationType do
use Dexcord.Enum, premium: 0, basic: 1
end

164
lib/dexcord/model/flags.ex Normal file
View file

@ -0,0 +1,164 @@
# Model flags — all `Dexcord.Flags` declarations (bit POSITIONS) for Phase 2.
#
# Tables transcribed verbatim from official Discord docs source (2026-07-05).
# Gaps in the bit positions are REAL and must not be "fixed". Real gaps:
# UserFlags bits 45, 1113, 15, 20+; Permissions bit 47; GuildMemberFlags
# bit 8; AttachmentFlags bit 4; ChannelFlags bits 0, 23, 514.
defmodule Dexcord.UserFlags do
use Dexcord.Flags,
staff: 0,
partner: 1,
hypesquad: 2,
bug_hunter_level_1: 3,
hypesquad_online_house_1: 6,
hypesquad_online_house_2: 7,
hypesquad_online_house_3: 8,
premium_early_supporter: 9,
team_pseudo_user: 10,
bug_hunter_level_2: 14,
verified_bot: 16,
verified_developer: 17,
certified_moderator: 18,
bot_http_interactions: 19
end
defmodule Dexcord.MessageFlags do
use Dexcord.Flags,
crossposted: 0,
is_crosspost: 1,
suppress_embeds: 2,
source_message_deleted: 3,
urgent: 4,
has_thread: 5,
ephemeral: 6,
loading: 7,
failed_to_mention_some_roles_in_thread: 8,
suppress_notifications: 12,
is_voice_message: 13,
has_snapshot: 14,
is_components_v2: 15
end
defmodule Dexcord.Permissions do
use Dexcord.Flags,
create_instant_invite: 0,
kick_members: 1,
ban_members: 2,
administrator: 3,
manage_channels: 4,
manage_guild: 5,
add_reactions: 6,
view_audit_log: 7,
priority_speaker: 8,
stream: 9,
view_channel: 10,
send_messages: 11,
send_tts_messages: 12,
manage_messages: 13,
embed_links: 14,
attach_files: 15,
read_message_history: 16,
mention_everyone: 17,
use_external_emojis: 18,
view_guild_insights: 19,
connect: 20,
speak: 21,
mute_members: 22,
deafen_members: 23,
move_members: 24,
use_vad: 25,
change_nickname: 26,
manage_nicknames: 27,
manage_roles: 28,
manage_webhooks: 29,
manage_guild_expressions: 30,
use_application_commands: 31,
request_to_speak: 32,
manage_events: 33,
manage_threads: 34,
create_public_threads: 35,
create_private_threads: 36,
use_external_stickers: 37,
send_messages_in_threads: 38,
use_embedded_activities: 39,
moderate_members: 40,
view_creator_monetization_analytics: 41,
use_soundboard: 42,
create_guild_expressions: 43,
create_events: 44,
use_external_sounds: 45,
send_voice_messages: 46,
set_voice_channel_status: 48,
send_polls: 49,
use_external_apps: 50,
pin_messages: 51,
bypass_slowmode: 52
end
defmodule Dexcord.SystemChannelFlags do
use Dexcord.Flags,
suppress_join_notifications: 0,
suppress_premium_subscriptions: 1,
suppress_guild_reminder_notifications: 2,
suppress_join_notification_replies: 3,
suppress_role_subscription_purchase_notifications: 4,
suppress_role_subscription_purchase_notification_replies: 5
end
defmodule Dexcord.GuildMemberFlags do
use Dexcord.Flags,
did_rejoin: 0,
completed_onboarding: 1,
bypasses_verification: 2,
started_onboarding: 3,
is_guest: 4,
started_home_actions: 5,
completed_home_actions: 6,
automod_quarantined_username: 7,
dm_settings_upsell_acknowledged: 9,
automod_quarantined_guild_tag: 10
end
defmodule Dexcord.AttachmentFlags do
use Dexcord.Flags, is_clip: 0, is_thumbnail: 1, is_remix: 2, is_spoiler: 3, is_animated: 5
end
defmodule Dexcord.ChannelFlags do
use Dexcord.Flags, pinned: 1, require_tag: 4, hide_media_download_options: 15
end
defmodule Dexcord.RoleFlags do
use Dexcord.Flags, in_prompt: 0
end
defmodule Dexcord.ApplicationFlags do
use Dexcord.Flags,
application_auto_moderation_rule_create_badge: 6,
gateway_presence: 12,
gateway_presence_limited: 13,
gateway_guild_members: 14,
gateway_guild_members_limited: 15,
verification_pending_guild_limit: 16,
embedded: 17,
gateway_message_content: 18,
gateway_message_content_limited: 19,
application_command_badge: 23
end
defmodule Dexcord.InviteFlags do
use Dexcord.Flags, is_guest_invite: 0
end
defmodule Dexcord.ActivityFlags do
use Dexcord.Flags,
instance: 0,
join: 1,
spectate: 2,
join_request: 3,
sync: 4,
play: 5,
party_privacy_friends: 6,
party_privacy_voice_channel: 7,
embedded: 8
end

217
lib/dexcord/model/guild.ex Normal file
View file

@ -0,0 +1,217 @@
defmodule Dexcord.Guild do
@moduledoc "A guild. https://docs.discord.com/developers/resources/guild"
use Dexcord.Struct
discord_struct do
field :id, :snowflake
field :name, :string
field :icon, :string
field :splash, :string
field :discovery_splash, :string
field :owner_id, :snowflake
field :afk_channel_id, :snowflake
field :afk_timeout, :integer
field :widget_enabled, :boolean
field :widget_channel_id, :snowflake
field :verification_level, {:enum, Dexcord.VerificationLevel}
field :default_message_notifications, {:enum, Dexcord.DefaultMessageNotificationLevel}
field :explicit_content_filter, {:enum, Dexcord.ExplicitContentFilterLevel}
field :roles, {:list, {:struct, Dexcord.Role}}, default: []
field :emojis, {:list, {:struct, Dexcord.Emoji}}, default: []
field :features, {:list, :string}, default: []
field :mfa_level, {:enum, Dexcord.MfaLevel}
field :application_id, :snowflake
field :system_channel_id, :snowflake
field :system_channel_flags, {:flags, Dexcord.SystemChannelFlags}
field :rules_channel_id, :snowflake
field :max_presences, :integer
field :max_members, :integer
field :vanity_url_code, :string
field :description, :string
field :banner, :string
field :premium_tier, {:enum, Dexcord.PremiumTier}
field :premium_subscription_count, :integer
field :preferred_locale, :string
field :public_updates_channel_id, :snowflake
field :max_video_channel_users, :integer
field :max_stage_video_channel_users, :integer
field :approximate_member_count, :integer
field :approximate_presence_count, :integer
field :welcome_screen, {:struct, Dexcord.WelcomeScreen}
field :nsfw_level, {:enum, Dexcord.GuildNsfwLevel}
field :stickers, {:list, {:struct, Dexcord.Sticker}}, default: []
field :premium_progress_bar_enabled, :boolean
field :safety_alerts_channel_id, :snowflake
field :incidents_data, :raw
# --- GUILD_CREATE gateway extension fields ---
field :joined_at, :datetime
field :large, :boolean
field :unavailable, :boolean, default: false
field :member_count, :integer
field :voice_states, {:list, {:struct, Dexcord.VoiceState}}, default: []
field :members, {:list, {:struct, Dexcord.Member}}, default: []
field :channels, {:list, {:struct, Dexcord.Channel}}, default: []
field :threads, {:list, {:struct, Dexcord.Channel}}, default: []
field :presences, {:list, {:struct, Dexcord.Presence}}, default: []
field :stage_instances, {:list, :raw}, default: []
field :guild_scheduled_events, {:list, {:struct, Dexcord.GuildScheduledEvent}}, default: []
field :soundboard_sounds, {:list, :raw}, default: []
end
@doc "The creation `DateTime` encoded in this guild's id, or `:error`."
@spec created_at(t()) :: {:ok, DateTime.t()} | :error
def created_at(%{id: id}), do: Dexcord.Snowflake.to_datetime(id)
import Bitwise
@all_permissions Dexcord.Permissions.all() |> Map.values() |> Enum.reduce(0, &Bitwise.bor/2)
@timeout_allowed Bitwise.bor(
Dexcord.Permissions.all()[:view_channel],
Dexcord.Permissions.all()[:read_message_history]
)
@doc """
Computes a member's effective permissions in the guild (arity 2) or in a
specific channel (arity 3, applying permission overwrites and the timeout
rule).
`guild.roles` must be populated when reading from the cache, set them first:
`%{guild | roles: Dexcord.Cache.roles(guild.id)}`. Returns the raw permissions
integer; combine with `Dexcord.Permissions.has?/2`.
Implements Discord's documented algorithm: owner → all; base = the @everyone
role (`role id == guild id`) OR'd with each member role; `ADMINISTRATOR` → all,
skipping overwrites entirely; otherwise channel overwrites in order
@everyone (deny then allow), aggregated role overwrites (all denies OR'd, all
allows OR'd, deny before allow), then the member overwrite; finally the timeout
rule (a member whose `communication_disabled_until` is in the future keeps only
`VIEW_CHANNEL | READ_MESSAGE_HISTORY`, unless owner/administrator a null or
past value is NOT a timeout).
"""
@spec member_permissions(t(), Dexcord.Member.t(), term()) :: non_neg_integer()
def member_permissions(guild, member, channel \\ nil)
def member_permissions(%{} = guild, member, channel) do
user_id = member_user_id(member)
cond do
guild.owner_id == user_id ->
@all_permissions
true ->
base = base_permissions(guild, member)
if has_flag?(base, :administrator) do
@all_permissions
else
base
|> apply_channel_overwrites(guild, member, user_id, channel)
|> apply_timeout(member)
end
end
end
defp member_user_id(member), do: member.user_id || (member.user && member.user.id)
defp has_flag?(perms, flag), do: Dexcord.Permissions.has?(perms, flag)
# base = @everyone role perms OR'd with each member role's perms (unknown role
# ids contribute nothing).
defp base_permissions(guild, member) do
Enum.reduce(member.roles, role_permissions(guild, guild.id), fn role_id, acc ->
bor(acc, role_permissions(guild, role_id))
end)
end
defp role_permissions(guild, role_id) do
case Enum.find(guild.roles, fn role -> role.id == role_id end) do
nil -> 0
role -> role.permissions || 0
end
end
defp apply_channel_overwrites(perms, _guild, _member, _user_id, nil), do: perms
defp apply_channel_overwrites(perms, guild, member, user_id, channel) do
overwrites = channel.permission_overwrites || []
perms
|> apply_everyone_overwrite(overwrites, guild.id)
|> apply_role_overwrites(overwrites, member.roles)
|> apply_member_overwrite(overwrites, user_id)
end
defp apply_everyone_overwrite(perms, overwrites, guild_id) do
case Enum.find(overwrites, fn ow -> ow.id == guild_id end) do
nil -> perms
ow -> apply_deny_allow(perms, ow.deny, ow.allow)
end
end
# All role overwrites for the member's roles are aggregated: denies OR'd, allows
# OR'd, then applied deny-before-allow as a single tier.
defp apply_role_overwrites(perms, overwrites, member_roles) do
{deny, allow} =
overwrites
|> Enum.filter(fn ow -> ow.id in member_roles end)
|> Enum.reduce({0, 0}, fn ow, {deny, allow} ->
{bor(deny, ow.deny || 0), bor(allow, ow.allow || 0)}
end)
apply_deny_allow(perms, deny, allow)
end
defp apply_member_overwrite(perms, overwrites, user_id) do
case Enum.find(overwrites, fn ow -> ow.id == user_id end) do
nil -> perms
ow -> apply_deny_allow(perms, ow.deny, ow.allow)
end
end
defp apply_deny_allow(perms, deny, allow) do
perms
|> band(bnot(deny || 0))
|> bor(allow || 0)
end
defp apply_timeout(perms, member) do
case member.communication_disabled_until do
%DateTime{} = cdu ->
if DateTime.compare(cdu, DateTime.utc_now()) == :gt do
band(perms, @timeout_allowed)
else
perms
end
_ ->
perms
end
end
end
defmodule Dexcord.UnavailableGuild do
@moduledoc "READY / outage guild stub. On GUILD_DELETE, `unavailable` absent means the bot was removed."
use Dexcord.Struct
discord_struct do
field :id, :snowflake
field :unavailable, :presence
end
end
defmodule Dexcord.PartialGuild do
@moduledoc "The GET /users/@me/guilds list shape."
use Dexcord.Struct
discord_struct do
field :id, :snowflake
field :name, :string
field :icon, :string
field :banner, :string
field :owner, :boolean
field :permissions, {:flags, Dexcord.Permissions}, wire_string: true
field :features, {:list, :string}, default: []
field :approximate_member_count, :integer
field :approximate_presence_count, :integer
end
end

View file

@ -0,0 +1,149 @@
defmodule Dexcord.WelcomeScreenChannel do
@moduledoc "A channel shown on a guild's welcome screen."
use Dexcord.Struct
discord_struct do
field :channel_id, :snowflake
field :description, :string
field :emoji_id, :snowflake
field :emoji_name, :string
end
end
defmodule Dexcord.WelcomeScreen do
@moduledoc "A guild welcome screen."
use Dexcord.Struct
discord_struct do
field :description, :string
field :welcome_channels, {:list, {:struct, Dexcord.WelcomeScreenChannel}}, default: []
end
end
defmodule Dexcord.Ban do
@moduledoc "A guild ban."
use Dexcord.Struct
discord_struct do
field :reason, :string
field :user, {:struct, Dexcord.User}
end
end
defmodule Dexcord.GuildWidgetSettings do
@moduledoc "A guild's widget settings."
use Dexcord.Struct
discord_struct do
field :enabled, :boolean
field :channel_id, :snowflake
end
end
defmodule Dexcord.GuildWidget do
@moduledoc "A guild widget."
use Dexcord.Struct
discord_struct do
field :id, :snowflake
field :name, :string
field :instant_invite, :string
field :channels, {:list, :raw}, default: []
field :members, {:list, :raw}, default: []
field :presence_count, :integer
end
end
defmodule Dexcord.OnboardingPromptOption do
@moduledoc "An option within an onboarding prompt."
use Dexcord.Struct
discord_struct do
field :id, :snowflake
field :channel_ids, {:list, :snowflake}, default: []
field :role_ids, {:list, :snowflake}, default: []
field :emoji, {:struct, Dexcord.PartialEmoji}
field :emoji_id, :snowflake
field :emoji_name, :string
field :emoji_animated, :boolean
field :title, :string
field :description, :string
end
end
defmodule Dexcord.OnboardingPrompt do
@moduledoc "A guild onboarding prompt."
use Dexcord.Struct
discord_struct do
field :id, :snowflake
field :type, {:enum, Dexcord.OnboardingPromptType}
field :options, {:list, {:struct, Dexcord.OnboardingPromptOption}}, default: []
field :title, :string
field :single_select, :boolean
field :required, :boolean
field :in_onboarding, :boolean
end
end
defmodule Dexcord.GuildOnboarding do
@moduledoc "A guild's onboarding flow. https://docs.discord.com/developers/resources/guild"
use Dexcord.Struct
discord_struct do
field :guild_id, :snowflake
field :prompts, {:list, {:struct, Dexcord.OnboardingPrompt}}, default: []
field :default_channel_ids, {:list, :snowflake}, default: []
field :enabled, :boolean
field :mode, {:enum, Dexcord.OnboardingMode}
end
end
defmodule Dexcord.IntegrationAccount do
@moduledoc "An integration account. Note: `id` is a plain string, NOT a snowflake."
use Dexcord.Struct
discord_struct do
field :id, :string
field :name, :string
end
end
defmodule Dexcord.IntegrationApplication do
@moduledoc "The bot/OAuth2 application associated with an integration."
use Dexcord.Struct
discord_struct do
field :id, :snowflake
field :name, :string
field :icon, :string
field :description, :string
field :bot, {:struct, Dexcord.User}
end
end
defmodule Dexcord.Integration do
@moduledoc "A guild integration. https://docs.discord.com/developers/resources/guild"
use Dexcord.Struct
discord_struct do
field :id, :snowflake
field :name, :string
field :type, :string
field :enabled, :boolean
field :syncing, :boolean
field :role_id, :snowflake
field :enable_emoticons, :boolean
field :expire_behavior, {:enum, Dexcord.IntegrationExpireBehavior}
field :expire_grace_period, :integer
field :user, {:struct, Dexcord.User}
field :account, {:struct, Dexcord.IntegrationAccount}
field :synced_at, :datetime
field :subscriber_count, :integer
field :revoked, :boolean
field :application, {:struct, Dexcord.IntegrationApplication}
field :scopes, {:list, :string}, default: []
# Gateway extra from INTEGRATION_CREATE/UPDATE.
field :guild_id, :snowflake
end
end

View file

@ -0,0 +1,158 @@
defmodule Dexcord.PartialChannel do
@moduledoc "The partial channel shape in interaction resolved data (current docs: 13 fields + thread_metadata)."
use Dexcord.Struct
discord_struct do
field :id, :snowflake
field :name, :string
field :type, {:enum, Dexcord.ChannelType}
field :permissions, {:flags, Dexcord.Permissions}, wire_string: true
field :last_message_id, :snowflake
field :last_pin_timestamp, :datetime
field :nsfw, :boolean, default: false
field :parent_id, :snowflake
field :guild_id, :snowflake
field :flags, {:flags, Dexcord.ChannelFlags}
field :rate_limit_per_user, :integer
field :topic, :string
field :position, :integer
field :thread_metadata, {:struct, Dexcord.ThreadMetadata}
end
end
defmodule Dexcord.ResolvedData do
@moduledoc "Interaction resolved data: id-keyed maps of (partial) objects."
use Dexcord.Struct
discord_struct do
field :users, {:id_map, {:struct, Dexcord.User}}, default: %{}
field :members, {:id_map, {:struct, Dexcord.PartialMember}}, default: %{}
field :roles, {:id_map, {:struct, Dexcord.Role}}, default: %{}
field :channels, {:id_map, {:struct, Dexcord.PartialChannel}}, default: %{}
field :messages, {:id_map, {:struct, Dexcord.Message}}, default: %{}
field :attachments, {:id_map, {:struct, Dexcord.Attachment}}, default: %{}
end
end
defmodule Dexcord.Interaction.DataOption do
@moduledoc "A single application-command interaction data option (recursive)."
use Dexcord.Struct
discord_struct do
field :name, :string
field :type, {:enum, Dexcord.ApplicationCommandOptionType}
field :value, :raw
field :options, {:list, {:struct, __MODULE__}}, default: []
field :focused, :boolean
end
end
defmodule Dexcord.Interaction.ApplicationCommandData do
@moduledoc "Interaction `data` for APPLICATION_COMMAND / autocomplete interactions."
use Dexcord.Struct
discord_struct do
field :id, :snowflake
field :name, :string
field :type, {:enum, Dexcord.ApplicationCommandType}
field :resolved, {:struct, Dexcord.ResolvedData}
field :options, {:list, {:struct, Dexcord.Interaction.DataOption}}, default: []
field :guild_id, :snowflake
field :target_id, :snowflake
end
end
defmodule Dexcord.Interaction.MessageComponentData do
@moduledoc "Interaction `data` for MESSAGE_COMPONENT interactions."
use Dexcord.Struct
discord_struct do
field :custom_id, :string
field :component_type, {:enum, Dexcord.ComponentType}
field :values, {:list, :string}, default: []
field :resolved, {:struct, Dexcord.ResolvedData}
end
end
defmodule Dexcord.Interaction.ModalSubmitData do
@moduledoc """
Interaction `data` for MODAL_SUBMIT interactions. `components` holds modal
RESPONSE shapes (not component definitions) deliberately raw.
"""
use Dexcord.Struct
discord_struct do
field :custom_id, :string
field :components, {:list, :raw}, default: []
field :resolved, {:struct, Dexcord.ResolvedData}
end
end
defmodule Dexcord.MessageInteraction do
@moduledoc """
The deprecated `message.interaction` shape (superseded by
`message.interaction_metadata`, still delivered on messages).
"""
use Dexcord.Struct
discord_struct do
field :id, :snowflake
field :type, {:enum, Dexcord.InteractionType}
field :name, :string
field :user, {:struct, Dexcord.User}
field :member, {:struct, Dexcord.PartialMember}
end
end
defmodule Dexcord.Interaction do
@moduledoc "An interaction. https://docs.discord.com/developers/interactions/receiving-and-responding"
use Dexcord.Struct
discord_struct do
field :id, :snowflake
field :application_id, :snowflake
field :type, {:enum, Dexcord.InteractionType}
field :data, :raw
field :guild, {:struct, Dexcord.Guild}
field :guild_id, :snowflake
field :channel, {:struct, Dexcord.PartialChannel}
field :channel_id, :snowflake
field :member, {:struct, Dexcord.Member}
field :user, {:struct, Dexcord.User}
field :token, :string
field :version, :integer
field :message, {:struct, Dexcord.Message}
field :app_permissions, {:flags, Dexcord.Permissions}, wire_string: true
field :locale, :string
field :guild_locale, :string
field :entitlements, :raw
field :authorizing_integration_owners, :raw
field :context, {:enum, Dexcord.InteractionContextType}
field :attachment_size_limit, :integer
end
@data_variants %{
application_command: Dexcord.Interaction.ApplicationCommandData,
application_command_autocomplete: Dexcord.Interaction.ApplicationCommandData,
message_component: Dexcord.Interaction.MessageComponentData,
modal_submit: Dexcord.Interaction.ModalSubmitData
}
# Polymorphic `data` decode: the generated `from_map/1` (Task 1's
# `defoverridable`) is post-processed to swap the raw `data` map for its
# typed variant based on the interaction `type`. NOTE: match the decoded
# struct as a plain map guarded by `is_struct/2` — `defstruct` is injected
# by `@before_compile`, so `%__MODULE__{}` is not usable in the body.
def from_map(map) do
case super(map) do
%{data: raw} = interaction when is_struct(interaction, __MODULE__) and is_map(raw) ->
case Map.fetch(@data_variants, interaction.type) do
{:ok, mod} -> %{interaction | data: mod.from_map(raw)}
:error -> interaction
end
other ->
other
end
end
end

View file

@ -0,0 +1,48 @@
defmodule Dexcord.Model.InviteShared do
@moduledoc false
# Field group shared by Dexcord.Invite and Dexcord.InviteMetadata
# (InviteMetadata is an Invite plus the extra metadata fields).
def __included_fields__ do
[
{:type, {:enum, Dexcord.InviteType}, []},
{:code, :string, []},
{:guild, {:struct, Dexcord.PartialGuild}, []},
{:channel, :raw, []},
{:inviter, {:struct, Dexcord.User}, []},
{:target_type, {:enum, Dexcord.InviteTargetType}, []},
{:target_user, {:struct, Dexcord.User}, []},
{:target_application, {:struct, Dexcord.ApplicationInfo}, []},
{:approximate_presence_count, :integer, []},
{:approximate_member_count, :integer, []},
{:expires_at, :datetime, []},
{:stage_instance, :raw, []},
{:guild_scheduled_event, {:struct, Dexcord.GuildScheduledEvent}, []},
{:flags, {:flags, Dexcord.InviteFlags}, []},
{:roles, {:list, :raw}, [default: []]}
]
end
end
defmodule Dexcord.Invite do
@moduledoc "A guild/group invite. https://docs.discord.com/developers/resources/invite"
use Dexcord.Struct
discord_struct do
include_fields Dexcord.Model.InviteShared
end
end
defmodule Dexcord.InviteMetadata do
@moduledoc "An invite plus extra metadata (uses, max_uses, max_age, temporary, created_at)."
use Dexcord.Struct
discord_struct do
include_fields Dexcord.Model.InviteShared
field :uses, :integer
field :max_uses, :integer
field :max_age, :integer
field :temporary, :boolean
field :created_at, :datetime
end
end

View file

@ -0,0 +1,66 @@
defmodule Dexcord.Model.MemberShared do
@moduledoc false
# Field group shared by Dexcord.Member and Dexcord.PartialMember.
def __included_fields__ do
[
{:guild_id, :snowflake, []},
# Cache-populated back-reference to the nested user's id. Decodes `nil` from
# real payloads (no such wire key); `Dexcord.Cache` sets it on write.
{:user_id, :snowflake, []},
{:nick, :string, []},
{:avatar, :string, []},
{:banner, :string, []},
{:roles, {:list, :snowflake}, [default: []]},
{:joined_at, :datetime, []},
{:premium_since, :datetime, []},
{:deaf, :boolean, []},
{:mute, :boolean, []},
{:flags, {:flags, Dexcord.GuildMemberFlags}, []},
{:pending, :boolean, []},
{:permissions, {:flags, Dexcord.Permissions}, [wire_string: true]},
{:communication_disabled_until, :datetime, []},
{:avatar_decoration_data, {:struct, Dexcord.AvatarDecorationData}, []},
{:collectibles, :raw, []}
]
end
end
defmodule Dexcord.Member do
@moduledoc "A guild member (with its user). https://docs.discord.com/developers/resources/guild"
use Dexcord.Struct
discord_struct do
field :user, {:struct, Dexcord.User}
include_fields Dexcord.Model.MemberShared
end
@doc "The mention string for this member (`<@id>`), from `user_id` or the nested user."
@spec mention(t()) :: String.t()
def mention(%{} = member), do: "<@#{member_user_id(member)}>"
@doc "The creation `DateTime` encoded in this member's user id, or `:error`."
@spec created_at(t()) :: {:ok, DateTime.t()} | :error
def created_at(%{} = member),
do: Dexcord.Snowflake.to_datetime(member_user_id(member))
defp member_user_id(%{user_id: user_id, user: user}) do
user_id || (user && user.id)
end
end
defimpl String.Chars, for: Dexcord.Member do
def to_string(member), do: Dexcord.Member.mention(member)
end
defmodule Dexcord.PartialMember do
@moduledoc """
A guild member without the nested user the shape Discord sends inside
MESSAGE_CREATE/UPDATE (`message.member`) and interaction resolved data.
"""
use Dexcord.Struct
discord_struct do
include_fields Dexcord.Model.MemberShared
end
end

View file

@ -0,0 +1,132 @@
defmodule Dexcord.Message do
@moduledoc "A Discord message. https://docs.discord.com/developers/resources/message"
use Dexcord.Struct
discord_struct do
field :id, :snowflake
field :channel_id, :snowflake
field :guild_id, :snowflake
field :author, {:struct, Dexcord.User}
field :member, {:struct, Dexcord.PartialMember}
field :content, :string
field :timestamp, :datetime
field :edited_timestamp, :datetime
field :tts, :boolean
field :mention_everyone, :boolean
field :mentions, {:list, {:struct, Dexcord.User}}, default: []
field :mention_roles, {:list, :snowflake}, default: []
field :mention_channels, {:list, {:struct, Dexcord.ChannelMention}}, default: []
field :attachments, {:list, {:struct, Dexcord.Attachment}}, default: []
field :embeds, {:list, {:struct, Dexcord.Embed}}, default: []
field :reactions, {:list, {:struct, Dexcord.Reaction}}, default: []
field :nonce, :raw
field :pinned, :boolean
field :webhook_id, :snowflake
field :type, {:enum, Dexcord.MessageType}
field :channel_type, :integer
field :activity, :raw
field :application, :raw
field :application_id, :snowflake
field :flags, {:flags, Dexcord.MessageFlags}
field :message_reference, {:struct, Dexcord.MessageReference}
field :message_snapshots, {:list, {:struct, Dexcord.MessageSnapshot}}
field :referenced_message, {:struct, __MODULE__}, tristate: true
field :interaction_metadata, :raw
field :interaction, :raw
field :thread, {:struct, Dexcord.Channel}
field :components, {:list, {:struct, Dexcord.Component}}, default: []
field :sticker_items, {:list, {:struct, Dexcord.StickerItem}}, default: []
field :stickers, {:list, {:struct, Dexcord.Sticker}}
field :position, :integer
field :role_subscription_data, :raw
field :resolved, {:struct, Dexcord.ResolvedData}
field :poll, {:struct, Dexcord.Poll}
field :call, {:struct, Dexcord.MessageCall}
field :shared_client_theme, :raw
end
@doc "The creation `DateTime` encoded in this message's id, or `:error`."
@spec created_at(t()) :: {:ok, DateTime.t()} | :error
def created_at(%{id: id}), do: Dexcord.Snowflake.to_datetime(id)
@doc """
Replies to this message. Sets `message_reference` to the source message and
routes through `Dexcord.Api.send/2` (so the allowed-mentions config default
merge applies).
`mention_author: true | false` overrides `allowed_mentions.replied_user`
(mirroring discord.py) it is applied AFTER the body is normalized, so it
wins over any `replied_user` a caller passed in the body. Everything else
behaves like `Dexcord.Api.send/3`.
"""
@spec reply(t(), term(), keyword()) ::
{:ok, t()} | {:error, Dexcord.Api.Error.t()}
def reply(msg, body, opts \\ [])
def reply(%{id: id, channel_id: channel_id}, body, opts) do
{mention_author, opts} = Keyword.pop(opts, :mention_author)
body =
body
|> Dexcord.Api.Endpoint.encode_body(%{binary_wrap: :content})
|> Map.put("message_reference", %{"message_id" => Dexcord.Snowflake.dump(id)})
|> apply_mention_author(mention_author)
Dexcord.Api.send(channel_id, body, opts)
end
defp apply_mention_author(body, nil), do: body
defp apply_mention_author(body, flag) when is_boolean(flag) do
Map.update(
body,
"allowed_mentions",
%{"replied_user" => flag},
&Map.put(&1, "replied_user", flag)
)
end
end
defmodule Dexcord.MessageReference do
@moduledoc false
use Dexcord.Struct
discord_struct do
field :type, {:enum, Dexcord.MessageReferenceType}
field :message_id, :snowflake
field :channel_id, :snowflake
field :guild_id, :snowflake
field :fail_if_not_exists, :boolean
end
end
defmodule Dexcord.MessageSnapshot do
@moduledoc "A forwarded-message snapshot (partial message; no author)."
use Dexcord.Struct
discord_struct do
field :message, {:struct, Dexcord.Message}
end
end
defmodule Dexcord.MessageCall do
@moduledoc false
use Dexcord.Struct
discord_struct do
field :participants, {:list, :snowflake}, default: []
field :ended_timestamp, :datetime
end
end
defmodule Dexcord.ChannelMention do
@moduledoc false
use Dexcord.Struct
discord_struct do
field :id, :snowflake
field :guild_id, :snowflake
field :type, {:enum, Dexcord.ChannelType}
field :name, :string
end
end

67
lib/dexcord/model/misc.ex Normal file
View file

@ -0,0 +1,67 @@
defmodule Dexcord.FollowedChannel do
@moduledoc "A followed announcement channel."
use Dexcord.Struct
discord_struct do
field :channel_id, :snowflake
field :webhook_id, :snowflake
end
end
defmodule Dexcord.VoiceRegion do
@moduledoc "A voice region. https://docs.discord.com/developers/resources/voice"
use Dexcord.Struct
discord_struct do
field :id, :string
field :name, :string
field :optimal, :boolean
field :deprecated, :boolean
field :custom, :boolean
end
end
defmodule Dexcord.Connection do
@moduledoc "A user connection to an external service."
use Dexcord.Struct
discord_struct do
field :id, :string
field :name, :string
field :type, :string
field :revoked, :boolean
field :integrations, :raw
field :verified, :boolean
field :friend_sync, :boolean
field :show_activity, :boolean
field :two_way_link, :boolean
field :visibility, {:enum, Dexcord.ConnectionVisibility}
end
end
defmodule Dexcord.Activity do
@moduledoc "A presence activity. https://docs.discord.com/developers/topics/gateway-events#activity-object"
use Dexcord.Struct
discord_struct do
field :name, :string
field :type, {:enum, Dexcord.ActivityType}
field :url, :string
# Unix milliseconds — NOT an ISO8601 datetime.
field :created_at, :integer
field :timestamps, :raw
field :application_id, :snowflake
field :status_display_type, {:enum, Dexcord.StatusDisplayType}
field :details, :string
field :details_url, :string
field :state, :string
field :state_url, :string
field :emoji, :raw
field :party, :raw
field :assets, :raw
field :secrets, :raw
field :instance, :boolean
field :flags, {:flags, Dexcord.ActivityFlags}
field :buttons, {:list, :string}
end
end

View file

@ -0,0 +1,11 @@
defmodule Dexcord.Overwrite do
@moduledoc "A channel permission overwrite."
use Dexcord.Struct
discord_struct do
field :id, :snowflake
field :type, {:enum, Dexcord.OverwriteType}
field :allow, {:flags, Dexcord.Permissions}, wire_string: true
field :deny, {:flags, Dexcord.Permissions}, wire_string: true
end
end

55
lib/dexcord/model/poll.ex Normal file
View file

@ -0,0 +1,55 @@
defmodule Dexcord.PollMedia do
@moduledoc "The text/emoji content of a poll question or answer."
use Dexcord.Struct
discord_struct do
field :text, :string
field :emoji, {:struct, Dexcord.PartialEmoji}
end
end
defmodule Dexcord.PollAnswer do
@moduledoc "A single poll answer."
use Dexcord.Struct
discord_struct do
field :answer_id, :integer
field :poll_media, {:struct, Dexcord.PollMedia}
end
end
defmodule Dexcord.PollAnswerCount do
@moduledoc "The vote tally for one poll answer."
use Dexcord.Struct
discord_struct do
field :id, :integer
field :count, :integer
field :me_voted, :boolean
end
end
defmodule Dexcord.PollResults do
@moduledoc "The tallied results of a poll."
use Dexcord.Struct
discord_struct do
field :is_finalized, :boolean
field :answer_counts, {:list, {:struct, Dexcord.PollAnswerCount}}, default: []
end
end
defmodule Dexcord.Poll do
@moduledoc "A message poll. https://docs.discord.com/developers/resources/poll"
use Dexcord.Struct
discord_struct do
field :question, {:struct, Dexcord.PollMedia}
field :answers, {:list, {:struct, Dexcord.PollAnswer}}, default: []
field :expiry, :datetime
field :allow_multiselect, :boolean
field :layout_type, {:enum, Dexcord.PollLayoutType}
# Tristate: absence does NOT mean "no votes" — Discord genuinely omits it.
field :results, {:struct, Dexcord.PollResults}, tristate: true
end
end

View file

@ -0,0 +1,23 @@
defmodule Dexcord.Presence do
@moduledoc "A presence update. `user` is raw — Discord guarantees only its `id` key."
use Dexcord.Struct
discord_struct do
field :user, :raw
field :guild_id, :snowflake
field :status, :string
field :activities, {:list, :raw}, default: []
field :client_status, {:struct, Dexcord.ClientStatus}
end
end
defmodule Dexcord.ClientStatus do
@moduledoc false
use Dexcord.Struct
discord_struct do
field :desktop, :string
field :mobile, :string
field :web, :string
end
end

View file

@ -0,0 +1,23 @@
defmodule Dexcord.Reaction do
@moduledoc "A message reaction. https://docs.discord.com/developers/resources/message"
use Dexcord.Struct
discord_struct do
field :count, :integer
field :count_details, {:struct, Dexcord.ReactionCountDetails}
field :me, :boolean
field :me_burst, :boolean
field :emoji, {:struct, Dexcord.PartialEmoji}
field :burst_colors, {:list, :string}, default: []
end
end
defmodule Dexcord.ReactionCountDetails do
@moduledoc false
use Dexcord.Struct
discord_struct do
field :burst, :integer
field :normal, :integer
end
end

61
lib/dexcord/model/role.ex Normal file
View file

@ -0,0 +1,61 @@
defmodule Dexcord.Role do
@moduledoc "A guild role. https://docs.discord.com/developers/topics/permissions"
use Dexcord.Struct
discord_struct do
field :id, :snowflake
field :name, :string
field :color, :integer
field :colors, {:struct, Dexcord.RoleColors}
field :hoist, :boolean
field :icon, :string
field :unicode_emoji, :string
field :position, :integer
field :permissions, {:flags, Dexcord.Permissions}, wire_string: true
field :managed, :boolean
field :mentionable, :boolean
field :tags, {:struct, Dexcord.RoleTags}
field :flags, {:flags, Dexcord.RoleFlags}
end
@doc "The mention string for this role (`<@&id>`)."
@spec mention(t()) :: String.t()
def mention(%{id: id}), do: "<@&#{id}>"
@doc "The creation `DateTime` encoded in this role's id, or `:error`."
@spec created_at(t()) :: {:ok, DateTime.t()} | :error
def created_at(%{id: id}), do: Dexcord.Snowflake.to_datetime(id)
end
defimpl String.Chars, for: Dexcord.Role do
def to_string(role), do: Dexcord.Role.mention(role)
end
defmodule Dexcord.RoleColors do
@moduledoc false
use Dexcord.Struct
discord_struct do
field :primary_color, :integer
field :secondary_color, :integer
field :tertiary_color, :integer
end
end
defmodule Dexcord.RoleTags do
@moduledoc """
Role tags. Docs: "Tags with type null represent booleans. They will be
present and set to null if they are 'true', and will be not present if
they are 'false'." — the three `:presence` fields below.
"""
use Dexcord.Struct
discord_struct do
field :bot_id, :snowflake
field :integration_id, :snowflake
field :premium_subscriber, :presence
field :subscription_listing_id, :snowflake
field :available_for_purchase, :presence
field :guild_connections, :presence
end
end

View file

@ -0,0 +1,73 @@
defmodule Dexcord.GuildScheduledEvent.EntityMetadata do
@moduledoc "Additional metadata for a guild scheduled event's entity (e.g. external location)."
use Dexcord.Struct
discord_struct do
field :location, :string
end
end
defmodule Dexcord.GuildScheduledEvent.NWeekday do
@moduledoc "An (n, weekday) pair for a monthly recurrence rule."
use Dexcord.Struct
discord_struct do
field :n, :integer
field :day, {:enum, Dexcord.RecurrenceRuleWeekday}
end
end
defmodule Dexcord.GuildScheduledEvent.RecurrenceRule do
@moduledoc "The recurrence rule for a repeating guild scheduled event."
use Dexcord.Struct
discord_struct do
field :start, :datetime
# The JSON key is "end"; Atom.to_string(:end) == "end", so no special handling.
field :end, :datetime
field :frequency, {:enum, Dexcord.RecurrenceRuleFrequency}
field :interval, :integer
field :by_weekday, {:list, {:enum, Dexcord.RecurrenceRuleWeekday}}, default: []
field :by_n_weekday, {:list, {:struct, Dexcord.GuildScheduledEvent.NWeekday}}, default: []
field :by_month, {:list, {:enum, Dexcord.RecurrenceRuleMonth}}, default: []
field :by_month_day, {:list, :integer}, default: []
field :by_year_day, {:list, :integer}, default: []
field :count, :integer
end
end
defmodule Dexcord.GuildScheduledEvent do
@moduledoc "A guild scheduled event. https://docs.discord.com/developers/resources/guild-scheduled-event"
use Dexcord.Struct
discord_struct do
field :id, :snowflake
field :guild_id, :snowflake
field :channel_id, :snowflake
field :creator_id, :snowflake
field :name, :string
field :description, :string
field :scheduled_start_time, :datetime
field :scheduled_end_time, :datetime
field :privacy_level, {:enum, Dexcord.GuildScheduledEventPrivacyLevel}
field :status, {:enum, Dexcord.GuildScheduledEventStatus}
field :entity_type, {:enum, Dexcord.GuildScheduledEventEntityType}
field :entity_id, :snowflake
field :entity_metadata, {:struct, Dexcord.GuildScheduledEvent.EntityMetadata}
field :creator, {:struct, Dexcord.User}
field :user_count, :integer
field :image, :string
field :recurrence_rule, {:struct, Dexcord.GuildScheduledEvent.RecurrenceRule}
end
end
defmodule Dexcord.GuildScheduledEventUser do
@moduledoc "A user subscribed to a guild scheduled event."
use Dexcord.Struct
discord_struct do
field :guild_scheduled_event_id, :snowflake
field :user, {:struct, Dexcord.User}
field :member, {:struct, Dexcord.Member}
end
end

View file

@ -0,0 +1,29 @@
defmodule Dexcord.Sticker do
@moduledoc "A message sticker. https://docs.discord.com/developers/resources/sticker"
use Dexcord.Struct
discord_struct do
field :id, :snowflake
field :pack_id, :snowflake
field :name, :string
field :description, :string
field :tags, :string
field :type, {:enum, Dexcord.StickerType}
field :format_type, {:enum, Dexcord.StickerFormatType}
field :available, :boolean
field :guild_id, :snowflake
field :user, {:struct, Dexcord.User}
field :sort_value, :integer
end
end
defmodule Dexcord.StickerItem do
@moduledoc "The smallest amount of data required to render a sticker."
use Dexcord.Struct
discord_struct do
field :id, :snowflake
field :name, :string
field :format_type, {:enum, Dexcord.StickerFormatType}
end
end

24
lib/dexcord/model/team.ex Normal file
View file

@ -0,0 +1,24 @@
defmodule Dexcord.TeamMember do
@moduledoc "A member of a developer team."
use Dexcord.Struct
discord_struct do
field :membership_state, {:enum, Dexcord.TeamMembershipState}
field :team_id, :snowflake
field :user, {:struct, Dexcord.User}
field :role, :string
end
end
defmodule Dexcord.Team do
@moduledoc "A developer team. https://docs.discord.com/developers/topics/teams"
use Dexcord.Struct
discord_struct do
field :icon, :string
field :id, :snowflake
field :members, {:list, {:struct, Dexcord.TeamMember}}, default: []
field :name, :string
field :owner_user_id, :snowflake
end
end

60
lib/dexcord/model/user.ex Normal file
View file

@ -0,0 +1,60 @@
defmodule Dexcord.User do
@moduledoc "A Discord user. https://docs.discord.com/developers/resources/user"
use Dexcord.Struct
discord_struct do
field :id, :snowflake
field :username, :string
field :discriminator, :string
field :global_name, :string
field :avatar, :string
field :bot, :boolean, default: false
field :system, :boolean, default: false
field :mfa_enabled, :boolean
field :banner, :string
field :accent_color, :integer
field :locale, :string
field :verified, :boolean
field :email, :string
field :flags, {:flags, Dexcord.UserFlags}
field :premium_type, {:enum, Dexcord.PremiumType}
field :public_flags, {:flags, Dexcord.UserFlags}
field :avatar_decoration_data, {:struct, Dexcord.AvatarDecorationData}
field :collectibles, :raw
field :primary_guild, {:struct, Dexcord.PrimaryGuild}
end
@doc "The mention string for this user (`<@id>`)."
@spec mention(t()) :: String.t()
def mention(%{id: id}), do: "<@#{id}>"
@doc "The creation `DateTime` encoded in this user's id, or `:error`."
@spec created_at(t()) :: {:ok, DateTime.t()} | :error
def created_at(%{id: id}), do: Dexcord.Snowflake.to_datetime(id)
end
defimpl String.Chars, for: Dexcord.User do
def to_string(user), do: Dexcord.User.mention(user)
end
defmodule Dexcord.AvatarDecorationData do
@moduledoc false
use Dexcord.Struct
discord_struct do
field :asset, :string
field :sku_id, :snowflake
end
end
defmodule Dexcord.PrimaryGuild do
@moduledoc "A user's primary-guild (server tag) identity."
use Dexcord.Struct
discord_struct do
field :identity_guild_id, :snowflake
field :identity_enabled, :boolean
field :tag, :string
field :badge, :string
end
end

View file

@ -0,0 +1,20 @@
defmodule Dexcord.VoiceState do
@moduledoc "A user's voice connection state. https://docs.discord.com/developers/resources/voice"
use Dexcord.Struct
discord_struct do
field :guild_id, :snowflake
field :channel_id, :snowflake
field :user_id, :snowflake
field :member, {:struct, Dexcord.Member}
field :session_id, :string
field :deaf, :boolean
field :mute, :boolean
field :self_deaf, :boolean
field :self_mute, :boolean
field :self_stream, :boolean, default: false
field :self_video, :boolean
field :suppress, :boolean
field :request_to_speak_timestamp, :datetime
end
end

View file

@ -0,0 +1,23 @@
defmodule Dexcord.Webhook do
@moduledoc "A webhook. https://docs.discord.com/developers/resources/webhook"
use Dexcord.Struct
discord_struct do
field :id, :snowflake
field :type, {:enum, Dexcord.WebhookType}
field :guild_id, :snowflake
field :channel_id, :snowflake
field :user, {:struct, Dexcord.User}
field :name, :string
field :avatar, :string
field :token, :string
field :application_id, :snowflake
field :source_guild, {:struct, Dexcord.PartialGuild}
field :source_channel, :raw
field :url, :string
end
@doc "The creation `DateTime` encoded in this webhook's id, or `:error`."
@spec created_at(t()) :: {:ok, DateTime.t()} | :error
def created_at(%{id: id}), do: Dexcord.Snowflake.to_datetime(id)
end

View file

@ -4,7 +4,7 @@ defmodule Dexcord.Prefix do
`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
wires a decoded `%Dexcord.Message{}` (`MESSAGE_CREATE`) 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`
@ -14,11 +14,11 @@ defmodule Dexcord.Prefix do
use Dexcord.Prefix.Router
def handle_command("ping", _args, msg),
do: Dexcord.Api.create_message(msg["channel_id"], "pong")
do: Dexcord.Api.create_message(msg.channel_id, "pong")
end
# in the handler:
def handle_event({:MESSAGE_CREATE, msg}),
def handle_event({:MESSAGE_CREATE, %Dexcord.Message{} = msg}),
do: Dexcord.Prefix.dispatch(msg, prefix: "!", to: MyBot.Commands)
"""
@ -77,7 +77,8 @@ defmodule Dexcord.Prefix do
end
@doc """
Routes a raw `MESSAGE_CREATE` payload to a `Dexcord.Prefix.Router`.
Routes a decoded `%Dexcord.Message{}` (`MESSAGE_CREATE`) to a
`Dexcord.Prefix.Router`.
Options:
@ -93,29 +94,30 @@ defmodule Dexcord.Prefix do
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
@spec dispatch(Dexcord.Message.t(), keyword()) :: any()
def dispatch(%Dexcord.Message{} = msg, opts) do
prefix = Keyword.fetch!(opts, :prefix)
router = Keyword.fetch!(opts, :to)
if bot_author?(msg) do
:ignore
else
case parse(msg["content"] || "", prefix) do
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)
defp bot_author?(%Dexcord.Message{author: nil}), do: true
defp bot_author?(%Dexcord.Message{author: author}) do
author.bot == true or own_message?(author)
end
defp own_message?(author) do
defp own_message?(%Dexcord.User{id: id}) do
case Dexcord.Cache.me() do
{:ok, %{"id" => id}} -> is_binary(id) and author["id"] == id
{:ok, %Dexcord.User{id: me_id}} -> id == me_id
_ -> false
end
end
@ -128,10 +130,14 @@ defmodule Dexcord.Prefix.Router do
`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.
argument list, and the decoded `%Dexcord.Message{}`.
"""
@callback handle_command(command :: String.t(), args :: [String.t()], msg :: map()) :: any()
@callback handle_command(
command :: String.t(),
args :: [String.t()],
msg :: Dexcord.Message.t()
) :: any()
defmacro __using__(_opts) do
quote do

View file

@ -31,13 +31,13 @@ defmodule Dexcord.Slash do
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
`dispatch/2` routes a decoded `%Dexcord.Interaction{}` on its `type` atom:
`:application_command` `handle_interaction/2` keyed on the data's `name`;
`:message_component` `handle_component/2` keyed on the data's `custom_id`;
`:modal_submit` `handle_modal/2` keyed on the data's `custom_id`. The
`Dexcord.Dispatcher` calls it automatically for those types when a `slash:`
module is configured (the raw event still reaches the handler).
module is configured (the event still reaches the handler too). Every callback
receives the full `%Dexcord.Interaction{}` as its second argument.
## Response helpers
@ -56,13 +56,22 @@ defmodule Dexcord.Slash do
@callback commands() :: [map()]
@doc "Handles a routed application-command interaction (type 2) for command `name`."
@callback handle_interaction(name :: String.t(), interaction :: map()) :: any()
@callback handle_interaction(
name :: String.t() | nil,
interaction :: Dexcord.Interaction.t()
) :: any()
@doc "Handles a routed message-component interaction (type 3) for `custom_id`."
@callback handle_component(custom_id :: String.t() | nil, interaction :: map()) :: any()
@callback handle_component(
custom_id :: String.t() | nil,
interaction :: Dexcord.Interaction.t()
) :: any()
@doc "Handles a routed modal-submit interaction (type 5) for `custom_id`."
@callback handle_modal(custom_id :: String.t() | nil, interaction :: map()) :: any()
@callback handle_modal(
custom_id :: String.t() | nil,
interaction :: Dexcord.Interaction.t()
) :: 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
@ -112,21 +121,42 @@ defmodule Dexcord.Slash do
# --- routing ------------------------------------------------------------
@doc """
Routes an `INTERACTION_CREATE` payload to `mod` on its top-level `"type"`.
Routes a decoded `%Dexcord.Interaction{}` to `mod` on its `type` atom.
* 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"]`
* `:application_command` `mod.handle_interaction/2`, keyed on the data's `name`
* `:message_component` `mod.handle_component/2`, keyed on the data's `custom_id`
* `:modal_submit` `mod.handle_modal/2`, keyed on the data's `custom_id`
Any other (or missing) type is ignored - the `Dexcord.Dispatcher` only routes
types 2/3/5 here, so this is defensive.
Any other (or nil) type is ignored - the `Dexcord.Dispatcher` only routes those
three types here, so this is defensive.
A malformed `INTERACTION_CREATE` that fails to decode into a struct is routed by
the dispatcher's integer-type gate to the **degraded raw-map head** below, which
reproduces the pre-typed routing on the top-level integer `"type"` so a
not-quite-decodable interaction still reaches the handler with the raw map.
"""
@spec dispatch(map(), module()) :: any()
def dispatch(interaction, mod) when is_map(interaction) and is_atom(mod) do
case interaction["type"] do
@spec dispatch(Dexcord.Interaction.t() | map(), module()) :: any()
def dispatch(%Dexcord.Interaction{} = interaction, mod) when is_atom(mod) do
case interaction.type do
:application_command ->
mod.handle_interaction(data_field(interaction, :name), interaction)
:message_component ->
mod.handle_component(data_field(interaction, :custom_id), interaction)
:modal_submit ->
mod.handle_modal(data_field(interaction, :custom_id), interaction)
_ ->
:ignore
end
end
# Degraded raw-map head: a raw INTERACTION_CREATE the decoder could not turn into
# a %Dexcord.Interaction{}. Routes exactly as the pre-typed dispatcher did, on the
# top-level integer "type", handing the raw map to the callback.
def dispatch(%{"type" => type} = interaction, mod) when is_atom(mod) do
case 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)
@ -134,6 +164,12 @@ defmodule Dexcord.Slash do
end
end
# Reads a field from an interaction's typed `data` variant struct (or nil-data).
# `data` is one of the ApplicationCommandData / MessageComponentData /
# ModalSubmitData structs - all plain maps to `Map.get/2`; nil-data yields nil.
defp data_field(%{data: %{} = data}, key), do: Map.get(data, key)
defp data_field(_interaction, _key), do: nil
# --- response helpers ---------------------------------------------------
@doc """
@ -142,15 +178,16 @@ defmodule Dexcord.Slash do
`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()}
@spec respond(Dexcord.Interaction.t(), 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
def respond(%Dexcord.Interaction{} = interaction, %{} = data) do
Dexcord.Api.create_interaction_response(
interaction["id"],
interaction["token"],
interaction.id,
interaction.token,
%{"type" => 4, "data" => message_data(data)}
)
end
@ -159,11 +196,11 @@ defmodule Dexcord.Slash do
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
@spec respond_later(Dexcord.Interaction.t()) :: {:ok, map()} | {:ok, nil} | {:error, term()}
def respond_later(%Dexcord.Interaction{} = interaction) do
Dexcord.Api.create_interaction_response(
interaction["id"],
interaction["token"],
interaction.id,
interaction.token,
%{"type" => 5}
)
end
@ -173,15 +210,15 @@ defmodule Dexcord.Slash do
`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()}
@spec followup(Dexcord.Interaction.t(), 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
def followup(%Dexcord.Interaction{} = interaction, %{} = data) do
Dexcord.Api.create_followup_message(
interaction["application_id"],
interaction["token"],
interaction.application_id,
interaction.token,
message_data(data)
)
end
@ -191,15 +228,16 @@ defmodule Dexcord.Slash do
`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()}
@spec edit_response(Dexcord.Interaction.t(), 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
def edit_response(%Dexcord.Interaction{} = interaction, %{} = data) do
Dexcord.Api.edit_original_interaction_response(
interaction["application_id"],
interaction["token"],
interaction.application_id,
interaction.token,
message_data(data)
)
end
@ -213,7 +251,7 @@ defmodule Dexcord.Slash 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)
{:ok, value} -> Map.put(acc, Atom.to_string(field), encode_values(value))
:error -> acc
end
end)
@ -224,6 +262,14 @@ defmodule Dexcord.Slash do
end
end
# Normalizes recognised field values for the wire: Dexcord structs (e.g.
# `%Dexcord.Embed{}`) go through their own `to_map/1`; lists recurse; anything
# else (a binary, a plain atom/string-keyed map) passes through unchanged and is
# serialized directly by the JSON encoder downstream.
defp encode_values(%_{} = struct), do: struct.__struct__.to_map(struct)
defp encode_values(list) when is_list(list), do: Enum.map(list, &encode_values/1)
defp encode_values(value), do: value
# 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

View file

@ -9,7 +9,7 @@ defmodule Dexcord.Slash.Registrar do
On start it:
1. calls `GET /oauth2/applications/@me` to learn the application id and caches
1. calls `GET /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`:
@ -99,7 +99,15 @@ defmodule Dexcord.Slash.Registrar do
# 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()
# Command defs may be plain maps or Dexcord command-builder structs; normalize
# struct defs to their wire maps before the (untouched) downstream register/3.
# `[]` normalizes to `[]`, so the empty-list global guard below still fires.
commands =
config.slash.commands()
|> Enum.map(fn
%_{} = struct -> struct.__struct__.to_map(struct)
map when is_map(map) -> map
end)
with {:ok, app_id} <- resolve_app_id() do
Dexcord.Config.put_application_id(app_id)
@ -109,12 +117,12 @@ defmodule Dexcord.Slash.Registrar do
defp resolve_app_id do
case Dexcord.Api.get_current_application() do
{:ok, %{"id" => id}} when is_binary(id) ->
{:ok, %Dexcord.ApplicationInfo{id: id}} when not is_nil(id) ->
{:ok, id}
other ->
{:error,
"could not resolve the application id (GET /oauth2/applications/@me): " <>
"could not resolve the application id (GET /applications/@me): " <>
inspect(other)}
end
end

View file

@ -2,18 +2,53 @@ defmodule Dexcord.Snowflake do
@moduledoc """
Discord snowflake id helpers, replacing `Nostrum.Snowflake`.
A snowflake is a 64-bit id whose top bits encode the creation time as
milliseconds since the Discord epoch (2015-01-01). Ids are accepted either as
integers or as the raw decimal strings Discord sends over the wire.
A snowflake is a 64-bit unsigned integer encoding a creation timestamp.
Dexcord represents snowflakes as plain integers everywhere; wire strings
are normalized to integers once, at decode time, via `cast/1`.
"""
import Bitwise
@discord_epoch_ms 1_420_070_400_000
@timestamp_shift 22
@max 0xFFFF_FFFF_FFFF_FFFF
@typedoc "A snowflake id, as an integer or a Discord decimal string."
@type t :: integer() | String.t()
@type t :: 0..0xFFFF_FFFF_FFFF_FFFF
@doc "Guard: `term` is an integer in the valid snowflake range."
defguard is_snowflake(term) when is_integer(term) and term >= 0 and term <= @max
@doc """
Casts a snowflake-ish value to an integer snowflake.
Accepts an integer in range, a decimal string, or a struct/map with an
`:id` field holding an in-range integer.
"""
@spec cast(term()) :: {:ok, t()} | :error
def cast(int) when is_snowflake(int), do: {:ok, int}
def cast(string) when is_binary(string) do
case Integer.parse(string) do
{int, ""} when is_snowflake(int) -> {:ok, int}
_ -> :error
end
end
def cast(%{id: id}) when is_snowflake(id), do: {:ok, id}
def cast(_), do: :error
@doc "Like `cast/1` but raises `ArgumentError` on invalid input."
@spec cast!(term()) :: t()
def cast!(value) do
case cast(value) do
{:ok, int} -> int
:error -> raise ArgumentError, "not a snowflake: #{inspect(value)}"
end
end
@doc "Dumps a snowflake to its wire (string) representation."
@spec dump(t()) :: String.t()
def dump(snowflake) when is_snowflake(snowflake), do: Integer.to_string(snowflake)
@doc """
Builds the smallest snowflake for a `DateTime` (timestamp bits only).
@ -30,30 +65,15 @@ defmodule Dexcord.Snowflake do
def from_datetime(_), do: :error
@doc "The creation `DateTime` encoded in a snowflake, or `:error` if unparseable."
@spec to_datetime(t()) :: {:ok, DateTime.t()} | :error
def to_datetime(snowflake) do
case to_integer(snowflake) do
{:ok, int} -> DateTime.from_unix(to_unix(int), :millisecond)
:error -> :error
end
end
@doc "The creation `DateTime` encoded in a snowflake, or `:error` for non-snowflakes."
@spec to_datetime(term()) :: {:ok, DateTime.t()} | :error
def to_datetime(snowflake) when is_snowflake(snowflake),
do: DateTime.from_unix(to_unix(snowflake), :millisecond)
def to_datetime(_), do: :error
@doc "The creation time of a snowflake as Unix milliseconds."
@spec to_unix(t()) :: integer()
def to_unix(snowflake) do
{:ok, int} = to_integer(snowflake)
(int >>> @timestamp_shift) + @discord_epoch_ms
end
defp to_integer(int) when is_integer(int), do: {:ok, int}
defp to_integer(str) when is_binary(str) do
case Integer.parse(str) do
{int, ""} -> {:ok, int}
_ -> :error
end
end
defp to_integer(_), do: :error
def to_unix(snowflake) when is_snowflake(snowflake),
do: (snowflake >>> @timestamp_shift) + @discord_epoch_ms
end

247
lib/dexcord/struct.ex Normal file
View file

@ -0,0 +1,247 @@
defmodule Dexcord.Struct do
@moduledoc """
A DSL for declaring Discord object shapes once.
One `field` declaration per field generates, at compile time, the
`defstruct`, `@type t`, `from_map/1` (string-keyed wire map -> struct,
minting zero atoms), `to_map/1` (struct -> JSON-ready map), and
`merge_map/2` (partial-update semantics for the cache).
defmodule Dexcord.Example do
use Dexcord.Struct
discord_struct do
field :id, :snowflake
field :name, :string
field :roles, {:list, :snowflake}, default: []
end
end
Field types: `:snowflake`, `:string`, `:integer`, `:number` (int or float),
`:boolean`, `:datetime`, `{:struct, Module}`, `{:list, type}`,
`{:enum, Module}`, `{:flags, Module}`, `:raw` (kept verbatim). Modifiers:
`default:`, `tristate: true` (`:absent | nil | value`), `presence: true`
(key-presence boolean), `wire_string: true` (re-encode an integer value as a
decimal string, for bitfields that exceed 2^53 like permissions).
`{:flags, Module}` decodes both integers and decimal strings (Discord sends
permission bitfields as strings); pair it with `wire_string: true` to
round-trip back to a string.
`include_fields Provider` pulls a shared field group from a plain-data module
exporting `__included_fields__/0` (a list of `{name, type, opts}`); the fields
land in declaration position, indistinguishable from hand-written `field`s.
`hydrate slot, from: :id_field, type: Module` declares a cache-fillable
slot (always `nil` after decode; populated only by `Dexcord.Cache.fill/1`).
"""
@scalar_types [:snowflake, :string, :integer, :number, :boolean, :datetime, :raw]
defmacro __using__(_opts) do
quote do
import Dexcord.Struct, only: [discord_struct: 1]
Module.register_attribute(__MODULE__, :dexcord_fields, accumulate: true)
Module.register_attribute(__MODULE__, :dexcord_hydrates, accumulate: true)
@before_compile Dexcord.Struct
# The generated decode/encode/merge entrypoints are defined here — before
# the module body — and marked `defoverridable`, so a model may define its
# own clause in the body and post-process the generated result via `super/1`
# (e.g. `Dexcord.Interaction`'s polymorphic `data` decode). They take plain
# arguments and delegate to the Codec, which does the struct matching at
# runtime; the `defstruct` they'd otherwise pattern-match is only injected
# later, by `@before_compile`.
@doc "Decodes a string-keyed wire map into `t()`. Non-maps decode to `nil`."
@spec from_map(term()) :: t() | nil
def from_map(map), do: Dexcord.Struct.Codec.from_map(__MODULE__, map)
@doc "Encodes to a JSON-ready string-keyed map. `nil`/`:absent` fields are omitted."
@spec to_map(t()) :: %{optional(String.t()) => term()}
def to_map(struct), do: Dexcord.Struct.Codec.to_map(struct)
@doc "Applies a partial wire map, updating only keys present in it."
@spec merge_map(t(), map()) :: t()
def merge_map(struct, map), do: Dexcord.Struct.Codec.merge_map(struct, map)
defoverridable from_map: 1, to_map: 1, merge_map: 2
end
end
defmacro discord_struct(do: block) do
quote do
try do
import Dexcord.Struct, only: [field: 2, field: 3, hydrate: 2, include_fields: 1]
unquote(block)
after
:ok
end
end
end
@doc false
defmacro field(name, type, opts \\ []) do
quote do
@dexcord_fields {unquote(name), unquote(Macro.escape(type)), unquote(opts)}
end
end
@doc false
defmacro include_fields(mod) do
quote do
Enum.each(unquote(mod).__included_fields__(), fn {name, type, opts} ->
Module.put_attribute(__MODULE__, :dexcord_fields, {name, type, opts})
end)
end
end
@doc false
defmacro hydrate(name, opts) do
quote do
@dexcord_hydrates {unquote(name), unquote(Macro.escape(opts))}
end
end
@doc false
defmacro __before_compile__(env) do
module = env.module
raw_fields = Module.get_attribute(module, :dexcord_fields) || []
raw_hydrates = Module.get_attribute(module, :dexcord_hydrates) || []
if raw_fields == [] and raw_hydrates == [] do
raise ArgumentError,
"#{inspect(module)}: `use Dexcord.Struct` requires a `discord_struct do ... end` " <>
"block declaring at least one `field` or `hydrate`"
end
fields =
raw_fields
|> Enum.reverse()
|> Enum.map(fn {name, type_ast, opts} -> build_field(name, type_ast, opts, env) end)
hydrates =
raw_hydrates
|> Enum.reverse()
|> Enum.map(fn {name, opts} -> build_hydrate(name, opts, env) end)
struct_defaults =
Enum.map(fields, fn f -> {f.name, f.default} end) ++
Enum.map(hydrates, fn h -> {h.name, nil} end)
field_type_pairs =
Enum.map(fields, fn f -> {f.name, field_typespec(f, module)} end) ++
Enum.map(hydrates, fn h -> {h.name, hydrate_typespec(h, module)} end)
type_ast = {:%, [], [{:__MODULE__, [], Elixir}, {:%{}, [], field_type_pairs}]}
quote do
defstruct unquote(Macro.escape(struct_defaults))
@type t :: unquote(type_ast)
@doc false
def __fields__, do: unquote(Macro.escape(fields))
@doc false
def __hydrations__, do: unquote(Macro.escape(hydrates))
end
end
# -- compile-time helpers (run in the macro, not in generated code) --
@doc false
def build_field(name, type_ast, opts, env) when type_ast == :presence,
do: build_field(name, :boolean, Keyword.put(opts, :presence, true), env)
def build_field(name, type_ast, opts, env) do
type = resolve_type(type_ast, env)
validate_type!(type, name, env.module)
tristate = Keyword.get(opts, :tristate, false)
presence = Keyword.get(opts, :presence, false)
if tristate and presence do
raise ArgumentError,
"#{inspect(env.module)}.#{name}: tristate and presence are mutually exclusive"
end
default =
cond do
Keyword.has_key?(opts, :default) -> Keyword.fetch!(opts, :default)
tristate -> :absent
presence -> false
true -> nil
end
%{
name: name,
key: Atom.to_string(name),
type: type,
default: default,
tristate: tristate,
presence: presence,
wire_string: Keyword.get(opts, :wire_string, false)
}
end
@doc false
def build_hydrate(name, opts, env) do
%{
name: name,
from: Keyword.fetch!(opts, :from),
type: resolve_type(Keyword.fetch!(opts, :type), env)
}
end
defp resolve_type({:struct, mod_ast}, env), do: {:struct, resolve_type(mod_ast, env)}
defp resolve_type({:list, inner}, env), do: {:list, resolve_type(inner, env)}
defp resolve_type({:enum, mod_ast}, env), do: {:enum, resolve_type(mod_ast, env)}
defp resolve_type({:flags, mod_ast}, env), do: {:flags, resolve_type(mod_ast, env)}
defp resolve_type({:id_map, inner}, env), do: {:id_map, resolve_type(inner, env)}
defp resolve_type(atom, _env) when is_atom(atom), do: atom
defp resolve_type(ast, env), do: Macro.expand(ast, env)
defp validate_type!(type, _name, _module) when type in @scalar_types, do: :ok
defp validate_type!({:struct, mod}, _name, _module) when is_atom(mod), do: :ok
defp validate_type!({:enum, mod}, _name, _module) when is_atom(mod), do: :ok
defp validate_type!({:flags, mod}, _name, _module) when is_atom(mod), do: :ok
defp validate_type!({:list, inner}, name, module), do: validate_type!(inner, name, module)
defp validate_type!({:id_map, inner}, name, module), do: validate_type!(inner, name, module)
defp validate_type!(other, name, module) do
raise ArgumentError,
"#{inspect(module)}.#{name}: unknown field type #{inspect(other)}"
end
# -- typespec construction --
defp field_typespec(%{presence: true}, _module), do: quote(do: boolean())
defp field_typespec(%{tristate: true} = f, module),
do: quote(do: :absent | nil | unquote(base_typespec(f.type, module)))
defp field_typespec(f, module),
do: quote(do: unquote(base_typespec(f.type, module)) | nil)
defp hydrate_typespec(%{type: :channel}, _module), do: quote(do: struct() | nil)
defp hydrate_typespec(%{type: mod}, module),
do: quote(do: unquote(base_typespec({:struct, mod}, module)) | nil)
defp base_typespec(:snowflake, _m), do: quote(do: Dexcord.Snowflake.t())
defp base_typespec(:string, _m), do: quote(do: String.t())
defp base_typespec(:integer, _m), do: quote(do: integer())
defp base_typespec(:number, _m), do: quote(do: number())
defp base_typespec(:boolean, _m), do: quote(do: boolean())
defp base_typespec(:datetime, _m), do: quote(do: DateTime.t())
defp base_typespec(:raw, _m), do: quote(do: term())
defp base_typespec({:struct, mod}, mod), do: quote(do: t())
defp base_typespec({:struct, mod}, _m), do: quote(do: unquote(mod).t())
defp base_typespec({:list, inner}, m), do: quote(do: [unquote(base_typespec(inner, m))])
defp base_typespec({:id_map, inner}, m),
do: quote(do: %{optional(Dexcord.Snowflake.t()) => unquote(base_typespec(inner, m))})
defp base_typespec({:enum, mod}, _m), do: quote(do: unquote(mod).t())
defp base_typespec({:flags, _mod}, _m), do: quote(do: non_neg_integer())
end

159
lib/dexcord/struct/codec.ex Normal file
View file

@ -0,0 +1,159 @@
defmodule Dexcord.Struct.Codec do
@moduledoc false
# Runtime walker behind every generated from_map/1, to_map/1, merge_map/2.
# All decode tolerance semantics live here, in one place.
@doc false
def from_map(module, map) when is_map(map) and not is_struct(map) do
fields =
for f <- module.__fields__() do
{f.name, decode_field(f, map)}
end
struct!(module, fields)
end
def from_map(_module, _non_map), do: nil
@doc false
def decode_field(%{presence: true} = f, map), do: Map.has_key?(map, f.key)
def decode_field(f, map) do
case Map.fetch(map, f.key) do
:error -> f.default
{:ok, nil} -> nil
{:ok, value} -> decode_value(f.type, value, f.default)
end
end
# decode_value(type, non-nil wire value, default) — wrong container shape
# falls to the default; failed scalar casts keep the raw value.
defp decode_value(:snowflake, v, _d) when is_integer(v), do: v
defp decode_value(:snowflake, v, _d) when is_binary(v) do
case Dexcord.Snowflake.cast(v) do
{:ok, int} -> int
:error -> v
end
end
defp decode_value(:datetime, v, _d) when is_binary(v) do
case DateTime.from_iso8601(v) do
{:ok, dt, _offset} -> dt
{:error, _} -> v
end
end
defp decode_value({:struct, mod}, v, _d) when is_map(v) and not is_struct(v),
do: mod.from_map(v)
defp decode_value({:struct, _mod}, _v, d), do: d
defp decode_value({:list, inner}, v, _d) when is_list(v),
do: Enum.map(v, &decode_element(inner, &1))
defp decode_value({:list, _inner}, _v, d), do: d
defp decode_value({:enum, mod}, v, _d) when is_integer(v), do: mod.decode(v)
defp decode_value({:flags, _mod}, v, _d) when is_integer(v), do: v
# Permission-style bitfields arrive as decimal strings (they exceed 2^53).
# Parse a clean decimal string to an integer; anything else stays raw.
defp decode_value({:flags, _mod}, v, _d) when is_binary(v) do
case Integer.parse(v) do
{int, ""} -> int
_ -> v
end
end
# Discord's `%{"snowflake_string" => object}` dictionaries (interaction
# resolved data): snowflake-castable keys become integers, everything else
# keeps its string key; values decode leniently through the inner type.
defp decode_value({:id_map, inner}, v, _d) when is_map(v) and not is_struct(v) do
Map.new(v, fn {k, val} ->
key =
case Dexcord.Snowflake.cast(k) do
{:ok, int} -> int
:error -> k
end
{key, decode_element(inner, val)}
end)
end
defp decode_value({:id_map, _inner}, _v, d), do: d
# :string, :integer, :boolean, :raw, plus every failed scalar cast:
# the raw wire value passes through untouched.
defp decode_value(_type, v, _d), do: v
defp decode_element(_inner, nil), do: nil
defp decode_element(inner, v), do: decode_value(inner, v, nil)
@doc false
def to_map(%module{} = struct) do
Enum.reduce(module.__fields__(), %{}, fn f, acc ->
case encode_field(f, Map.fetch!(struct, f.name)) do
:skip -> acc
{:ok, encoded} -> Map.put(acc, f.key, encoded)
end
end)
end
defp encode_field(%{tristate: true}, :absent), do: :skip
defp encode_field(%{presence: true}, true), do: {:ok, nil}
defp encode_field(%{presence: true}, false), do: :skip
defp encode_field(_f, nil), do: :skip
defp encode_field(f, value) do
encoded = encode_value(f.type, value)
if f.wire_string and is_integer(encoded) do
{:ok, Integer.to_string(encoded)}
else
{:ok, encoded}
end
end
defp encode_value(:snowflake, v) when is_integer(v), do: Integer.to_string(v)
defp encode_value(:datetime, %DateTime{} = v), do: DateTime.to_iso8601(v)
# Route through the struct's own to_map/1 (not Codec.to_map directly): generated
# structs delegate straight back here, while hand-written fallback structs
# (UnknownChannel, Component.Unknown — no __fields__/0) provide their own.
defp encode_value({:struct, _mod}, %_{} = v), do: v.__struct__.to_map(v)
defp encode_value({:list, inner}, v) when is_list(v),
do: Enum.map(v, &encode_element(inner, &1))
# id_map: re-stringify integer snowflake keys, encode values through the inner
# type; non-integer keys (junk we kept verbatim on decode) pass through.
defp encode_value({:id_map, inner}, v) when is_map(v) and not is_struct(v) do
Map.new(v, fn {k, val} ->
{if(is_integer(k), do: Integer.to_string(k), else: k), encode_element(inner, val)}
end)
end
defp encode_value({:enum, mod}, v) when is_atom(v) and not is_nil(v), do: mod.encode(v)
defp encode_value({:enum, _mod}, {:unknown, n}) when is_integer(n), do: n
# raw passthrough: junk scalars that decode kept raw re-encode raw; a raw
# int in an enum/flags field is already wire format.
defp encode_value(_type, v), do: v
defp encode_element(_inner, nil), do: nil
defp encode_element(inner, v), do: encode_value(inner, v)
@doc false
def merge_map(%module{} = struct, map) when is_map(map) and not is_struct(map) do
Enum.reduce(module.__fields__(), struct, fn f, acc ->
if Map.has_key?(map, f.key) do
Map.put(acc, f.name, decode_field(f, map))
else
acc
end
end)
end
def merge_map(struct, _non_map), do: struct
end

22
lib/dexcord/util.ex Normal file
View file

@ -0,0 +1,22 @@
defmodule Dexcord.Util do
@moduledoc "Small formatting helpers for the ergonomics layer."
# The nine documented Discord timestamp styles (Message Formatting reference):
# t short time, T long time, d short date, D long date, f short date/time,
# F long date/time, s short relative-ish (seconds), S, R relative.
@styles ~w(t T d D f F s S R)
@doc """
Renders a Discord timestamp markdown token `<t:unix[:style]>`.
Accepts a `DateTime` or a unix-seconds integer. `style` is one of
`#{Enum.join(@styles, " ")}` and defaults to `"f"` (per Discord's docs).
"""
@spec format_dt(DateTime.t() | integer(), String.t()) :: String.t()
def format_dt(dt_or_unix, style \\ "f")
def format_dt(%DateTime{} = dt, style), do: format_dt(DateTime.to_unix(dt), style)
def format_dt(unix, style) when is_integer(unix) and style in @styles,
do: "<t:#{unix}:#{style}>"
end

View file

@ -0,0 +1,22 @@
defmodule Mix.Tasks.Dexcord.Coverage do
@shortdoc "Verifies declared REST routes cover the pinned Discord OpenAPI spec"
@moduledoc "See Dexcord.Api.Coverage. Exits non-zero when in-scope endpoints are missing."
use Mix.Task
@impl Mix.Task
def run(_argv) do
Mix.Task.run("compile")
case Dexcord.Api.Coverage.check() do
{:ok, stats} ->
Mix.shell().info(
"coverage OK — #{stats.declared} declared, #{stats.spec} in spec, #{stats.allowlisted} allowlisted"
)
{:missing, missing} ->
Mix.shell().error("MISSING #{length(missing)} in-scope endpoint(s):")
Enum.each(missing, &Mix.shell().error(" " <> &1))
exit({:shutdown, 1})
end
end
end

View file

@ -0,0 +1,81 @@
defmodule Mix.Tasks.Dexcord.Coverage.Refresh do
@shortdoc "Downloads and pins the latest Discord OpenAPI spec into priv/"
@moduledoc """
Refreshes `priv/discord_openapi.json` from the upstream
`discord/discord-api-spec` repo.
This is a DELIBERATE, human-initiated action it is never run in CI. Pinning
means upstream churn can only affect coverage when an operator explicitly runs
this task, reviews the `git diff`, reconciles the allowlist, and reruns
`mix dexcord.coverage`.
"""
use Mix.Task
@url "https://raw.githubusercontent.com/discord/discord-api-spec/main/specs/openapi.json"
@impl Mix.Task
def run(_argv) do
# Mix prunes OTP apps it doesn't depend on from the code path, so inets/ssl
# (needed only by this deliberate, dev-only task) may not be loadable. Add
# their ebin dirs from the Erlang root before starting them.
ensure_otp_app_on_path(:ssl)
ensure_otp_app_on_path(:inets)
{:ok, _} = Application.ensure_all_started(:ssl)
{:ok, _} = Application.ensure_all_started(:inets)
dest = Path.join(:code.priv_dir(:dexcord), "discord_openapi.json")
File.mkdir_p!(Path.dirname(dest))
Mix.shell().info("fetching #{@url} ...")
body = fetch!(@url)
File.write!(dest, body)
Mix.shell().info("wrote #{dest} (#{byte_size(body)} bytes)")
Mix.shell().info(
"review `git diff priv/discord_openapi.json`, reconcile priv/coverage_allowlist.exs, then rerun `mix dexcord.coverage`"
)
end
# Adds an OTP application's ebin dir (under the Erlang root) to the code path
# if its modules aren't already loadable.
defp ensure_otp_app_on_path(app) do
if :code.lib_dir(app) == {:error, :bad_name} do
root = to_string(:code.root_dir())
case Path.wildcard(Path.join([root, "lib", "#{app}-*", "ebin"])) do
[ebin | _] -> :code.add_patha(String.to_charlist(ebin))
[] -> Mix.raise("cannot locate OTP application #{app} under #{root}/lib")
end
end
end
# Verifies the peer with castore's CA bundle so the pin can't be MITM'd.
defp fetch!(url) do
cacerts = CAStore.file_path() |> to_charlist()
http_opts = [
ssl: [
verify: :verify_peer,
cacertfile: cacerts,
depth: 3,
customize_hostname_check: [
match_fun: :public_key.pkix_verify_hostname_match_fun(:https)
]
],
autoredirect: true
]
case :httpc.request(:get, {to_charlist(url), []}, http_opts, body_format: :binary) do
{:ok, {{_v, 200, _r}, _headers, body}} ->
body
{:ok, {{_v, status, _r}, _headers, _body}} ->
Mix.raise("failed to fetch spec: HTTP #{status}")
{:error, reason} ->
Mix.raise("failed to fetch spec: #{inspect(reason)}")
end
end
end

View file

@ -0,0 +1,95 @@
# Routes present in the pinned OpenAPI spec but deliberately NOT declared.
# Everything here remains reachable via Dexcord.Api.request/4.
[
# -- monetization (out of scope) --
"GET /applications/*/skus",
"GET /applications/*/entitlements*",
"POST /applications/*/entitlements*",
"DELETE /applications/*/entitlements*",
"GET /applications/*/subscriptions*",
"GET /skus/*/subscriptions*",
# -- soundboard (out of scope) --
"GET /soundboard-default-sounds",
"GET /guilds/*/soundboard-sounds*",
"POST /guilds/*/soundboard-sounds",
"PATCH /guilds/*/soundboard-sounds/*",
"DELETE /guilds/*/soundboard-sounds/*",
"POST /channels/*/send-soundboard-sound",
# -- stage instances (out of scope) --
"GET /stage-instances/*",
"POST /stage-instances",
"PATCH /stage-instances/*",
"DELETE /stage-instances/*",
# -- guild templates (out of scope) --
"GET /guilds/templates/*",
"POST /guilds/templates/*",
"GET /guilds/*/templates",
"POST /guilds/*/templates",
"PUT /guilds/*/templates/*",
"PATCH /guilds/*/templates/*",
"DELETE /guilds/*/templates/*",
# -- Bearer-only / not bot-usable --
"GET /oauth2/@me",
"GET /users/@me/applications/*/role-connection",
"PUT /users/@me/applications/*/role-connection",
"DELETE /users/@me/applications/*/role-connection",
"GET /applications/*/role-connections/metadata",
"PUT /applications/*/role-connections/metadata",
"PUT /applications/*/guilds/*/commands/*/permissions",
"PUT /applications/*/guilds/*/commands/permissions",
# -- deprecated routes kept out of the typed surface --
"GET /channels/*/pins",
"PUT /channels/*/pins/*",
"DELETE /channels/*/pins/*",
"PATCH /guilds/*/members/@me/nick",
# -- removed from official docs (2026-07); spec may still carry them --
"POST /guilds",
"DELETE /guilds/*",
"POST /guilds/*/mfa",
# -- special CSV multipart (guest invites); escape hatch --
"GET /invites/*/target-users*",
"PUT /invites/*/target-users",
# -- activities --
"GET /applications/*/activity-instances/*",
# -- OAuth2 token machinery (not a bot-library concern) --
"POST /oauth2/token*",
"POST /oauth2/token/revoke",
# -- OpenID Connect / key-discovery (bearer/OIDC, not bot-token) --
"GET /oauth2/keys",
"GET /oauth2/userinfo",
# ---------------------------------------------------------------------------
# Reconciliation against the pinned spec (2026-07-05). Everything below is
# present upstream but NOT in the plan's authoritative declared surface: all
# are Social-SDK-era or post-inventory additions, none documented as part of
# the bot-token REST surface the design scoped. Reachable via request/4.
# ---------------------------------------------------------------------------
#
# -- Social SDK: Lobbies (whole resource family, out of scope) --
"PUT /lobbies*",
"POST /lobbies*",
"GET /lobbies*",
"PATCH /lobbies*",
"DELETE /lobbies*",
# -- Social SDK: Partner SDK token + provisional accounts + DM moderation --
"POST /partner-sdk*",
"PUT /partner-sdk*",
# -- Application-by-id (Social SDK / OAuth variants of the @me routes we
# declare) + application attachment upload --
"GET /applications/*",
"PATCH /applications/*",
"POST /applications/*/attachment",
# -- Guild scheduled-event exceptions & user counts (recurring-event feature;
# the base scheduled-events family IS declared — only these extras aren't) --
"POST /guilds/*/scheduled-events/*/exceptions",
"PATCH /guilds/*/scheduled-events/*/exceptions/*",
"GET /guilds/*/scheduled-events/*/users/counts",
"GET /guilds/*/scheduled-events/*/*/users",
# -- Guild join requests / membership application flow (newer feature) --
"GET /guilds/*/new-member-welcome",
"GET /guilds/*/requests",
"PATCH /guilds/*/requests/*",
# -- Thread search (not in the documented channel bot surface) --
"GET /channels/*/threads/search",
# -- monetization: per-user application entitlements (monetization excluded) --
"GET /users/@me/applications/*/entitlements"
]

43111
priv/discord_openapi.json Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,178 @@
defmodule Dexcord.ApiChannelsTest do
@moduledoc """
Representative wire tests for the `Dexcord.Api.Channels` group, driven through
the real `Dexcord.Api` + `Dexcord.Api.Ratelimit` against `Dexcord.FakeRest`.
One round-trip per shape family plus the specials called out in Phase 5 Task 2.
"""
use ExUnit.Case, async: false
alias Dexcord.Api
alias Dexcord.Api.Ratelimit
alias Dexcord.FakeRest
@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})
start_supervised!(Ratelimit)
start_supervised!(FakeRest)
Application.put_env(:dexcord, :api_base_url, FakeRest.base_url())
FakeRest.subscribe(self())
:ok
end
test "edit_channel PATCHes a body and decodes the concrete channel struct" do
FakeRest.stub(
:patch,
"/channels/1",
FakeRest.resp(200, body: ~s({"id":"1","type":0,"name":"renamed"}))
)
assert {:ok, %Dexcord.TextChannel{id: 1, name: "renamed"}} =
Api.edit_channel(1, %{"name" => "renamed"}, reason: "tidy up")
assert_receive {:rest_hit, info}
assert info.method == "PATCH"
assert info.path == "/channels/1"
assert JSON.decode!(info.body) == %{"name" => "renamed"}
headers = Map.new(info.headers)
assert headers["x-audit-log-reason"] == "tidy%20up"
end
test "delete_channel decodes the deleted channel and carries a reason" do
FakeRest.stub(:delete, "/channels/5", FakeRest.resp(200, body: ~s({"id":"5","type":0})))
assert {:ok, %Dexcord.TextChannel{id: 5}} = Api.delete_channel(5, reason: "gone")
assert_receive {:rest_hit, info}
assert info.method == "DELETE"
assert Map.new(info.headers)["x-audit-log-reason"] == "gone"
end
test "get_channel_invites decodes a list of invites" do
FakeRest.stub(
:get,
"/channels/1/invites",
FakeRest.resp(200, body: ~s([{"code":"abc"},{"code":"def"}]))
)
assert {:ok, [%Dexcord.Invite{code: "abc"}, %Dexcord.Invite{code: "def"}]} =
Api.get_channel_invites(1)
end
test "create_channel_invite posts a body and decodes an invite" do
FakeRest.stub(
:post,
"/channels/1/invites",
FakeRest.resp(200, body: ~s({"code":"xyz"}))
)
assert {:ok, %Dexcord.Invite{code: "xyz"}} =
Api.create_channel_invite(1, %{"max_age" => 3600}, reason: "share")
assert_receive {:rest_hit, info}
assert JSON.decode!(info.body) == %{"max_age" => 3600}
end
test "follow_announcement_channel decodes a FollowedChannel" do
FakeRest.stub(
:post,
"/channels/1/followers",
FakeRest.resp(200, body: ~s({"channel_id":"9","webhook_id":"10"}))
)
assert {:ok, %Dexcord.FollowedChannel{channel_id: 9, webhook_id: 10}} =
Api.follow_announcement_channel(1, %{"webhook_channel_id" => "9"})
end
test "trigger_typing POSTs with no body and returns {:ok, nil}" do
FakeRest.stub(:post, "/channels/1/typing", FakeRest.resp(204))
assert {:ok, nil} = Api.trigger_typing(1)
assert_receive {:rest_hit, info}
assert info.method == "POST"
assert info.body == ""
end
test "edit_channel_permissions PUTs a body, returns nil, carries a reason" do
FakeRest.stub(:put, "/channels/1/permissions/2", FakeRest.resp(204))
assert {:ok, nil} =
Api.edit_channel_permissions(1, 2, %{"allow" => "8", "type" => 0}, reason: "perm")
assert_receive {:rest_hit, info}
assert info.path == "/channels/1/permissions/2"
assert Map.new(info.headers)["x-audit-log-reason"] == "perm"
end
test "get_thread_member forwards the with_member query and decodes a ThreadMember" do
FakeRest.stub(
:get,
"/channels/1/thread-members/2",
FakeRest.resp(200, body: ~s({"id":"1","user_id":"2"}))
)
assert {:ok, %Dexcord.ThreadMember{user_id: 2}} =
Api.get_thread_member(1, 2, with_member: true)
assert_receive {:rest_hit, info}
assert info.query_string == "with_member=true"
end
test "list_thread_members decodes a list and forwards the query keys" do
FakeRest.stub(
:get,
"/channels/1/thread-members",
FakeRest.resp(200, body: ~s([{"user_id":"2"},{"user_id":"3"}]))
)
assert {:ok, [%Dexcord.ThreadMember{user_id: 2}, %Dexcord.ThreadMember{user_id: 3}]} =
Api.list_thread_members(1, with_member: true, limit: 50)
assert_receive {:rest_hit, info}
assert URI.decode_query(info.query_string) == %{"with_member" => "true", "limit" => "50"}
end
test "list_public_archived_threads returns the raw envelope untouched" do
FakeRest.stub(
:get,
"/channels/1/threads/archived/public",
FakeRest.resp(200, body: ~s({"threads":[],"members":[],"has_more":false}))
)
assert {:ok, %{"threads" => [], "members" => [], "has_more" => false}} =
Api.list_public_archived_threads(1, limit: 10)
end
test "start_thread wraps a binary as name, carries a reason, decodes a Thread" do
FakeRest.stub(
:post,
"/channels/1/threads",
FakeRest.resp(201, body: ~s({"id":"77","type":11,"name":"chat"}))
)
assert {:ok, %Dexcord.Thread{id: 77, name: "chat"}} =
Api.start_thread(1, "chat", reason: "spin up")
assert_receive {:rest_hit, info}
assert JSON.decode!(info.body) == %{"name" => "chat"}
assert Map.new(info.headers)["x-audit-log-reason"] == "spin%20up"
end
test "group_dm_add_recipient PUTs a body and returns nil" do
FakeRest.stub(:put, "/channels/1/recipients/2", FakeRest.resp(204))
assert {:ok, nil} =
Api.group_dm_add_recipient(1, 2, %{"access_token" => "tok", "nick" => "friend"})
assert_receive {:rest_hit, info}
assert info.path == "/channels/1/recipients/2"
assert JSON.decode!(info.body) == %{"access_token" => "tok", "nick" => "friend"}
end
end

View file

@ -0,0 +1,140 @@
defmodule Dexcord.ApiCommandsTest do
@moduledoc """
Representative wire tests for the extended `Dexcord.Api.Commands` group
(Phase 5 Task 5), including a struct body encoded via `to_map/1` with a
recursive (sub-command-group) options tree.
"""
use ExUnit.Case, async: false
alias Dexcord.Api
alias Dexcord.Api.Ratelimit
alias Dexcord.FakeRest
@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})
start_supervised!(Ratelimit)
start_supervised!(FakeRest)
Application.put_env(:dexcord, :api_base_url, FakeRest.base_url())
FakeRest.subscribe(self())
:ok
end
test "get_global_commands forwards with_localizations and decodes a list" do
FakeRest.stub(
:get,
"/applications/1/commands",
FakeRest.resp(200, body: ~s([{"id":"2","name":"ping"}]))
)
assert {:ok, [%Dexcord.ApplicationCommand{id: 2, name: "ping"}]} =
Api.get_global_commands(1, with_localizations: true)
assert_receive {:rest_hit, info}
assert info.query_string == "with_localizations=true"
end
test "create_global_command encodes a recursive ApplicationCommand struct body" do
FakeRest.stub(
:post,
"/applications/1/commands",
FakeRest.resp(201, body: ~s({"id":"7","name":"config"}))
)
command = %Dexcord.ApplicationCommand{
name: "config",
description: "configure the bot",
options: [
%Dexcord.ApplicationCommand.Option{
type: :sub_command_group,
name: "group",
description: "a group",
options: [
%Dexcord.ApplicationCommand.Option{
type: :sub_command,
name: "sub",
description: "a sub-command"
}
]
}
]
}
assert {:ok, %Dexcord.ApplicationCommand{id: 7, name: "config"}} =
Api.create_global_command(1, command)
assert_receive {:rest_hit, info}
body = JSON.decode!(info.body)
assert body["name"] == "config"
assert [group] = body["options"]
# sub_command_group encodes to 2, the nested sub_command to 1.
assert group["type"] == 2
assert group["name"] == "group"
assert [sub] = group["options"]
assert sub["type"] == 1
assert sub["name"] == "sub"
end
test "edit_global_command PATCHes and decodes an ApplicationCommand" do
FakeRest.stub(
:patch,
"/applications/1/commands/2",
FakeRest.resp(200, body: ~s({"id":"2","name":"renamed"}))
)
assert {:ok, %Dexcord.ApplicationCommand{id: 2, name: "renamed"}} =
Api.edit_global_command(1, 2, %{"name" => "renamed"})
end
test "delete_global_command returns nil" do
FakeRest.stub(:delete, "/applications/1/commands/2", FakeRest.resp(204))
assert {:ok, nil} = Api.delete_global_command(1, 2)
end
test "create_guild_command decodes an ApplicationCommand" do
FakeRest.stub(
:post,
"/applications/1/guilds/9/commands",
FakeRest.resp(201, body: ~s({"id":"3","name":"g"}))
)
assert {:ok, %Dexcord.ApplicationCommand{id: 3, name: "g"}} =
Api.create_guild_command(1, 9, %{"name" => "g", "description" => "d"})
end
test "get_guild_command_permissions decodes a list of permission structs" do
FakeRest.stub(
:get,
"/applications/1/guilds/9/commands/permissions",
FakeRest.resp(200,
body: ~s([{"id":"2","permissions":[{"id":"5","type":1,"permission":true}]}])
)
)
assert {:ok,
[
%Dexcord.GuildApplicationCommandPermissions{
id: 2,
permissions: [%Dexcord.ApplicationCommandPermission{id: 5, permission: true}]
}
]} = Api.get_guild_command_permissions(1, 9)
end
test "get_command_permissions decodes a single permissions struct" do
FakeRest.stub(
:get,
"/applications/1/guilds/9/commands/2/permissions",
FakeRest.resp(200, body: ~s({"id":"2","permissions":[]}))
)
assert {:ok, %Dexcord.GuildApplicationCommandPermissions{id: 2, permissions: []}} =
Api.get_command_permissions(1, 9, 2)
end
end

View file

@ -0,0 +1,114 @@
defmodule Dexcord.ApiFacadeTest do
@moduledoc """
Guards the generated `Dexcord.Api` facade: every endpoint head derived from the
group modules' static `__endpoints__/0` route tables (plus explicit sugar) must
be re-exported from `Dexcord.Api` at the same arity, so the delegation can never
silently drop an endpoint.
This guard is deterministic: it derives the expected `{name, arity}` set from
the SAME static source of truth the facade generation uses (`__endpoints__/0`),
never from `module_info(:exports)` (whose export table races under
parallel/incremental compilation).
"""
use ExUnit.Case, async: true
# `function_exported?/3` does NOT load a module — it only inspects an
# already-loaded one. Under `async: true` a probe-only test can otherwise run
# before anything forces `Dexcord.Api` into the VM, yielding a false negative
# that has nothing to do with the facade. Load every relevant module up front
# so the export probes below reflect the compiled beam, deterministically.
setup_all do
Code.ensure_loaded!(Dexcord.Api)
Enum.each(Dexcord.Api.__endpoint_groups__(), &Code.ensure_loaded!/1)
:ok
end
# Hand-written sugar arities not reproduced by the `__endpoints__/0` derivation
# (mirrors `Dexcord.Api`'s own `@facade_sugar`).
@sugar [get_channel_messages: 3]
# Independently re-derive the expected delegate set from the groups' static
# route tables, using the identical rule the facade generation applies: each
# generated head ends in `opts \\ []`, so it exports at `base` and `base + 1`
# where `base = path_param_count + (body? 1 : 0)`.
defp expected_exports do
endpoint_exports =
for group <- Dexcord.Api.__endpoint_groups__(),
spec <- group.__endpoints__(),
params = Enum.count(spec.segments, &match?({:param, _}, &1)),
base = params + if(spec.body, do: 1, else: 0),
arity <- [base, base + 1] do
{spec.name, arity}
end
MapSet.new(endpoint_exports ++ @sugar)
end
test "the facade's export set exactly equals the statically-derived expected set" do
# Same source of truth as the generator: if the two ever diverge, either the
# generator dropped/added a delegate or the derivation rule drifted.
assert Dexcord.Api.__facade_exports__() == expected_exports()
end
test "every statically-derived export is actually defined on Dexcord.Api" do
# Guards the generation step itself: the derived set must correspond to real
# exported functions. This is what fails deterministically if a delegate is
# silently dropped by a racy compile.
exports = expected_exports()
assert MapSet.size(exports) > 0
for {name, arity} <- exports do
assert function_exported?(Dexcord.Api, name, arity),
"Dexcord.Api is missing a delegate for #{name}/#{arity}"
end
end
test "__endpoint_groups__/0 lists all declared groups" do
assert Dexcord.Api.__endpoint_groups__() == [
Dexcord.Api.Channels,
Dexcord.Api.Messages,
Dexcord.Api.Guilds,
Dexcord.Api.Members,
Dexcord.Api.Roles,
Dexcord.Api.Webhooks,
Dexcord.Api.Invites,
Dexcord.Api.Users,
Dexcord.Api.Interactions,
Dexcord.Api.Commands,
Dexcord.Api.EmojisStickers,
Dexcord.Api.Moderation,
Dexcord.Api.Misc
]
end
test "the facade covers historically-load-bearing endpoint arities" do
# These are the arities depended on by the internal call sites (gateway,
# prefix, registrar, slash) and by external Nostrum-compatible callers.
for {name, arity} <- [
get_gateway_bot: 0,
get_current_user: 0,
get_current_application: 0,
get_user: 1,
get_channel: 1,
get_guild: 1,
create_dm: 1,
get_channel_messages: 1,
get_channel_messages: 2,
get_channel_messages: 3,
create_message: 2,
create_message: 3,
edit_message: 3,
delete_message: 2,
create_reaction: 3,
start_thread_with_message: 3,
create_interaction_response: 3,
edit_original_interaction_response: 3,
create_followup_message: 3,
bulk_overwrite_global_commands: 2,
bulk_overwrite_guild_commands: 3
] do
assert function_exported?(Dexcord.Api, name, arity),
"expected Dexcord.Api.#{name}/#{arity} to exist"
end
end
end

View file

@ -0,0 +1,233 @@
defmodule Dexcord.ApiGuildsTest do
@moduledoc """
Representative wire tests for the `Dexcord.Api.Guilds`, `Dexcord.Api.Members`,
and `Dexcord.Api.Roles` groups (Phase 5 Task 3): top-level-array bodies, the
binary widget.png, audit-log decode, and the moved `get_guild`.
"""
use ExUnit.Case, async: false
alias Dexcord.Api
alias Dexcord.Api.Ratelimit
alias Dexcord.FakeRest
@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})
start_supervised!(Ratelimit)
start_supervised!(FakeRest)
Application.put_env(:dexcord, :api_base_url, FakeRest.base_url())
FakeRest.subscribe(self())
:ok
end
# --- Guilds -------------------------------------------------------------
test "get_guild still works after moving out of Misc, honoring with_counts" do
FakeRest.stub(:get, "/guilds/1", FakeRest.resp(200, body: ~s({"id":"1","name":"g"})))
assert {:ok, %Dexcord.Guild{id: 1, name: "g"}} = Api.get_guild(1, with_counts: true)
assert_receive {:rest_hit, info}
assert info.query_string == "with_counts=true"
end
test "edit_guild PATCHes a body and decodes the Guild with a reason" do
FakeRest.stub(:patch, "/guilds/1", FakeRest.resp(200, body: ~s({"id":"1","name":"new"})))
assert {:ok, %Dexcord.Guild{name: "new"}} =
Api.edit_guild(1, %{"name" => "new"}, reason: "rebrand")
assert_receive {:rest_hit, info}
assert Map.new(info.headers)["x-audit-log-reason"] == "rebrand"
end
test "edit_guild_channel_positions sends a TOP-LEVEL array body and returns nil" do
FakeRest.stub(:patch, "/guilds/1/channels", FakeRest.resp(204))
assert {:ok, nil} =
Api.edit_guild_channel_positions(1, [
%{"id" => "2", "position" => 1},
%{"id" => "3", "position" => 2}
])
assert_receive {:rest_hit, info}
assert JSON.decode!(info.body) == [
%{"id" => "2", "position" => 1},
%{"id" => "3", "position" => 2}
]
end
test "get_guild_channels decodes a list of concrete channel structs" do
FakeRest.stub(
:get,
"/guilds/1/channels",
FakeRest.resp(200, body: ~s([{"id":"2","type":0},{"id":"3","type":2}]))
)
assert {:ok, [%Dexcord.TextChannel{id: 2}, %Dexcord.VoiceChannel{id: 3}]} =
Api.get_guild_channels(1)
end
test "get_guild_widget_settings decodes the settings struct" do
FakeRest.stub(
:get,
"/guilds/1/widget",
FakeRest.resp(200, body: ~s({"enabled":true,"channel_id":"9"}))
)
assert {:ok, %Dexcord.GuildWidgetSettings{enabled: true, channel_id: 9}} =
Api.get_guild_widget_settings(1)
end
test "get_guild_widget_image returns a non-JSON PNG body raw" do
png = <<137, 80, 78, 71, 13, 10, 26, 10>>
FakeRest.stub(
:get,
"/guilds/1/widget.png",
FakeRest.resp(200, headers: [{"content-type", "image/png"}], body: png)
)
assert {:ok, {:raw, ^png}} = Api.get_guild_widget_image(1, style: "banner1")
assert_receive {:rest_hit, info}
assert info.query_string == "style=banner1"
end
test "get_guild_audit_log encodes its query and decodes nested entries" do
body = ~s({"audit_log_entries":[{"id":"5","action_type":1}],"users":[{"id":"7"}]})
FakeRest.stub(:get, "/guilds/1/audit-logs", FakeRest.resp(200, body: body))
assert {:ok,
%Dexcord.AuditLog{
audit_log_entries: [%Dexcord.AuditLogEntry{id: 5}],
users: [%Dexcord.User{id: 7}]
}} = Api.get_guild_audit_log(1, user_id: "7", action_type: 1, limit: 50)
assert_receive {:rest_hit, info}
assert URI.decode_query(info.query_string) == %{
"user_id" => "7",
"action_type" => "1",
"limit" => "50"
}
end
# --- Members ------------------------------------------------------------
test "get_guild_member decodes a Member" do
FakeRest.stub(
:get,
"/guilds/1/members/2",
FakeRest.resp(200, body: ~s({"nick":"bob","user":{"id":"2"}}))
)
assert {:ok, %Dexcord.Member{nick: "bob", user: %Dexcord.User{id: 2}}} =
Api.get_guild_member(1, 2)
end
test "list_guild_members decodes a list and forwards paging query" do
FakeRest.stub(
:get,
"/guilds/1/members",
FakeRest.resp(200, body: ~s([{"nick":"a"},{"nick":"b"}]))
)
assert {:ok, [%Dexcord.Member{nick: "a"}, %Dexcord.Member{nick: "b"}]} =
Api.list_guild_members(1, limit: 100, after: "0")
assert_receive {:rest_hit, info}
assert URI.decode_query(info.query_string) == %{"limit" => "100", "after" => "0"}
end
test "create_guild_ban PUTs a body, returns nil, carries a reason" do
FakeRest.stub(:put, "/guilds/1/bans/2", FakeRest.resp(204))
assert {:ok, nil} =
Api.create_guild_ban(1, 2, %{"delete_message_seconds" => 3600}, reason: "spam")
assert_receive {:rest_hit, info}
assert info.method == "PUT"
assert info.path == "/guilds/1/bans/2"
assert Map.new(info.headers)["x-audit-log-reason"] == "spam"
end
test "get_guild_ban decodes a Ban" do
FakeRest.stub(
:get,
"/guilds/1/bans/2",
FakeRest.resp(200, body: ~s({"reason":"bad","user":{"id":"2"}}))
)
assert {:ok, %Dexcord.Ban{reason: "bad", user: %Dexcord.User{id: 2}}} =
Api.get_guild_ban(1, 2)
end
test "get_current_user_voice_state decodes a VoiceState" do
FakeRest.stub(
:get,
"/guilds/1/voice-states/@me",
FakeRest.resp(200, body: ~s({"channel_id":"9","session_id":"s"}))
)
assert {:ok, %Dexcord.VoiceState{channel_id: 9, session_id: "s"}} =
Api.get_current_user_voice_state(1)
end
# --- Roles --------------------------------------------------------------
test "get_guild_roles decodes a list of Roles" do
FakeRest.stub(
:get,
"/guilds/1/roles",
FakeRest.resp(200, body: ~s([{"id":"2","name":"mod"},{"id":"3","name":"admin"}]))
)
assert {:ok, [%Dexcord.Role{id: 2, name: "mod"}, %Dexcord.Role{id: 3, name: "admin"}]} =
Api.get_guild_roles(1)
end
test "create_guild_role wraps a binary as name and decodes a Role with a reason" do
FakeRest.stub(:post, "/guilds/1/roles", FakeRest.resp(200, body: ~s({"id":"9","name":"vip"})))
assert {:ok, %Dexcord.Role{id: 9, name: "vip"}} =
Api.create_guild_role(1, "vip", reason: "perk")
assert_receive {:rest_hit, info}
assert JSON.decode!(info.body) == %{"name" => "vip"}
assert Map.new(info.headers)["x-audit-log-reason"] == "perk"
end
test "edit_guild_role_positions sends a list body and decodes a list of Roles" do
FakeRest.stub(
:patch,
"/guilds/1/roles",
FakeRest.resp(200, body: ~s([{"id":"2","position":1},{"id":"3","position":2}]))
)
assert {:ok, [%Dexcord.Role{id: 2, position: 1}, %Dexcord.Role{id: 3, position: 2}]} =
Api.edit_guild_role_positions(1, [
%{"id" => "2", "position" => 1},
%{"id" => "3", "position" => 2}
])
assert_receive {:rest_hit, info}
assert is_list(JSON.decode!(info.body))
end
test "delete_guild_role returns nil with a reason" do
FakeRest.stub(:delete, "/guilds/1/roles/2", FakeRest.resp(204))
assert {:ok, nil} = Api.delete_guild_role(1, 2, reason: "obsolete")
assert_receive {:rest_hit, info}
assert Map.new(info.headers)["x-audit-log-reason"] == "obsolete"
end
end

View file

@ -46,7 +46,7 @@ defmodule Dexcord.ApiIntegrationTest do
test "auth + user-agent headers are present on every request" do
FakeRest.stub(:get, "/users/@me", FakeRest.resp(200, body: ~s({"id":"42"})))
assert {:ok, %{"id" => "42"}} = Api.get_current_user()
assert {:ok, %Dexcord.User{id: 42}} = Api.get_current_user()
assert_receive {:rest_hit, info}
headers = Map.new(info.headers)
@ -135,7 +135,7 @@ defmodule Dexcord.ApiIntegrationTest do
{elapsed_us, result} = :timer.tc(fn -> Api.create_message(5, "hi") end)
assert {:ok, %{"id" => "ok"}} = result
assert {:ok, %Dexcord.Message{id: "ok"}} = result
# Exactly two hits: the 429 and the successful retry - and no more.
assert_receive {:rest_hit, _}
assert_receive {:rest_hit, _}
@ -173,7 +173,7 @@ defmodule Dexcord.ApiIntegrationTest do
{elapsed_us, {:ok, _}} = :timer.tc(fn -> Api.get_guild(8) end)
assert elapsed_us >= 150_000, "unrelated route was not blocked by the global lock"
assert {:ok, %{"id" => "a"}} = Task.await(task, 5_000)
assert {:ok, %Dexcord.Message{id: "a"}} = Task.await(task, 5_000)
end
test "a wait that would exceed the request deadline returns an error tuple" do
@ -232,13 +232,13 @@ defmodule Dexcord.ApiIntegrationTest do
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")
assert {:ok, %Dexcord.Message{id: "ok"}} = Api.create_message(999, "recovered")
end
test "get_channel_messages/2 with a limit only builds a plain query" do
FakeRest.stub(:get, "/channels/1/messages", FakeRest.resp(200, body: ~s([{"id":"m1"}])))
assert {:ok, [%{"id" => "m1"}]} = Api.get_channel_messages(1, limit: 50)
assert {:ok, [%Dexcord.Message{id: "m1"}]} = Api.get_channel_messages(1, limit: 50)
assert_receive {:rest_hit, info}
assert info.method == "GET"
@ -265,14 +265,24 @@ defmodule Dexcord.ApiIntegrationTest do
assert info.query_string == ""
end
test "get_channel_messages/2 emits at most one anchor (before > after > around)" do
test "get_channel_messages/2 now emits every anchor it is given (no precedence collapse)" do
FakeRest.stub(:get, "/channels/1/messages", FakeRest.resp(200, body: "[]"))
assert {:ok, []} =
Api.get_channel_messages(1, before: "b", after: "a", around: "r", limit: 5)
assert_receive {:rest_hit, info}
assert query(info) == %{"limit" => "5", "before" => "b"}
# Behavior change from the pre-DSL surface: the old `message_query/1` kept
# only the highest-precedence anchor (before > after > around). The DSL query
# builder forwards every declared query key present in opts, so the caller is
# now responsible for passing exactly one anchor (the /3 locator arity does).
assert query(info) == %{
"limit" => "5",
"before" => "b",
"after" => "a",
"around" => "r"
}
end
test "get_channel_messages/3 normalizes the Nostrum-style locator" do
@ -287,15 +297,18 @@ defmodule Dexcord.ApiIntegrationTest do
assert query(info2) == %{"limit" => "25"}
end
test "start_thread_with_message posts a string-keyed body with the name" do
test "start_thread_with_message posts a string-keyed body and decodes the thread" do
FakeRest.stub(
:post,
"/channels/1/messages/2/threads",
FakeRest.resp(201, body: ~s({"id":"t1"}))
FakeRest.resp(201, body: ~s({"id":"123","type":11}))
)
assert {:ok, %{"id" => "t1"}} =
Api.start_thread_with_message(1, 2, "chat", auto_archive_duration: 1440)
# The extra body fields now travel in the keyword/map body argument (the old
# surface merged them from a trailing opts list; the DSL's opts are reserved
# for query/reason/files).
assert {:ok, %Dexcord.Thread{id: 123}} =
Api.start_thread_with_message(1, 2, name: "chat", auto_archive_duration: 1440)
assert_receive {:rest_hit, info}
assert info.method == "POST"
@ -307,8 +320,151 @@ defmodule Dexcord.ApiIntegrationTest do
refute Map.has_key?(body, "rate_limit_per_user")
end
describe "multipart bodies" do
test "a single file becomes a payload_json part plus a files[0] part" do
FakeRest.stub(:post, "/channels/1/messages", FakeRest.resp(200, body: ~s({"id":"1"})))
payload = %{"content" => "hi", "attachments" => [%{"id" => 0, "filename" => "a.png"}]}
files = [%{filename: "a.png", data: <<137, 80, 78, 71>>, content_type: "image/png"}]
assert {:ok, %{"id" => "1"}} =
Api.request(:post, "/channels/1/messages", {:multipart, payload, files})
assert_receive {:rest_hit, info}
headers = Map.new(info.headers)
assert "multipart/form-data; boundary=" <> boundary = headers["content-type"]
assert boundary != ""
parts = parse_multipart(info.body, boundary)
assert %{"payload_json" => payload_part} = by_name(parts)
assert payload_part.content_type == "application/json"
assert JSON.decode!(payload_part.data) == payload
assert %{"files[0]" => file_part} = by_name(parts)
assert file_part.filename == "a.png"
assert file_part.content_type == "image/png"
assert file_part.data == <<137, 80, 78, 71>>
end
test "two files become files[0] and files[1] parts in order" do
FakeRest.stub(:post, "/channels/1/messages", FakeRest.resp(200, body: ~s({"id":"1"})))
files = [
%{filename: "a.png", data: <<1>>, content_type: "image/png"},
%{filename: "b.txt", data: <<2>>, content_type: "text/plain"}
]
assert {:ok, _} =
Api.request(:post, "/channels/1/messages", {:multipart, %{"content" => ""}, files})
assert_receive {:rest_hit, info}
"multipart/form-data; boundary=" <> boundary = Map.new(info.headers)["content-type"]
parts = parse_multipart(info.body, boundary)
names = Enum.map(parts, & &1.name)
assert names == ["payload_json", "files[0]", "files[1]"]
named = by_name(parts)
assert named["files[0]"].filename == "a.png"
assert named["files[0]"].data == <<1>>
assert named["files[1]"].filename == "b.txt"
assert named["files[1]"].data == <<2>>
end
test "a filename containing a quote arrives sanitized" do
FakeRest.stub(:post, "/channels/1/messages", FakeRest.resp(200, body: ~s({"id":"1"})))
files = [%{filename: ~s(ev"il.png), data: <<0>>, content_type: "image/png"}]
assert {:ok, _} =
Api.request(:post, "/channels/1/messages", {:multipart, %{}, files})
assert_receive {:rest_hit, info}
"multipart/form-data; boundary=" <> boundary = Map.new(info.headers)["content-type"]
parts = parse_multipart(info.body, boundary)
assert %{"files[0]" => file_part} = by_name(parts)
refute file_part.filename =~ "\""
assert file_part.filename == "ev_il.png"
end
test "a form_multipart body emits each field as its own part plus a file part" do
FakeRest.stub(:post, "/guilds/1/stickers", FakeRest.resp(201, body: ~s({"id":"1"})))
fields = %{"name" => "s", "tags" => "t"}
file = %{filename: "s.png", data: <<1>>, content_type: "image/png"}
assert {:ok, %{"id" => "1"}} =
Api.request(:post, "/guilds/1/stickers", {:form_multipart, fields, file})
assert_receive {:rest_hit, info}
headers = Map.new(info.headers)
assert "multipart/form-data; boundary=" <> boundary = headers["content-type"]
assert boundary != ""
parts = parse_multipart(info.body, boundary)
named = by_name(parts)
# Each field is its own plain form part (no filename, no content-type).
assert named["name"].data == "s"
assert named["name"].filename == nil
assert named["tags"].data == "t"
assert named["tags"].filename == nil
# The file rides in a part literally named "file" (not files[n]).
assert %{"file" => file_part} = named
assert file_part.filename == "s.png"
assert file_part.content_type == "image/png"
assert file_part.data == <<1>>
end
end
# --- helpers ------------------------------------------------------------
# Splits a multipart/form-data body into parsed parts, preserving raw part
# bytes (no trimming, so binary payloads survive intact).
defp parse_multipart(body, boundary) do
body
|> String.split("--" <> boundary)
|> Enum.drop(1)
|> Enum.reject(fn seg -> seg == "--\r\n" or seg == "--" end)
|> Enum.map(fn seg ->
seg = String.replace_prefix(seg, "\r\n", "")
[raw_headers, rest] = String.split(seg, "\r\n\r\n", parts: 2)
data = String.replace_suffix(rest, "\r\n", "")
disposition = header_value(raw_headers, "content-disposition")
%{
name: extract(disposition, ~r/name="([^"]*)"/),
filename: extract(disposition, ~r/filename="([^"]*)"/),
content_type: header_value(raw_headers, "content-type"),
data: data
}
end)
end
defp by_name(parts), do: Map.new(parts, fn part -> {part.name, part} end)
defp header_value(raw_headers, name) do
raw_headers
|> String.split("\r\n")
|> Enum.find_value(fn line ->
case String.split(line, ": ", parts: 2) do
[key, value] -> if String.downcase(key) == name, do: value
_ -> nil
end
end)
end
defp extract(nil, _re), do: nil
defp extract(string, re) do
case Regex.run(re, string) do
[_, captured] -> captured
_ -> nil
end
end
defp query(info) do
case info.query_string do
qs when is_binary(qs) and qs != "" -> URI.decode_query(qs)

View file

@ -0,0 +1,180 @@
defmodule Dexcord.ApiMessagesTest do
@moduledoc """
Representative wire tests for the `Dexcord.Api.Messages` group (Phase 5 Task 2):
reactions, bulk delete, the new pins route, polls, and guild message search.
"""
use ExUnit.Case, async: false
alias Dexcord.Api
alias Dexcord.Api.Ratelimit
alias Dexcord.FakeRest
@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})
start_supervised!(Ratelimit)
start_supervised!(FakeRest)
Application.put_env(:dexcord, :api_base_url, FakeRest.base_url())
FakeRest.subscribe(self())
:ok
end
test "get_channel_message decodes a single Message" do
FakeRest.stub(
:get,
"/channels/1/messages/2",
FakeRest.resp(200, body: ~s({"id":"2","content":"hi"}))
)
assert {:ok, %Dexcord.Message{id: 2, content: "hi"}} = Api.get_channel_message(1, 2)
end
test "crosspost_message decodes a Message" do
FakeRest.stub(
:post,
"/channels/1/messages/2/crosspost",
FakeRest.resp(200, body: ~s({"id":"2"}))
)
assert {:ok, %Dexcord.Message{id: 2}} = Api.crosspost_message(1, 2)
end
test "delete_own_reaction percent-encodes the emoji in the path" do
FakeRest.stub(
:delete,
"/channels/1/messages/2/reactions/%F0%9F%94%A5/@me",
FakeRest.resp(204)
)
assert {:ok, nil} = Api.delete_own_reaction(1, 2, "🔥")
assert_receive {:rest_hit, info}
assert info.method == "DELETE"
assert info.path == "/channels/1/messages/2/reactions/%F0%9F%94%A5/@me"
end
test "delete_user_reaction targets a specific user's reaction" do
FakeRest.stub(
:delete,
"/channels/1/messages/2/reactions/%F0%9F%94%A5/3",
FakeRest.resp(204)
)
assert {:ok, nil} = Api.delete_user_reaction(1, 2, "🔥", 3)
assert_receive {:rest_hit, info}
assert info.path == "/channels/1/messages/2/reactions/%F0%9F%94%A5/3"
end
test "get_reactions decodes a list of users and forwards the query" do
FakeRest.stub(
:get,
"/channels/1/messages/2/reactions/%F0%9F%94%A5",
FakeRest.resp(200, body: ~s([{"id":"10"},{"id":"11"}]))
)
assert {:ok, [%Dexcord.User{id: 10}, %Dexcord.User{id: 11}]} =
Api.get_reactions(1, 2, "🔥", limit: 25)
assert_receive {:rest_hit, info}
assert info.query_string == "limit=25"
end
test "delete_all_reactions returns nil" do
FakeRest.stub(:delete, "/channels/1/messages/2/reactions", FakeRest.resp(204))
assert {:ok, nil} = Api.delete_all_reactions(1, 2)
end
test "bulk_delete_messages posts a messages array body with a reason" do
FakeRest.stub(:post, "/channels/1/messages/bulk-delete", FakeRest.resp(204))
assert {:ok, nil} =
Api.bulk_delete_messages(1, %{"messages" => ["2", "3", "4"]}, reason: "cleanup")
assert_receive {:rest_hit, info}
assert info.path == "/channels/1/messages/bulk-delete"
assert JSON.decode!(info.body) == %{"messages" => ["2", "3", "4"]}
assert Map.new(info.headers)["x-audit-log-reason"] == "cleanup"
end
test "get_channel_pins hits the new pins route and returns the raw envelope" do
FakeRest.stub(
:get,
"/channels/1/messages/pins",
FakeRest.resp(200, body: ~s({"items":[],"has_more":false}))
)
assert {:ok, %{"items" => [], "has_more" => false}} =
Api.get_channel_pins(1, limit: 5)
assert_receive {:rest_hit, info}
assert info.path == "/channels/1/messages/pins"
end
test "pin_message PUTs the new pins route with a reason header" do
FakeRest.stub(:put, "/channels/1/messages/pins/2", FakeRest.resp(204))
assert {:ok, nil} = Api.pin_message(1, 2, reason: "important")
assert_receive {:rest_hit, info}
assert info.method == "PUT"
assert info.path == "/channels/1/messages/pins/2"
assert Map.new(info.headers)["x-audit-log-reason"] == "important"
end
test "unpin_message DELETEs the new pins route with a reason header" do
FakeRest.stub(:delete, "/channels/1/messages/pins/2", FakeRest.resp(204))
assert {:ok, nil} = Api.unpin_message(1, 2, reason: "stale")
assert_receive {:rest_hit, info}
assert info.path == "/channels/1/messages/pins/2"
assert Map.new(info.headers)["x-audit-log-reason"] == "stale"
end
test "search_guild_messages forwards its query keys and returns raw" do
FakeRest.stub(
:get,
"/guilds/1/messages/search",
FakeRest.resp(200, body: ~s({"messages":[],"total_results":0}))
)
assert {:ok, %{"messages" => [], "total_results" => 0}} =
Api.search_guild_messages(1, content: "hello", limit: 10, author_id: "42")
assert_receive {:rest_hit, info}
assert URI.decode_query(info.query_string) == %{
"content" => "hello",
"limit" => "10",
"author_id" => "42"
}
end
test "get_answer_voters returns the raw voters envelope" do
FakeRest.stub(
:get,
"/channels/1/polls/2/answers/3",
FakeRest.resp(200, body: ~s({"users":[]}))
)
assert {:ok, %{"users" => []}} = Api.get_answer_voters(1, 2, 3, limit: 5)
end
test "end_poll returns the finalized Message" do
FakeRest.stub(
:post,
"/channels/1/polls/2/expire",
FakeRest.resp(200, body: ~s({"id":"2","content":"poll over"}))
)
assert {:ok, %Dexcord.Message{id: 2, content: "poll over"}} = Api.end_poll(1, 2)
end
end

View file

@ -0,0 +1,248 @@
defmodule Dexcord.ApiModerationTest do
@moduledoc """
Representative wire tests for the Phase 5 Task 5 groups: `Dexcord.Api.Moderation`
(auto-moderation + scheduled events), `Dexcord.Api.EmojisStickers` (base64 JSON
emoji bodies vs. plain-form sticker multipart), the `Dexcord.Api.Users`
additions, and the `Dexcord.Api.Misc` `get_current_application` re-point.
"""
use ExUnit.Case, async: false
alias Dexcord.Api
alias Dexcord.Api.Ratelimit
alias Dexcord.FakeRest
@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})
start_supervised!(Ratelimit)
start_supervised!(FakeRest)
Application.put_env(:dexcord, :api_base_url, FakeRest.base_url())
FakeRest.subscribe(self())
:ok
end
# --- Moderation: auto-moderation ---------------------------------------
test "create_auto_moderation_rule posts a body and decodes the rule with a reason" do
FakeRest.stub(
:post,
"/guilds/1/auto-moderation/rules",
FakeRest.resp(200, body: ~s({"id":"5","name":"no-spam","trigger_type":1}))
)
assert {:ok, %Dexcord.AutoModerationRule{id: 5, name: "no-spam"}} =
Api.create_auto_moderation_rule(1, %{"name" => "no-spam"}, reason: "policy")
assert_receive {:rest_hit, info}
assert Map.new(info.headers)["x-audit-log-reason"] == "policy"
end
test "list_auto_moderation_rules decodes a list of rules" do
FakeRest.stub(
:get,
"/guilds/1/auto-moderation/rules",
FakeRest.resp(200, body: ~s([{"id":"5","name":"a"},{"id":"6","name":"b"}]))
)
assert {:ok, [%Dexcord.AutoModerationRule{id: 5}, %Dexcord.AutoModerationRule{id: 6}]} =
Api.list_auto_moderation_rules(1)
end
# --- Moderation: scheduled events --------------------------------------
test "get_guild_scheduled_event forwards with_user_count and decodes the event" do
FakeRest.stub(
:get,
"/guilds/1/scheduled-events/2",
FakeRest.resp(200, body: ~s({"id":"2","name":"party","user_count":10}))
)
assert {:ok, %Dexcord.GuildScheduledEvent{id: 2, name: "party", user_count: 10}} =
Api.get_guild_scheduled_event(1, 2, with_user_count: true)
assert_receive {:rest_hit, info}
assert info.query_string == "with_user_count=true"
end
test "delete_guild_scheduled_event returns nil (no reason support)" do
FakeRest.stub(:delete, "/guilds/1/scheduled-events/2", FakeRest.resp(204))
assert {:ok, nil} = Api.delete_guild_scheduled_event(1, 2)
end
# --- Emojis / Stickers -------------------------------------------------
test "create_guild_emoji sends a base64 image in a plain JSON body (NOT multipart)" do
FakeRest.stub(
:post,
"/guilds/1/emojis",
FakeRest.resp(200, body: ~s({"id":"9","name":"blob"}))
)
image = "data:image/png;base64,iVBORw0KGgo="
assert {:ok, %Dexcord.Emoji{id: 9, name: "blob"}} =
Api.create_guild_emoji(1, %{"name" => "blob", "image" => image}, reason: "add")
assert_receive {:rest_hit, info}
headers = Map.new(info.headers)
assert headers["content-type"] == "application/json"
assert JSON.decode!(info.body) == %{"name" => "blob", "image" => image}
end
test "create_guild_sticker sends plain-form multipart (name/description/tags + file)" do
FakeRest.stub(
:post,
"/guilds/1/stickers",
FakeRest.resp(201, body: ~s({"id":"9","name":"wave"}))
)
file = %{filename: "wave.png", data: <<137, 80, 78, 71>>, content_type: "image/png"}
assert {:ok, %Dexcord.Sticker{id: 9, name: "wave"}} =
Api.create_guild_sticker(
1,
%{"name" => "wave", "description" => "a wave", "tags" => "wave"},
files: [file],
reason: "add sticker"
)
assert_receive {:rest_hit, info}
headers = Map.new(info.headers)
assert "multipart/form-data; boundary=" <> boundary = headers["content-type"]
assert headers["x-audit-log-reason"] == "add%20sticker"
named = info.body |> parse_multipart(boundary) |> by_name()
assert named["name"].data == "wave"
assert named["name"].filename == nil
assert named["description"].data == "a wave"
assert named["tags"].data == "wave"
assert %{"file" => file_part} = named
assert file_part.filename == "wave.png"
assert file_part.content_type == "image/png"
assert file_part.data == <<137, 80, 78, 71>>
end
test "list_application_emojis returns the raw {items: [...]} envelope" do
FakeRest.stub(
:get,
"/applications/1/emojis",
FakeRest.resp(200, body: ~s({"items":[]}))
)
assert {:ok, %{"items" => []}} = Api.list_application_emojis(1)
end
# --- Users -------------------------------------------------------------
test "edit_current_user PATCHes and decodes a User" do
FakeRest.stub(
:patch,
"/users/@me",
FakeRest.resp(200, body: ~s({"id":"42","username":"bot"}))
)
assert {:ok, %Dexcord.User{id: 42, username: "bot"}} =
Api.edit_current_user(%{"username" => "bot"})
end
test "get_current_user_guilds forwards the query and decodes PartialGuilds" do
FakeRest.stub(
:get,
"/users/@me/guilds",
FakeRest.resp(200, body: ~s([{"id":"1","name":"g1"},{"id":"2","name":"g2"}]))
)
assert {:ok, [%Dexcord.PartialGuild{id: 1, name: "g1"}, %Dexcord.PartialGuild{id: 2}]} =
Api.get_current_user_guilds(limit: 100, with_counts: true)
assert_receive {:rest_hit, info}
assert URI.decode_query(info.query_string) == %{"limit" => "100", "with_counts" => "true"}
end
test "leave_guild returns nil" do
FakeRest.stub(:delete, "/users/@me/guilds/1", FakeRest.resp(204))
assert {:ok, nil} = Api.leave_guild(1)
end
# --- Misc: application-info re-point ------------------------------------
test "get_current_application now hits the canonical /applications/@me route" do
FakeRest.stub(
:get,
"/applications/@me",
FakeRest.resp(200, body: ~s({"id":"99","name":"App"}))
)
assert {:ok, %Dexcord.ApplicationInfo{id: 99}} = Api.get_current_application()
assert_receive {:rest_hit, info}
assert info.path == "/applications/@me"
end
test "get_current_bot_application keeps the oauth2 route" do
FakeRest.stub(
:get,
"/oauth2/applications/@me",
FakeRest.resp(200, body: ~s({"id":"99","name":"App"}))
)
assert {:ok, %Dexcord.ApplicationInfo{id: 99}} = Api.get_current_bot_application()
assert_receive {:rest_hit, info}
assert info.path == "/oauth2/applications/@me"
end
# --- multipart parsing helpers (copied from api_integration_test.exs) ---
defp parse_multipart(body, boundary) do
body
|> String.split("--" <> boundary)
|> Enum.drop(1)
|> Enum.reject(fn seg -> seg == "--\r\n" or seg == "--" end)
|> Enum.map(fn seg ->
seg = String.replace_prefix(seg, "\r\n", "")
[raw_headers, rest] = String.split(seg, "\r\n\r\n", parts: 2)
data = String.replace_suffix(rest, "\r\n", "")
disposition = header_value(raw_headers, "content-disposition")
%{
name: extract(disposition, ~r/name="([^"]*)"/),
filename: extract(disposition, ~r/filename="([^"]*)"/),
content_type: header_value(raw_headers, "content-type"),
data: data
}
end)
end
defp by_name(parts), do: Map.new(parts, fn part -> {part.name, part} end)
defp header_value(raw_headers, name) do
raw_headers
|> String.split("\r\n")
|> Enum.find_value(fn line ->
case String.split(line, ": ", parts: 2) do
[key, value] -> if String.downcase(key) == name, do: value
_ -> nil
end
end)
end
defp extract(nil, _re), do: nil
defp extract(string, re) do
case Regex.run(re, string) do
[_, captured] -> captured
_ -> nil
end
end
end

View file

@ -0,0 +1,169 @@
defmodule Dexcord.ApiWebhooksTest do
@moduledoc """
Representative wire tests for the `Dexcord.Api.Webhooks` and
`Dexcord.Api.Invites` groups plus the extended `Dexcord.Api.Interactions`
group (Phase 5 Task 4): the `wait` / `thread_id` / `with_response` specials,
multipart webhook-message edit, and token bucketing that never leaks the token.
"""
use ExUnit.Case, async: false
alias Dexcord.Api
alias Dexcord.Api.Ratelimit
alias Dexcord.FakeRest
@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})
start_supervised!(Ratelimit)
start_supervised!(FakeRest)
Application.put_env(:dexcord, :api_base_url, FakeRest.base_url())
FakeRest.subscribe(self())
:ok
end
# --- Webhooks -----------------------------------------------------------
test "create_webhook wraps a binary as name, decodes a Webhook, carries a reason" do
FakeRest.stub(
:post,
"/channels/1/webhooks",
FakeRest.resp(200, body: ~s({"id":"9","name":"hook"}))
)
assert {:ok, %Dexcord.Webhook{id: 9, name: "hook"}} =
Api.create_webhook(1, "hook", reason: "notify")
assert_receive {:rest_hit, info}
assert JSON.decode!(info.body) == %{"name" => "hook"}
assert Map.new(info.headers)["x-audit-log-reason"] == "notify"
end
test "get_webhook_with_token decodes a Webhook" do
FakeRest.stub(:get, "/webhooks/9/tok", FakeRest.resp(200, body: ~s({"id":"9"})))
assert {:ok, %Dexcord.Webhook{id: 9}} = Api.get_webhook_with_token(9, "tok")
end
test "execute_webhook with no wait sends no query and passes a 204 through as nil" do
FakeRest.stub(:post, "/webhooks/9/tok", FakeRest.resp(204))
assert {:ok, nil} = Api.execute_webhook(9, "tok", "hi")
assert_receive {:rest_hit, info}
assert info.query_string == ""
assert JSON.decode!(info.body) == %{"content" => "hi"}
end
test "execute_webhook with wait+thread_id forwards the query and decodes a Message" do
FakeRest.stub(
:post,
"/webhooks/9/tok",
FakeRest.resp(200, body: ~s({"id":"5","content":"hi"}))
)
assert {:ok, %Dexcord.Message{id: 5, content: "hi"}} =
Api.execute_webhook(9, "tok", "hi", wait: true, thread_id: 123)
assert_receive {:rest_hit, info}
assert URI.decode_query(info.query_string) == %{"wait" => "true", "thread_id" => "123"}
end
test "execute_slack_webhook returns the raw compat response" do
FakeRest.stub(:post, "/webhooks/9/tok/slack", FakeRest.resp(200, body: ~s({"ok":true})))
assert {:ok, %{"ok" => true}} = Api.execute_slack_webhook(9, "tok", %{"text" => "hi"})
end
test "edit_webhook_message with files sends a multipart body and decodes a Message" do
FakeRest.stub(
:patch,
"/webhooks/9/tok/messages/5",
FakeRest.resp(200, body: ~s({"id":"5","content":"edited"}))
)
file = %{filename: "a.png", data: <<1, 2, 3>>, content_type: "image/png"}
assert {:ok, %Dexcord.Message{id: 5, content: "edited"}} =
Api.edit_webhook_message(9, "tok", 5, %{"content" => "edited"}, files: [file])
assert_receive {:rest_hit, info}
assert "multipart/form-data; boundary=" <> _ = Map.new(info.headers)["content-type"]
end
# --- Invites ------------------------------------------------------------
test "get_invite forwards with_counts and decodes an Invite" do
FakeRest.stub(
:get,
"/invites/abc",
FakeRest.resp(200, body: ~s({"code":"abc","approximate_member_count":42}))
)
assert {:ok, %Dexcord.Invite{code: "abc", approximate_member_count: 42}} =
Api.get_invite("abc", with_counts: true)
assert_receive {:rest_hit, info}
assert info.query_string == "with_counts=true"
end
test "delete_invite decodes the deleted Invite with a reason" do
FakeRest.stub(:delete, "/invites/abc", FakeRest.resp(200, body: ~s({"code":"abc"})))
assert {:ok, %Dexcord.Invite{code: "abc"}} = Api.delete_invite("abc", reason: "revoke")
assert_receive {:rest_hit, info}
assert Map.new(info.headers)["x-audit-log-reason"] == "revoke"
end
# --- Interactions -------------------------------------------------------
test "create_interaction_response forwards the with_response query" do
FakeRest.stub(
:post,
"/interactions/1/itok/callback",
FakeRest.resp(200, body: ~s({"resource":{}}))
)
assert {:ok, %{"resource" => %{}}} =
Api.create_interaction_response(1, "itok", %{"type" => 4}, with_response: true)
assert_receive {:rest_hit, info}
assert info.query_string == "with_response=true"
end
test "get_followup_message decodes a Message" do
FakeRest.stub(
:get,
"/webhooks/99/itok/messages/5",
FakeRest.resp(200, body: ~s({"id":"5","content":"fu"}))
)
assert {:ok, %Dexcord.Message{id: 5, content: "fu"}} =
Api.get_followup_message(99, "itok", 5)
end
test "delete_followup_message returns nil" do
FakeRest.stub(:delete, "/webhooks/99/itok/messages/5", FakeRest.resp(204))
assert {:ok, nil} = Api.delete_followup_message(99, "itok", 5)
end
# --- Token bucketing ----------------------------------------------------
test "the webhook token never appears in the ratelimit route key" do
secret = "super-secret-webhook-token"
key = Ratelimit.route_key(:post, "/webhooks/9/#{secret}/messages/5")
refute key =~ secret
assert key =~ "webhooks/9/"
# The trailing message id collapses to a bounded placeholder.
assert String.ends_with?(key, "/messages/:id [message-delete]") or
String.ends_with?(key, "/messages/:id")
end
end

View file

@ -70,11 +70,11 @@ defmodule Dexcord.CacheCascadeTest do
)
# The next dispatch caches fine against the recreated tables - no crash.
guild = %{"id" => "g1", "name" => "Test Guild"}
guild = %{"id" => "1", "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")
wait_until(fn -> match?({:ok, _}, Dexcord.Cache.guild(1)) end, 200)
assert {:ok, %Dexcord.Guild{id: 1, name: "Test Guild"}} = Dexcord.Cache.guild(1)
# The gateway was never restarted (still the same, still alive) - crash contained.
assert Process.whereis(Dexcord.Gateway) == gateway

View file

@ -0,0 +1,103 @@
defmodule Dexcord.CacheFillTest do
@moduledoc """
Unit tests for `Dexcord.Cache.fill/1` (api-surface.AC3.8): best-effort
hydration of an envelope's declared `hydrate` slots from ETS only.
These tests start ONLY `Dexcord.Cache` - no FakeRest, no Finch pool. `fill/1`
reads ETS and nothing else, so any accidental HTTP round-trip would crash on
the missing connection pool; the suite passing is the proof that `fill/1`
never touches the network.
"""
use ExUnit.Case, async: false
alias Dexcord.Cache
alias Dexcord.Events
setup do
Dexcord.EnvSandbox.sandbox_env()
start_supervised!(Dexcord.Cache)
:ok
end
# Seed the cache exactly as the dispatcher would: decode once, hand the cache
# both the decoded struct and the raw partial map.
defp feed(name, raw) do
Cache.handle_dispatch(name, Events.decode(name, raw), raw, %{cache_presences: false})
end
# The golden guild's own ids (see test/fixtures/guild_create.json).
@guild_id 900_000_000_000_000_000
# channel 100000000000000000 is a type-0 text channel.
@channel_id 100_000_000_000_000_000
# member/user 800000000000000000 is Nelly.
@user_id 800_000_000_000_000_000
defp reaction(overrides) do
raw =
Map.merge(
%{
"user_id" => to_string(@user_id),
"channel_id" => to_string(@channel_id),
"message_id" => "100000000000000099",
"guild_id" => to_string(@guild_id),
"emoji" => %{"id" => nil, "name" => "👍"}
},
overrides
)
Events.decode(:MESSAGE_REACTION_ADD, raw)
end
describe "fill/1 hydration (AC3.8)" do
test "populates every declared slot from the cache on a full hit" do
feed(:GUILD_CREATE, Dexcord.Fixtures.load!("guild_create.json"))
event = reaction(%{})
# After decode the hydrate slots are always nil.
assert %Dexcord.Events.ReactionAdd{user: nil, channel: nil, guild: nil} = event
filled = Cache.fill(event)
assert %Dexcord.Events.ReactionAdd{
user: %Dexcord.User{id: @user_id},
channel: %Dexcord.TextChannel{id: @channel_id},
guild: %Dexcord.Guild{id: @guild_id}
} = filled
end
test "a cache miss leaves that slot nil while the others still fill" do
feed(:GUILD_CREATE, Dexcord.Fixtures.load!("guild_create.json"))
# Unknown user id: not in the cache.
event = reaction(%{"user_id" => "111111111111111111"})
filled = Cache.fill(event)
assert filled.user == nil
assert %Dexcord.TextChannel{id: @channel_id} = filled.channel
assert %Dexcord.Guild{id: @guild_id} = filled.guild
end
test "an unseeded cache leaves every slot nil (never blocks on the network)" do
# No GUILD_CREATE seeded and, deliberately, no FakeRest/Finch started: if
# fill/1 ever reached for HTTP it would crash here instead of returning nils.
filled = Cache.fill(reaction(%{}))
assert %Dexcord.Events.ReactionAdd{user: nil, channel: nil, guild: nil} = filled
end
test "fill/1 is idempotent" do
feed(:GUILD_CREATE, Dexcord.Fixtures.load!("guild_create.json"))
event = reaction(%{})
once = Cache.fill(event)
assert Cache.fill(once) == once
end
test "a struct with no hydration slots passes through unchanged" do
user = %Dexcord.User{id: @user_id, username: "nelly"}
assert Cache.fill(user) == user
end
end
end

View file

@ -52,27 +52,27 @@ defmodule Dexcord.CacheIntegrationTest do
await_ready()
guild = %{
"id" => "g100",
"id" => "100",
"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"}]
"channels" => [%{"id" => "101", "type" => 0}],
"roles" => [%{"id" => "102", "name" => "everyone"}],
"members" => [%{"user" => %{"id" => "103", "username" => "alice"}, "nick" => "a"}],
"voice_states" => [%{"user_id" => "103", "channel_id" => "104"}],
"presences" => [%{"user" => %{"id" => "103"}, "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
assert_receive {:handler_saw, :GUILD_CREATE, {:ok, %Dexcord.Guild{id: 100}}}, @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")
# And every fan-out table is populated with decoded structs keyed by integers.
assert {:ok, %Dexcord.TextChannel{guild_id: 100}} = Cache.channel(101)
assert {:ok, %Dexcord.Role{name: "everyone"}} = Cache.role(100, 102)
assert {:ok, %Dexcord.Member{user_id: 103, user: nil}} = Cache.member(100, 103)
assert {:ok, %Dexcord.User{username: "alice"}} = Cache.user(103)
assert {:ok, %Dexcord.VoiceState{channel_id: 104}} = Cache.voice_state(100, 103)
assert {:ok, %Dexcord.Presence{status: "online"}} = Cache.presence(100, 103)
end
test "MESSAGE_CREATE author is cached before the handler observes it" do
@ -80,10 +80,11 @@ defmodule Dexcord.CacheIntegrationTest do
start_bot(fake, intents: :all)
await_ready()
msg = %{"id" => "m1", "author" => %{"id" => "au1", "username" => "bob"}, "content" => "hi"}
msg = %{"id" => "1", "author" => %{"id" => "200", "username" => "bob"}, "content" => "hi"}
FakeGateway.push_dispatch(fake, "MESSAGE_CREATE", msg, 11)
assert_receive {:handler_saw, :MESSAGE_CREATE, {:ok, %{"username" => "bob"}}}, @timeout
assert_receive {:handler_saw, :MESSAGE_CREATE, {:ok, %Dexcord.User{username: "bob"}}},
@timeout
end
test "chunking: GUILD_CREATE -> client sends op 8 -> CHUNK populates members" do
@ -93,21 +94,21 @@ defmodule Dexcord.CacheIntegrationTest do
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)
FakeGateway.push_dispatch(fake, "GUILD_CREATE", %{"id" => "400", "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["guild_id"] == "400"
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",
"guild_id" => "400",
"members" => [
%{"user" => %{"id" => "cu1", "username" => "x"}},
%{"user" => %{"id" => "cu2", "username" => "y"}}
%{"user" => %{"id" => "401", "username" => "x"}},
%{"user" => %{"id" => "402", "username" => "y"}}
]
}
@ -116,8 +117,8 @@ defmodule Dexcord.CacheIntegrationTest do
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")
assert {:ok, %Dexcord.Member{}} = Cache.member(400, 401)
assert {:ok, %Dexcord.Member{}} = Cache.member(400, 402)
end
test "no op 8 is sent when request_guild_members is false" do
@ -125,8 +126,8 @@ defmodule Dexcord.CacheIntegrationTest do
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
FakeGateway.push_dispatch(fake, "GUILD_CREATE", %{"id" => "500", "name" => "q"}, 30)
assert_receive {:handler_saw, :GUILD_CREATE, {:ok, %Dexcord.Guild{}}}, @timeout
refute_receive {:fake_gw, :frame, _c, %{"op" => 8}}, 300
end

View file

@ -1,9 +1,10 @@
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.
Unit tests for `Dexcord.Cache.handle_dispatch/4` against real ETS. Each test
starts a fresh `Dexcord.Cache` (which owns and creates the tables), feeds it a
raw string-keyed payload map decoded exactly as the dispatcher would (via
`feed/3`), and asserts table contents through the public read API - which now
returns model structs keyed by integer snowflakes (api-surface.AC2.20).
"""
use ExUnit.Case, async: false
@ -17,341 +18,424 @@ defmodule Dexcord.CacheTest do
:ok
end
defp user(id, name \\ "u"), do: %{"id" => id, "username" => "#{name}#{id}"}
# Feed a raw wire payload exactly as the dispatcher does: decode once, then hand
# the cache both the decoded struct and the raw partial map.
defp feed(name, raw, config) do
Cache.handle_dispatch(name, Dexcord.Events.decode(name, raw), raw, config)
end
defp user(id, name \\ "u"), do: %{"id" => to_string(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.
# A full-ish guild payload as GUILD_CREATE delivers it. Child ids are derived
# from `gid` so a test can reference them: channel `gid+1`, role `gid+2`, member
# user `gid+3`, its voice channel `gid+4`.
defp guild_create(gid, opts \\ []) do
%{
"id" => gid,
"id" => to_string(gid),
"name" => "guild#{gid}",
"emojis" => [%{"id" => "e1", "name" => "smile"}],
"channels" => Keyword.get(opts, :channels, [%{"id" => "c#{gid}", "type" => 0}]),
"emojis" => [%{"id" => to_string(gid + 90), "name" => "smile"}],
"channels" => Keyword.get(opts, :channels, [%{"id" => to_string(gid + 1), "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}")]),
"roles" => Keyword.get(opts, :roles, [%{"id" => to_string(gid + 2), "name" => "everyone"}]),
"members" => Keyword.get(opts, :members, [member(gid + 3)]),
"voice_states" =>
Keyword.get(opts, :voice_states, [%{"user_id" => "m#{gid}", "channel_id" => "vc#{gid}"}]),
Keyword.get(opts, :voice_states, [
%{"user_id" => to_string(gid + 3), "channel_id" => to_string(gid + 4)}
]),
"presences" =>
Keyword.get(opts, :presences, [%{"user" => %{"id" => "m#{gid}"}, "status" => "online"}])
Keyword.get(opts, :presences, [
%{"user" => %{"id" => to_string(gid + 3)}, "status" => "online"}
])
}
end
describe "READY" do
test "stores the bot user and unavailable guild stubs" do
test "stores the bot user (struct) and unavailable guild stubs" do
data = %{
"user" => user("bot1", "self"),
"user" => user(10, "self"),
"guilds" => [
%{"id" => "g1", "unavailable" => true},
%{"id" => "g2", "unavailable" => true}
%{"id" => "1", "unavailable" => true},
%{"id" => "2", "unavailable" => true}
]
}
Cache.handle_dispatch(:READY, data, @off)
feed(:READY, data, @off)
assert {:ok, %{"id" => "bot1"}} = Cache.me()
assert {:ok, %{"unavailable" => true}} = Cache.guild("g1")
assert {:ok, %{"unavailable" => true}} = Cache.guild("g2")
assert {:ok, %Dexcord.User{id: 10}} = Cache.me()
assert {:ok, %Dexcord.UnavailableGuild{id: 1, unavailable: true}} = Cache.guild(1)
assert {:ok, %Dexcord.UnavailableGuild{id: 2}} = Cache.guild(2)
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)
test "stores struct guild row with big arrays stripped and fans out children" do
feed(:GUILD_CREATE, guild_create(100), @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"
{:ok, g} = Cache.guild(100)
assert %Dexcord.Guild{id: 100} = g
# Big arrays stripped to empty...
for f <- [:channels, :threads, :roles, :members, :presences, :voice_states] do
assert Map.get(g, f) == [], "expected #{f} emptied on the guild row"
end
# ...emojis stay inline.
assert [%{"name" => "smile"}] = g["emojis"]
# ...emojis stay inline as decoded structs.
assert [%Dexcord.Emoji{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")
# AC2.20: wrote from string-snowflake wire data, read back with INTEGER key.
assert {:ok, %Dexcord.TextChannel{guild_id: 100}} = Cache.channel(101)
assert [%Dexcord.TextChannel{id: 101}] = Cache.channels(100)
assert {:ok, %{"name" => "everyone"}} = Cache.role("g1", "rg1")
assert [%{"id" => "rg1"}] = Cache.roles("g1")
assert {:ok, %Dexcord.Role{name: "everyone"}} = Cache.role(100, 102)
assert [%Dexcord.Role{id: 102}] = Cache.roles(100)
# 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")
# Member: nested "user" moved to users table; row keeps user_id, user: nil.
assert {:ok, %Dexcord.Member{user_id: 103, user: nil, nick: "nick103"}} =
Cache.member(100, 103)
assert {:ok, %{"channel_id" => "vcg1"}} = Cache.voice_state("g1", "mg1")
assert {:ok, %{"status" => "online"}} = Cache.presence("g1", "mg1")
assert {:ok, %Dexcord.User{id: 103}} = Cache.user(103)
assert {:ok, %Dexcord.VoiceState{channel_id: 104}} = Cache.voice_state(100, 103)
assert {:ok, %Dexcord.Presence{status: "online"}} = Cache.presence(100, 103)
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") == []
feed(:GUILD_CREATE, guild_create(100), @off)
assert Cache.presence(100, 103) == :error
assert Cache.presences(100) == []
# But members/voice_states still populate.
assert {:ok, _} = Cache.member("g1", "mg1")
assert {:ok, _} = Cache.voice_state("g1", "mg1")
assert {:ok, %Dexcord.Member{}} = Cache.member(100, 103)
assert {:ok, %Dexcord.VoiceState{}} = Cache.voice_state(100, 103)
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)
describe "GUILD_UPDATE and child tables" do
test "updates the guild row without touching the per-guild child tables" do
feed(:GUILD_CREATE, guild_create(100), @on)
feed(:GUILD_UPDATE, %{"id" => "100", "name" => "renamed"}, @on)
{:ok, g} = Cache.guild("g1")
assert g["name"] == "renamed"
assert {:ok, %Dexcord.Guild{name: "renamed"}} = Cache.guild(100)
# 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")
assert {:ok, %Dexcord.Member{}} = Cache.member(100, 103)
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
)
test "CHANNEL_CREATE/UPDATE/DELETE and THREAD_* upsert and remove structs" do
feed(:CHANNEL_CREATE, %{"id" => "1", "type" => 0, "guild_id" => "5", "name" => "gen"}, @off)
assert {:ok, %Dexcord.TextChannel{name: "gen"}} = Cache.channel(1)
assert {:ok, %{"name" => "gen"}} = Cache.channel("c1")
Cache.handle_dispatch(
feed(
:CHANNEL_UPDATE,
%{"id" => "c1", "guild_id" => "g1", "name" => "renamed"},
%{"id" => "1", "type" => 0, "guild_id" => "5", "name" => "renamed"},
@off
)
assert {:ok, %{"name" => "renamed"}} = Cache.channel("c1")
assert {:ok, %Dexcord.TextChannel{name: "renamed"}} = Cache.channel(1)
Cache.handle_dispatch(:THREAD_CREATE, %{"id" => "t1", "guild_id" => "g1"}, @off)
assert {:ok, _} = Cache.channel("t1")
feed(:THREAD_CREATE, %{"id" => "2", "type" => 11, "guild_id" => "5"}, @off)
assert {:ok, %Dexcord.Thread{}} = Cache.channel(2)
Cache.handle_dispatch(:THREAD_DELETE, %{"id" => "t1"}, @off)
assert Cache.channel("t1") == :error
feed(:THREAD_DELETE, %{"id" => "2", "guild_id" => "5"}, @off)
assert Cache.channel(2) == :error
Cache.handle_dispatch(:CHANNEL_DELETE, %{"id" => "c1"}, @off)
assert Cache.channel("c1") == :error
feed(:CHANNEL_DELETE, %{"id" => "1", "type" => 0}, @off)
assert Cache.channel(1) == :error
end
end
describe "threads/1" do
test "returns only thread-typed channels for the given guild" do
test "returns only Thread structs for the given guild" do
g1 =
guild_create("g1",
guild_create(100,
channels: [
%{"id" => "c1", "type" => 0},
%{"id" => "t1", "type" => 10},
%{"id" => "t2", "type" => 11},
%{"id" => "t3", "type" => 12}
%{"id" => "1", "type" => 0},
%{"id" => "2", "type" => 10},
%{"id" => "3", "type" => 11},
%{"id" => "4", "type" => 12}
],
threads: []
)
g2 =
guild_create("g2",
channels: [%{"id" => "c2", "type" => 0}, %{"id" => "t4", "type" => 11}],
guild_create(200,
channels: [%{"id" => "5", "type" => 0}, %{"id" => "6", "type" => 11}],
threads: []
)
Cache.handle_dispatch(:GUILD_CREATE, g1, @off)
Cache.handle_dispatch(:GUILD_CREATE, g2, @off)
feed(:GUILD_CREATE, g1, @off)
feed(:GUILD_CREATE, g2, @off)
assert Cache.threads("g1") |> Enum.map(& &1["id"]) |> Enum.sort() == ["t1", "t2", "t3"]
assert Cache.threads("g2") |> Enum.map(& &1["id"]) == ["t4"]
assert Cache.threads("nope") == []
assert Cache.threads(100) |> Enum.map(& &1.id) |> Enum.sort() == [2, 3, 4]
assert Cache.threads(200) |> Enum.map(& &1.id) == [6]
assert Cache.threads(999) == []
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")
test "GUILD_MEMBER_ADD moves user out; GUILD_MEMBER_REMOVE deletes the member" do
feed(:GUILD_MEMBER_ADD, member(1, %{"guild_id" => "5"}), @off)
# Update merges (keeps prior fields, changes nick).
Cache.handle_dispatch(
:GUILD_MEMBER_UPDATE,
%{"guild_id" => "g1", "user" => user("u1"), "nick" => "new"},
@off
)
assert {:ok, %Dexcord.Member{user_id: 1, user: nil, nick: "nick1"}} = Cache.member(5, 1)
assert {:ok, %Dexcord.User{id: 1}} = Cache.user(1)
{: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
feed(:GUILD_MEMBER_REMOVE, %{"guild_id" => "5", "user" => user(1)}, @off)
assert Cache.member(5, 1) == :error
# User survives removal from one guild.
assert {:ok, _} = Cache.user("u1")
assert {:ok, %Dexcord.User{id: 1}} = Cache.user(1)
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"}]
"guild_id" => "5",
"members" => [member(1), member(2)],
"presences" => [%{"user" => %{"id" => "1"}, "status" => "idle"}]
}
Cache.handle_dispatch(:GUILD_MEMBERS_CHUNK, data, @on)
feed(: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")
assert length(Cache.members(5)) == 2
assert {:ok, %Dexcord.User{}} = Cache.user(1)
assert {:ok, %Dexcord.User{}} = Cache.user(2)
assert {:ok, %Dexcord.Presence{status: "idle"}} = Cache.presence(5, 1)
end
end
describe "roles" do
test "ROLE_CREATE/UPDATE/DELETE" do
Cache.handle_dispatch(
feed(
:GUILD_ROLE_CREATE,
%{"guild_id" => "g1", "role" => %{"id" => "r1", "name" => "mod"}},
%{"guild_id" => "5", "role" => %{"id" => "9", "name" => "mod"}},
@off
)
assert {:ok, %{"name" => "mod"}} = Cache.role("g1", "r1")
assert {:ok, %Dexcord.Role{name: "mod"}} = Cache.role(5, 9)
Cache.handle_dispatch(
feed(
:GUILD_ROLE_UPDATE,
%{"guild_id" => "g1", "role" => %{"id" => "r1", "name" => "admin"}},
%{"guild_id" => "5", "role" => %{"id" => "9", "name" => "admin"}},
@off
)
assert {:ok, %{"name" => "admin"}} = Cache.role("g1", "r1")
assert {:ok, %Dexcord.Role{name: "admin"}} = Cache.role(5, 9)
Cache.handle_dispatch(:GUILD_ROLE_DELETE, %{"guild_id" => "g1", "role_id" => "r1"}, @off)
assert Cache.role("g1", "r1") == :error
feed(:GUILD_ROLE_DELETE, %{"guild_id" => "5", "role_id" => "9"}, @off)
assert Cache.role(5, 9) == :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"}
p = %{"guild_id" => "5", "user" => %{"id" => "1"}, "status" => "dnd"}
Cache.handle_dispatch(:PRESENCE_UPDATE, p, @off)
assert Cache.presence("g1", "u1") == :error
feed(:PRESENCE_UPDATE, p, @off)
assert Cache.presence(5, 1) == :error
Cache.handle_dispatch(:PRESENCE_UPDATE, p, @on)
assert {:ok, %{"status" => "dnd"}} = Cache.presence("g1", "u1")
feed(:PRESENCE_UPDATE, p, @on)
assert {:ok, %Dexcord.Presence{status: "dnd"}} = Cache.presence(5, 1)
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
)
feed(:VOICE_STATE_UPDATE, %{"guild_id" => "5", "user_id" => "1", "channel_id" => "7"}, @off)
assert {:ok, %Dexcord.VoiceState{channel_id: 7}} = Cache.voice_state(5, 1)
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
feed(:VOICE_STATE_UPDATE, %{"guild_id" => "5", "user_id" => "1", "channel_id" => nil}, @off)
assert Cache.voice_state(5, 1) == :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()
describe "GUILD_UPDATE / GUILD_EMOJIS_UPDATE / GUILD_STICKERS_UPDATE" do
test "GUILD_UPDATE merge preserves gateway-only fields like joined_at (AC2.21)" do
golden = Dexcord.Fixtures.load!("guild_create.json")
gid = golden["id"]
feed(:GUILD_CREATE, golden, @off)
original = Cache.guild!(gid)
assert %DateTime{} = original.joined_at
feed(:GUILD_UPDATE, %{"id" => gid, "name" => "renamed"}, @off)
updated = Cache.guild!(gid)
assert updated.name == "renamed"
# joined_at is absent from GUILD_UPDATE payloads: the merge must not drop it.
assert updated.joined_at == original.joined_at
assert updated.verification_level == original.verification_level
end
test "MESSAGE_CREATE opportunistically upserts author and member fragment" do
test "GUILD_UPDATE on an unknown guild stores it decoded (minus children)" do
feed(:GUILD_UPDATE, %{"id" => "100", "name" => "fresh"}, @off)
assert {:ok, %Dexcord.Guild{id: 100, name: "fresh", members: []}} = Cache.guild(100)
end
test "GUILD_EMOJIS_UPDATE replaces the inline emoji list" do
feed(:GUILD_CREATE, guild_create(100), @off)
feed(
:GUILD_EMOJIS_UPDATE,
%{"guild_id" => "100", "emojis" => [%{"id" => "77", "name" => "wave"}]},
@off
)
{:ok, g} = Cache.guild(100)
assert [%Dexcord.Emoji{name: "wave"}] = g.emojis
end
test "GUILD_STICKERS_UPDATE replaces the inline sticker list" do
feed(:GUILD_CREATE, guild_create(100), @off)
feed(
:GUILD_STICKERS_UPDATE,
%{"guild_id" => "100", "stickers" => [%{"id" => "88", "name" => "party"}]},
@off
)
{:ok, g} = Cache.guild(100)
assert [%Dexcord.Sticker{name: "party"}] = g.stickers
end
end
describe "GUILD_MEMBER_UPDATE merge" do
test "merges only present keys and creates the row when absent" do
feed(
:GUILD_MEMBER_ADD,
member(1, %{"guild_id" => "5", "joined_at" => "2020-01-01T00:00:00Z"}),
@off
)
{:ok, before} = Cache.member(5, 1)
assert before.nick == "nick1"
assert %DateTime{} = before.joined_at
feed(
:GUILD_MEMBER_UPDATE,
%{"guild_id" => "5", "user" => user(1), "roles" => ["9", "10"]},
@off
)
{:ok, updated} = Cache.member(5, 1)
assert updated.roles == [9, 10]
# nick / joined_at were not in the update payload: they must survive.
assert updated.nick == "nick1"
assert updated.joined_at == before.joined_at
assert updated.user == nil
assert updated.user_id == 1
# No existing row: the update creates one.
feed(:GUILD_MEMBER_UPDATE, %{"guild_id" => "5", "user" => user(2), "nick" => "new"}, @off)
{:ok, created} = Cache.member(5, 2)
assert created.nick == "new"
assert created.user_id == 2
assert created.user == nil
end
end
describe "USER_UPDATE merge" do
test "merges the partial into the cached me record, preserving absent fields" do
feed(
:READY,
%{
"user" => %{"id" => "10", "username" => "self", "global_name" => "Selfy"},
"guilds" => []
},
@off
)
feed(:USER_UPDATE, %{"id" => "10", "username" => "renamed"}, @off)
{:ok, me} = Cache.me()
assert me.username == "renamed"
# global_name was absent from the update: merge must preserve it.
assert me.global_name == "Selfy"
assert me.id == 10
end
end
describe "MESSAGE_CREATE fold-in" do
test "folds author + member fragment and stamps user_id" do
data = %{
"guild_id" => "g1",
"author" => user("a1"),
"member" => %{"nick" => "frag", "roles" => ["r1"]}
"id" => "1",
"guild_id" => "5",
"author" => user(2),
"member" => %{"nick" => "frag", "roles" => ["9"]}
}
Cache.handle_dispatch(:MESSAGE_CREATE, data, @off)
feed(:MESSAGE_CREATE, data, @off)
assert {:ok, _} = Cache.user("a1")
{:ok, m} = Cache.member("g1", "a1")
assert m["user_id"] == "a1"
assert m["nick"] == "frag"
assert {:ok, %Dexcord.User{id: 2}} = Cache.user(2)
{:ok, m} = Cache.member(5, 2)
assert m.user_id == 2
assert m.user == nil
assert m.nick == "frag"
assert m.roles == [9]
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
test "ignores webhook authors (writes nothing)" do
data = %{
"id" => "1",
"webhook_id" => "99",
"author" => %{"id" => "99", "username" => "hook"}
}
feed(:MESSAGE_CREATE, data, @off)
assert Cache.user(99) == :error
end
end
describe "GUILD_DELETE cascade (AC2.22)" do
test "purge wipes exactly that guild's rows across every per-guild table, and no others" do
feed(:GUILD_CREATE, guild_create(100), @on)
feed(:GUILD_CREATE, guild_create(200), @on)
feed(:GUILD_DELETE, %{"id" => "100"}, @on)
# Guild 100 gone everywhere - including the struct-valued channels table.
assert Cache.guild(100) == :error
assert Cache.members(100) == []
assert Cache.roles(100) == []
assert Cache.presences(100) == []
assert Cache.voice_states(100) == []
assert Cache.channels(100) == []
# Guild 200 fully intact.
assert {:ok, %Dexcord.Guild{}} = Cache.guild(200)
assert [_] = Cache.members(200)
assert [_] = Cache.roles(200)
assert [_] = Cache.presences(200)
assert [_] = Cache.voice_states(200)
assert [_] = Cache.channels(200)
end
test "unavailable:true keeps a stub instead of purging" do
feed(:GUILD_CREATE, guild_create(100), @on)
feed(:GUILD_DELETE, %{"id" => "100", "unavailable" => true}, @on)
assert {:ok, %Dexcord.UnavailableGuild{unavailable: true}} = Cache.guild(100)
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
test "bang variants return the struct or raise" do
feed(:GUILD_CREATE, guild_create(100), @on)
assert %Dexcord.Guild{id: 100} = Cache.guild!(100)
assert_raise RuntimeError, fn -> Cache.guild!(999) end
assert_raise RuntimeError, fn -> Cache.member!(100, 999) end
end
test "listings return plain lists (empty, never :error)" do
assert Cache.guilds() == []
assert Cache.channels("g1") == []
assert Cache.members("g1") == []
assert Cache.channels(5) == []
assert Cache.members(5) == []
end
test "reads accept string and integer ids interchangeably" do
feed(:GUILD_CREATE, guild_create(100), @off)
assert {:ok, %Dexcord.Guild{id: 100}} = Cache.guild(100)
assert {:ok, %Dexcord.Guild{id: 100}} = Cache.guild("100")
# Non-castable ids read as a miss, never a crash.
assert Cache.guild("not-a-snowflake") == :error
end
end
end

View file

@ -75,4 +75,25 @@ defmodule Dexcord.ConfigValidationTest do
assert %{slash_guild_ids: nil} = validate([])
end
end
describe "allowed_mentions" do
test "a keyword is normalized to a string-keyed wire map" do
assert %{allowed_mentions: %{"parse" => []}} = validate(allowed_mentions: [parse: []])
end
test "a struct is normalized via to_map" do
assert %{allowed_mentions: %{"parse" => [], "replied_user" => false}} =
validate(allowed_mentions: %Dexcord.AllowedMentions{})
end
test "absent defaults to nil" do
assert %{allowed_mentions: nil} = validate([])
end
test "an invalid type raises a friendly ArgumentError" do
assert_raise ArgumentError, ~r/:allowed_mentions must be/, fn ->
validate(allowed_mentions: 123)
end
end
end
end

View file

@ -0,0 +1,73 @@
defmodule Dexcord.Api.CoverageTest do
@moduledoc """
The coverage gate as a hermetic unit test: everything reads only committed
files (`priv/discord_openapi.json`, `priv/coverage_allowlist.exs`) and the
compiled route table no network, no live Discord.
"""
use ExUnit.Case, async: true
alias Dexcord.Api.Coverage
describe "check/1 (the gate)" do
# api-surface.AC1.8: the pinned spec is fully covered by declarations +
# allowlist. THIS is the assertion CI runs as `mix dexcord.coverage`.
test "passes against the real pinned spec" do
assert {:ok, stats} = Coverage.check()
assert stats.declared > 0
assert stats.spec > 0
assert stats.allowlisted > 0
end
# api-surface.AC1.9: dropping any one declared route re-exposes it as missing
# (this is exactly what a deleted route-table entry would do). The mix task's
# non-zero exit is a thin shell wrapper over this {:missing, _} return.
test "reports missing when a declared route is removed" do
declared = Coverage.declared_routes()
dropped = tl(declared)
# The dropped head must be a spec route (otherwise removing it changes
# nothing) — pick one that is guaranteed present in the spec.
assert {:missing, missing} = Coverage.check(dropped)
assert is_list(missing)
assert missing != []
# missing is sorted and deduped strings.
assert missing == Enum.sort(missing)
assert Enum.all?(missing, &is_binary/1)
end
end
describe "normalization" do
test "spec and declared routes agree on param collapse for get_channel" do
assert "GET /channels/*" in Coverage.spec_routes()
assert "GET /channels/*" in Coverage.declared_routes()
end
test "routes are normalized VERB /path strings" do
for route <- Coverage.declared_routes() do
assert route =~ ~r{^(GET|POST|PUT|PATCH|DELETE) /}
end
end
end
describe "allowlist matching" do
# allowlisted?/2 is private; exercise it through check/1 with a hand-built
# declared set so the match semantics are observable.
test "an unlisted, undeclared spec route is reported missing" do
# Declaring nothing surfaces every non-allowlisted spec route as missing.
assert {:missing, missing} = Coverage.check([])
assert missing != []
# Allowlisted families (e.g. lobbies) must NOT appear in the missing set.
refute Enum.any?(missing, &String.starts_with?(&1, "GET /lobbies"))
refute Enum.any?(missing, &String.starts_with?(&1, "POST /partner-sdk"))
end
test "exact and trailing-* patterns both suppress their routes" do
# An exact allowlist entry ("GET /oauth2/@me") and a prefix entry
# ("GET /lobbies*") both keep their routes out of the missing set even
# when nothing is declared.
assert {:missing, missing} = Coverage.check([])
refute "GET /oauth2/@me" in missing
refute "GET /lobbies/*" in missing
end
end
end

View file

@ -0,0 +1,175 @@
defmodule Dexcord.Api.EndpointMacroTest do
@moduledoc """
Round-trip tests for the `endpoint/4` macro: the generated functions issue the
right method/path/query/headers/body through the real `Dexcord.Api` pipeline
against the scripted `Dexcord.FakeRest` server, and decode responses per their
declared `returns:`.
"""
use ExUnit.Case, async: false
alias Dexcord.Api.Error
alias Dexcord.Api.Ratelimit
alias Dexcord.FakeRest
alias Dexcord.Test.Endpoints
alias Dexcord.Test.Widget
@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})
start_supervised!(Ratelimit)
start_supervised!(FakeRest)
Application.put_env(:dexcord, :api_base_url, FakeRest.base_url())
FakeRest.subscribe(self())
:ok
end
describe "AC1.1: method + interpolated path + typed decode" do
test "get_widget/1 issues GET and decodes the returns struct" do
FakeRest.stub(:get, "/channels/1/widget", FakeRest.resp(200, body: ~s({"id":"77"})))
assert {:ok, %Widget{id: 77}} = Endpoints.get_widget(1)
assert_receive {:rest_hit, info}
assert info.method == "GET"
assert info.path == "/channels/1/widget"
end
end
describe "AC1.2: query params" do
test "declared query keys are URL-encoded when present" do
FakeRest.stub(:get, "/channels/1/widget", FakeRest.resp(200, body: ~s({"id":"1"})))
assert {:ok, %Widget{}} = Endpoints.get_widget(1, limit: 5, around: 99)
assert_receive {:rest_hit, info}
assert URI.decode_query(info.query_string) == %{"limit" => "5", "around" => "99"}
end
test "no query opts sends an empty query string" do
FakeRest.stub(:get, "/channels/1/widget", FakeRest.resp(200, body: ~s({"id":"1"})))
assert {:ok, %Widget{}} = Endpoints.get_widget(1)
assert_receive {:rest_hit, info}
assert info.query_string == ""
end
end
describe "AC1.3: reason header" do
test "reason: endpoints send a URL-encoded X-Audit-Log-Reason" do
FakeRest.stub(:delete, "/channels/1/widget/2", FakeRest.resp(204))
assert {:ok, nil} = Endpoints.delete_widget(1, 2, reason: "spam & bad")
assert_receive {:rest_hit, info}
assert Map.new(info.headers)["x-audit-log-reason"] == "spam%20%26%20bad"
end
test "without :reason no header is sent" do
FakeRest.stub(:delete, "/channels/1/widget/2", FakeRest.resp(204))
assert {:ok, nil} = Endpoints.delete_widget(1, 2)
assert_receive {:rest_hit, info}
refute Map.has_key?(Map.new(info.headers), "x-audit-log-reason")
end
end
describe "AC1.6: error decoding" do
test "a 4xx decodes to an Error struct with status/code/message" do
body = ~s({"code":50013,"message":"Missing Permissions"})
FakeRest.stub(:get, "/channels/1/widget", FakeRest.resp(403, body: body))
assert {:error, %Error{status: 403, code: 50013, message: "Missing Permissions"}} =
Endpoints.get_widget(1)
end
end
describe "multipart + binary wrap" do
test "files: opts encode multipart with payload_json carrying attachments" do
FakeRest.stub(:post, "/channels/1/widget", FakeRest.resp(200, body: ~s({"id":"1"})))
assert {:ok, %Widget{}} =
Endpoints.create_widget(1, [name: "w"], files: [%{filename: "a.png", data: <<1>>}])
assert_receive {:rest_hit, info}
"multipart/form-data; boundary=" <> boundary = Map.new(info.headers)["content-type"]
payload = payload_json(info.body, boundary)
assert payload["name"] == "w"
assert payload["attachments"] == [%{"id" => 0, "filename" => "a.png"}]
end
test "a binary body wraps to the declared key" do
FakeRest.stub(:post, "/channels/1/widget", FakeRest.resp(200, body: ~s({"id":"1"})))
assert {:ok, %Widget{}} = Endpoints.create_widget(1, "hello")
assert_receive {:rest_hit, info}
assert JSON.decode!(info.body) == %{"name" => "hello"}
end
end
describe "list returns and default :raw" do
test "a [Module] return decodes to a list of structs" do
FakeRest.stub(
:get,
"/channels/1/widgets",
FakeRest.resp(200, body: ~s([{"id":"1"},{"id":"2"}]))
)
assert {:ok, [%Widget{id: 1}, %Widget{id: 2}]} = Endpoints.list_widgets(1)
end
test "an endpoint without returns: yields the raw decoded map" do
FakeRest.stub(:get, "/things/5", FakeRest.resp(200, body: ~s({"id":"5","k":"v"})))
assert {:ok, %{"id" => "5", "k" => "v"}} = Endpoints.raw_thing(5)
end
end
describe "__endpoints__/0 route table" do
test "lists every declaration in order with parsed segments" do
specs = Endpoints.__endpoints__()
assert Enum.map(specs, & &1.name) == [
:get_widget,
:create_widget,
:delete_widget,
:list_widgets,
:raw_thing
]
get_widget = Enum.find(specs, &(&1.name == :get_widget))
assert get_widget.method == :get
assert get_widget.segments == ["channels", {:param, :channel_id}, "widget"]
assert get_widget.query == [:limit, :around]
assert get_widget.returns == Dexcord.Test.Widget
list_widgets = Enum.find(specs, &(&1.name == :list_widgets))
assert list_widgets.returns == [Dexcord.Test.Widget]
raw_thing = Enum.find(specs, &(&1.name == :raw_thing))
assert raw_thing.returns == :raw
end
end
# --- helpers ------------------------------------------------------------
defp payload_json(body, boundary) do
body
|> String.split("--" <> boundary)
|> Enum.find_value(fn seg ->
if seg =~ ~s(name="payload_json") do
[_headers, rest] = String.split(seg, "\r\n\r\n", parts: 2)
rest |> String.replace_suffix("\r\n", "") |> JSON.decode!()
end
end)
end
end

View file

@ -0,0 +1,213 @@
defmodule Dexcord.Api.EndpointTest do
@moduledoc """
Pure unit tests for the endpoint runtime engine helpers path/query/body
building and response decoding. No HTTP: these exercise the functions in
isolation with hand-built spec maps.
"""
use ExUnit.Case, async: true
alias Dexcord.Api.Endpoint
alias Dexcord.Api.Error
# A spec fragment for /channels/:channel_id/messages.
defp messages_spec do
%{segments: ["channels", {:param, :channel_id}, "messages"]}
end
describe "build_path/2 (AC1.5)" do
test "accepts integer, decimal string, and struct-with-id, all identical" do
spec = messages_spec()
assert Endpoint.build_path(spec, [123]) == {:ok, "/channels/123/messages"}
assert Endpoint.build_path(spec, ["123"]) == {:ok, "/channels/123/messages"}
assert Endpoint.build_path(spec, [%Dexcord.Test.Widget{id: 123}]) ==
{:ok, "/channels/123/messages"}
end
test "a non-numeric snowflake returns an Error naming the param, no HTTP" do
assert {:error, %Error{status: nil, message: message}} =
Endpoint.build_path(messages_spec(), ["12x"])
assert message =~ ":channel_id"
end
test "non-id params are URL-encoded strings" do
spec = %{
segments: [
"channels",
{:param, :channel_id},
"reactions",
{:param, :emoji},
"@me"
]
}
assert {:ok, path} = Endpoint.build_path(spec, [1, "🔥"])
assert path == "/channels/1/reactions/%F0%9F%94%A5/@me"
assert {:ok, colon_path} = Endpoint.build_path(spec, [1, "name:123"])
assert colon_path == "/channels/1/reactions/name%3A123/@me"
end
end
describe "build_query/2 (AC1.2)" do
test "encodes only declared, present, non-nil keys" do
assert Endpoint.build_query([:limit, :around], limit: 50) == "?limit=50"
end
test "no matching opts yields an empty string" do
assert Endpoint.build_query([:limit, :around], []) == ""
assert Endpoint.build_query([:limit], limit: nil) == ""
end
test "struct-with-id values collapse to the id" do
assert Endpoint.build_query([:around], around: %Dexcord.Test.Widget{id: 99}) ==
"?around=99"
end
test "unknown opt keys are ignored" do
assert Endpoint.build_query([:limit], limit: 5, mystery: "x") == "?limit=5"
end
end
describe "encode_body/2 (AC2.9)" do
test "keyword: absent key omitted, explicit nil kept as JSON null" do
spec = %{binary_wrap: nil}
assert Endpoint.encode_body([content: "hi", nonce: nil], spec) ==
%{"content" => "hi", "nonce" => nil}
refute Map.has_key?(Endpoint.encode_body([content: "hi"], spec), "nonce")
end
test "nested structs in a keyword value encode via to_map" do
spec = %{binary_wrap: nil}
result = Endpoint.encode_body([embeds: [%Dexcord.Test.Gadget{id: 1, name: "t"}]], spec)
assert %{"embeds" => [embed]} = result
assert embed["name"] == "t"
end
test "binary body with binary_wrap wraps to the configured key" do
assert Endpoint.encode_body("hi", %{binary_wrap: :content}) == %{"content" => "hi"}
end
test "a top-level (non-keyword) list maps each struct element" do
spec = %{binary_wrap: nil}
result = Endpoint.encode_body([%Dexcord.Test.Gadget{id: 1, name: "a"}], spec)
assert [%{"name" => "a"}] = result
end
test "nil body encodes to nil" do
assert Endpoint.encode_body(nil, %{binary_wrap: nil}) == nil
end
end
describe "prepare_body/3 with files (AC1.4)" do
defp files_spec do
%{body: true, files: true, binary_wrap: nil}
end
test "returns a multipart tuple with attachments injected and description propagated" do
files = [%{filename: "a.png", data: <<1>>, description: "alt"}]
assert {:multipart, payload, [file]} =
Endpoint.prepare_body(files_spec(), [content: "hi"], files: files)
assert payload["content"] == "hi"
assert payload["attachments"] == [
%{"id" => 0, "filename" => "a.png", "description" => "alt"}
]
assert file.filename == "a.png"
assert file.content_type == "application/octet-stream"
refute Map.has_key?(file, :description)
end
test "without :files in opts returns a plain map" do
assert Endpoint.prepare_body(files_spec(), [content: "hi"], []) == %{"content" => "hi"}
end
test "a body:false spec always yields nil" do
assert Endpoint.prepare_body(%{body: false}, [content: "x"], []) == nil
end
end
describe "prepare_body/3 with form-field files (sticker create)" do
defp form_spec do
%{body: true, files: :form, binary_wrap: nil}
end
test "stringifies every payload value onto bare form parts" do
file = %{filename: "s.png", data: <<1>>}
assert {:form_multipart, fields, out_file} =
Endpoint.prepare_body(form_spec(), [name: "sticker", tags: "cat"], files: [file])
assert fields == %{"name" => "sticker", "tags" => "cat"}
assert out_file.filename == "s.png"
refute Map.has_key?(out_file, :description)
end
test "a nil body is legitimate and yields empty form fields" do
file = %{filename: "s.png", data: <<1>>}
assert {:form_multipart, %{}, out_file} =
Endpoint.prepare_body(form_spec(), nil, files: [file])
assert out_file.filename == "s.png"
end
test "a non-map, non-nil payload raises rather than silently vanishing" do
file = %{filename: "s.png", data: <<1>>}
# A top-level list body encodes to a list, which must not be coerced to %{}.
assert_raise FunctionClauseError, fn ->
Endpoint.prepare_body(form_spec(), [%Dexcord.Test.Gadget{id: 1, name: "a"}],
files: [file]
)
end
end
end
describe "decode_response/2 (AC1.6 + passthrough)" do
test "decodes a single map to the returns struct" do
assert {:ok, %Dexcord.User{id: 1}} =
Endpoint.decode_response({:ok, %{"id" => "1"}}, Dexcord.User)
end
test "decodes a list to a list of structs" do
assert {:ok, [%Dexcord.User{id: 1}, %Dexcord.User{id: 2}]} =
Endpoint.decode_response(
{:ok, [%{"id" => "1"}, %{"id" => "2"}]},
[Dexcord.User]
)
end
test "an error tuple passes through untouched" do
err = {:error, %Error{status: 403, message: "no"}}
assert Endpoint.decode_response(err, Dexcord.User) == err
end
test "a raw body passes through" do
assert Endpoint.decode_response({:ok, {:raw, "x"}}, Dexcord.User) == {:ok, {:raw, "x"}}
end
test "a nil body passes through" do
assert Endpoint.decode_response({:ok, nil}, Dexcord.User) == {:ok, nil}
end
test "returns: nil normalizes a non-204 JSON body to {:ok, nil}" do
# A `returns: nil` endpoint must not leak the raw decoded map when the
# server answers with a JSON body instead of an empty 204.
assert Endpoint.decode_response({:ok, %{"unexpected" => "body"}}, nil) == {:ok, nil}
assert Endpoint.decode_response({:ok, nil}, nil) == {:ok, nil}
end
test "returns: :raw leaves the decoded map alone" do
assert Endpoint.decode_response({:ok, %{"id" => "1"}}, :raw) == {:ok, %{"id" => "1"}}
end
end
end

View file

@ -0,0 +1,49 @@
defmodule Dexcord.EnumTest do
use ExUnit.Case, async: true
alias Dexcord.Test.Color
# api-surface.AC2.4: unknown enum int -> {:unknown, n}; encode(decode(n)) lossless.
test "decode/1 maps known integers to their named atoms" do
assert Color.decode(0) == :red
assert Color.decode(1) == :green
assert Color.decode(4) == :blue
end
test "decode/1 maps a gap value (unnamed but in range) to {:unknown, n}" do
assert Color.decode(2) == {:unknown, 2}
end
test "decode/1 maps out-of-range and negative integers to {:unknown, n}" do
assert Color.decode(999) == {:unknown, 999}
assert Color.decode(-5) == {:unknown, -5}
end
test "encode(decode(n)) is lossless for known and unknown values, including negative" do
for n <- [0, 1, 4, 2, 999, -5] do
assert Color.encode(Color.decode(n)) == n
end
end
test "encode/1 maps named atoms to their wire integer" do
assert Color.encode(:red) == 0
assert Color.encode(:green) == 1
assert Color.encode(:blue) == 4
end
test "encode/1 raises FunctionClauseError for an unnamed atom" do
# apply/3 keeps the invalid atom opaque to the compile-time type checker,
# so this deliberate misuse doesn't emit a typing-violation warning.
assert_raise FunctionClauseError, fn -> apply(Color, :encode, [:not_a_color]) end
end
test "values/0 returns the full name => integer table" do
assert Color.values() == %{red: 0, green: 1, blue: 4}
end
test "the module generates a :t type" do
{:ok, types} = Code.Typespec.fetch_types(Color)
assert Enum.any?(types, fn {_kind, {name, _def, _args}} -> name == :t end)
end
end

View file

@ -0,0 +1,131 @@
defmodule Dexcord.ErgonomicsHelpersTest do
@moduledoc """
Pure helper tests for the discord.py taste layer (api-surface.AC3.4): mentions,
`String.Chars` interpolation, `Dexcord.PartialEmoji.to_string/1`, `created_at/1`,
and `Dexcord.Util.format_dt/2`.
"""
use ExUnit.Case, async: true
alias Dexcord.Util
# Discord's documented example snowflake -> 2016-04-30T11:18:25.796Z.
@known_snowflake 175_928_847_299_117_063
@known_unix_ms 1_462_015_105_796
describe "mention/1" do
test "User renders <@id>" do
assert Dexcord.User.mention(%Dexcord.User{id: 1}) == "<@1>"
end
test "Member renders <@id> from user_id" do
assert Dexcord.Member.mention(%Dexcord.Member{user_id: 7}) == "<@7>"
end
test "Member renders <@id> from the nested user when user_id is nil" do
assert Dexcord.Member.mention(%Dexcord.Member{user: %Dexcord.User{id: 8}}) == "<@8>"
end
test "Role renders <@&id>" do
assert Dexcord.Role.mention(%Dexcord.Role{id: 2}) == "<@&2>"
end
test "Dexcord.Channel.mention/1 renders <#id> for any channel struct" do
assert Dexcord.Channel.mention(%Dexcord.TextChannel{id: 3}) == "<#3>"
assert Dexcord.Channel.mention(%Dexcord.VoiceChannel{id: 4}) == "<#4>"
assert Dexcord.Channel.mention(%Dexcord.Thread{id: 5}) == "<#5>"
assert Dexcord.Channel.mention(%Dexcord.DMChannel{id: 6}) == "<#6>"
end
end
describe "String.Chars interpolation" do
test "a user interpolates as a real ping" do
assert "hey #{%Dexcord.User{id: 1}}" == "hey <@1>"
end
test "a member interpolates as a real ping" do
assert "#{%Dexcord.Member{user_id: 9}}" == "<@9>"
end
test "a role interpolates as <@&id>" do
assert "#{%Dexcord.Role{id: 2}}" == "<@&2>"
end
test "a text channel interpolates as <#id>" do
assert "#{%Dexcord.TextChannel{id: 3}}" == "<#3>"
end
test "a thread interpolates as <#id>" do
assert "#{%Dexcord.Thread{id: 5}}" == "<#5>"
end
end
describe "PartialEmoji.to_string/1" do
test "a custom (static) emoji renders <:name:id>" do
emoji = %Dexcord.PartialEmoji{id: 100, name: "blob", animated: false}
assert Dexcord.PartialEmoji.to_string(emoji) == "<:blob:100>"
end
test "an animated emoji renders <a:name:id>" do
emoji = %Dexcord.PartialEmoji{id: 200, name: "party", animated: true}
assert Dexcord.PartialEmoji.to_string(emoji) == "<a:party:200>"
end
test "a unicode emoji (nil id) renders its raw name" do
emoji = %Dexcord.PartialEmoji{id: nil, name: "🔥"}
assert Dexcord.PartialEmoji.to_string(emoji) == "🔥"
end
test "interpolates via String.Chars" do
assert "#{%Dexcord.PartialEmoji{id: 100, name: "blob"}}" == "<:blob:100>"
assert "#{%Dexcord.PartialEmoji{id: nil, name: "🔥"}}" == "🔥"
end
end
describe "Dexcord.Util.format_dt/2" do
test "renders every documented style" do
for style <- ~w(t T d D f F s S R) do
assert Util.format_dt(1_700_000_000, style) == "<t:1700000000:#{style}>"
end
end
test "defaults to the f style" do
assert Util.format_dt(1_700_000_000) == "<t:1700000000:f>"
end
test "accepts a DateTime and converts to unix" do
dt = DateTime.from_unix!(1_700_000_000)
assert Util.format_dt(dt) == "<t:1700000000:f>"
assert Util.format_dt(dt, "R") == "<t:1700000000:R>"
end
end
describe "created_at/1" do
test "round-trips a known snowflake vector on a User" do
assert {:ok, dt} = Dexcord.User.created_at(%Dexcord.User{id: @known_snowflake})
assert DateTime.to_unix(dt, :millisecond) == @known_unix_ms
end
test "works on a Message via its id" do
assert {:ok, dt} = Dexcord.Message.created_at(%Dexcord.Message{id: @known_snowflake})
assert DateTime.to_unix(dt, :millisecond) == @known_unix_ms
end
test "works on a Member via user_id" do
assert {:ok, dt} =
Dexcord.Member.created_at(%Dexcord.Member{user_id: @known_snowflake})
assert DateTime.to_unix(dt, :millisecond) == @known_unix_ms
end
test "works on a guild channel struct" do
assert {:ok, dt} =
Dexcord.TextChannel.created_at(%Dexcord.TextChannel{id: @known_snowflake})
assert DateTime.to_unix(dt, :millisecond) == @known_unix_ms
end
test "an emoji with a nil id returns :error" do
assert Dexcord.Emoji.created_at(%Dexcord.Emoji{id: nil}) == :error
end
end
end

View file

@ -71,10 +71,10 @@ defmodule Dexcord.ErgonomicsIntegrationTest do
FakeRest.stub(
:get,
"/oauth2/applications/@me",
FakeRest.resp(200, body: ~s({"id":"app-erg"}))
FakeRest.resp(200, body: ~s({"id":"555"}))
)
FakeRest.stub(:put, "/applications/app-erg/commands", FakeRest.resp(200, body: "[]"))
FakeRest.stub(:put, "/applications/555/commands", FakeRest.resp(200, body: "[]"))
fake = start_supervised!({FakeGateway, test_pid: self(), hello_interval: 80})
:ok = start_bot(fake)
@ -102,7 +102,8 @@ defmodule Dexcord.ErgonomicsIntegrationTest 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
assert_receive {:command, "ping", ["a", "b"], %Dexcord.Message{content: "!ping a b"}},
@timeout
bot_msg = %{"content" => "!ping", "author" => %{"id" => "b1", "bot" => true}}
FakeGateway.push_dispatch(fake, "MESSAGE_CREATE", bot_msg, 11)
@ -115,8 +116,10 @@ defmodule Dexcord.ErgonomicsIntegrationTest 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
assert_receive {:slash, "ping", %Dexcord.Interaction{type: :application_command}}, @timeout
assert_receive {:raw, :INTERACTION_CREATE, %Dexcord.Interaction{type: :application_command}},
@timeout
end
test "an autocomplete interaction (type 4) reaches the raw handler but is not auto-routed",
@ -124,7 +127,10 @@ defmodule Dexcord.ErgonomicsIntegrationTest do
itx = %{"id" => "i4", "type" => 4, "data" => %{"name" => "ping"}}
FakeGateway.push_dispatch(fake, "INTERACTION_CREATE", itx, 21)
assert_receive {:raw, :INTERACTION_CREATE, ^itx}, @timeout
assert_receive {:raw, :INTERACTION_CREATE,
%Dexcord.Interaction{type: :application_command_autocomplete}},
@timeout
refute_receive {:slash, _, _}, 300
end

View file

@ -0,0 +1,252 @@
defmodule Dexcord.EventsEnvelopeTest do
use ExUnit.Case, async: true
alias Dexcord.Events
describe "message_events.ex" do
test "ReactionAdd decodes member, unicode emoji, burst; hydrate slots default nil" do
raw = %{
"user_id" => "111",
"channel_id" => "222",
"message_id" => "333",
"guild_id" => "444",
"member" => %{"nick" => "bee", "user" => %{"id" => "111", "username" => "bee"}},
"emoji" => %{"id" => nil, "name" => "🔥"},
"message_author_id" => "555",
"burst" => true,
"burst_colors" => ["#ffffff"],
"type" => 0
}
assert %Events.ReactionAdd{} = ev = Events.ReactionAdd.from_map(raw)
assert ev.user_id == 111
assert ev.channel_id == 222
assert ev.message_id == 333
assert ev.guild_id == 444
assert %Dexcord.Member{nick: "bee"} = ev.member
assert %Dexcord.User{username: "bee"} = ev.member.user
assert %Dexcord.PartialEmoji{name: "🔥", id: nil} = ev.emoji
assert ev.message_author_id == 555
assert ev.burst == true
assert ev.burst_colors == ["#ffffff"]
assert ev.type == :normal
# hydrate slots exist, default to nil, and are declared
assert ev.user == nil
assert ev.channel == nil
assert ev.guild == nil
slots = Enum.map(Events.ReactionAdd.__hydrations__(), & &1.name)
assert :user in slots
assert :channel in slots
assert :guild in slots
end
test "MessageDelete decodes minimal payload with hydrate slots" do
ev = Events.MessageDelete.from_map(%{"id" => "1", "channel_id" => "2", "guild_id" => "3"})
assert %Events.MessageDelete{id: 1, channel_id: 2, guild_id: 3} = ev
assert ev.channel == nil
assert ev.guild == nil
end
test "MessageDeleteBulk decodes ids list" do
ev = Events.MessageDeleteBulk.from_map(%{"ids" => ["1", "2"], "channel_id" => "9"})
assert ev.ids == [1, 2]
assert ev.channel_id == 9
end
test "PollVoteAdd decodes answer_id" do
ev =
Events.PollVoteAdd.from_map(%{
"user_id" => "1",
"channel_id" => "2",
"message_id" => "3",
"guild_id" => "4",
"answer_id" => 7
})
assert ev.answer_id == 7
assert ev.user == nil
end
end
describe "guild_events.ex" do
test "GuildMemberUpdate: nick present-null -> nil, absent premium_since -> :absent" do
raw = %{
"guild_id" => "1",
"roles" => ["10", "11"],
"user" => %{"id" => "2", "username" => "x"},
"nick" => nil,
"joined_at" => "2021-01-01T00:00:00.000000+00:00"
}
ev = Events.GuildMemberUpdate.from_map(raw)
assert ev.guild_id == 1
assert ev.roles == [10, 11]
assert %Dexcord.User{username: "x"} = ev.user
assert ev.nick == nil
assert ev.premium_since == :absent
assert ev.communication_disabled_until == :absent
end
test "GuildRoleCreate decodes nested role + guild hydrate" do
ev =
Events.GuildRoleCreate.from_map(%{
"guild_id" => "1",
"role" => %{"id" => "5", "name" => "admin"}
})
assert ev.guild_id == 1
assert %Dexcord.Role{name: "admin"} = ev.role
assert ev.guild == nil
end
test "GuildRoleDelete decodes role_id" do
ev = Events.GuildRoleDelete.from_map(%{"guild_id" => "1", "role_id" => "5"})
assert ev.role_id == 5
end
test "GuildMembersChunk decodes nested members" do
ev =
Events.GuildMembersChunk.from_map(%{
"guild_id" => "1",
"members" => [%{"user" => %{"id" => "2"}}],
"chunk_index" => 0,
"chunk_count" => 1
})
assert [%Dexcord.Member{}] = ev.members
assert ev.chunk_index == 0
end
end
describe "channel_events.ex" do
test "ThreadMembersUpdate: added_members with nested member" do
raw = %{
"id" => "1",
"guild_id" => "2",
"member_count" => 3,
"added_members" => [
%{"id" => "1", "user_id" => "9", "member" => %{"user" => %{"id" => "9"}}}
],
"removed_member_ids" => ["8"]
}
ev = Events.ThreadMembersUpdate.from_map(raw)
assert ev.member_count == 3
assert [%Dexcord.ThreadMember{} = tm] = ev.added_members
assert %Dexcord.Member{} = tm.member
assert ev.removed_member_ids == [8]
end
test "ChannelPinsUpdate tristate last_pin_timestamp absent -> :absent" do
ev = Events.ChannelPinsUpdate.from_map(%{"guild_id" => "1", "channel_id" => "2"})
assert ev.last_pin_timestamp == :absent
assert ev.channel == nil
end
test "ThreadDelete decodes type enum" do
ev =
Events.ThreadDelete.from_map(%{
"id" => "1",
"guild_id" => "2",
"parent_id" => "3",
"type" => 11
})
assert ev.id == 1
assert ev.type == :public_thread
end
end
describe "misc_events.ex" do
test "TypingStart: integer timestamp stays integer" do
raw = %{
"channel_id" => "1",
"guild_id" => "2",
"user_id" => "3",
"timestamp" => 1_700_000_000,
"member" => %{"user" => %{"id" => "3"}}
}
ev = Events.TypingStart.from_map(raw)
assert ev.timestamp == 1_700_000_000
assert is_integer(ev.timestamp)
assert %Dexcord.Member{} = ev.member
assert ev.user == nil
end
test "InviteCreate: expires_at null stays nil" do
raw = %{
"channel_id" => "1",
"code" => "abc",
"created_at" => "2021-01-01T00:00:00.000000+00:00",
"guild_id" => "2",
"max_age" => 3600,
"max_uses" => 0,
"temporary" => false,
"uses" => 0,
"expires_at" => nil
}
ev = Events.InviteCreate.from_map(raw)
assert ev.code == "abc"
assert ev.expires_at == nil
assert ev.channel == nil
end
test "VoiceServerUpdate decodes token/endpoint" do
ev =
Events.VoiceServerUpdate.from_map(%{
"token" => "t",
"guild_id" => "1",
"endpoint" => "smart.example"
})
assert ev.token == "t"
assert ev.endpoint == "smart.example"
end
test "AutoModerationActionExecution decodes nested action + enum" do
ev =
Events.AutoModerationActionExecution.from_map(%{
"guild_id" => "1",
"action" => %{"type" => 1, "metadata" => %{"channel_id" => "9"}},
"rule_id" => "2",
"rule_trigger_type" => 1,
"user_id" => "3",
"content" => "bad word"
})
assert %Dexcord.AutoModerationAction{type: :block_message} = ev.action
assert ev.rule_trigger_type == :keyword
assert ev.content == "bad word"
end
test "WebhooksUpdate decodes with channel/guild hydrates" do
ev = Events.WebhooksUpdate.from_map(%{"guild_id" => "1", "channel_id" => "2"})
assert ev.guild_id == 1
assert ev.channel_id == 2
assert ev.channel == nil
assert ev.guild == nil
end
end
describe "ready.ex" do
test "Ready decodes user + unavailable guilds" do
ev =
Events.Ready.from_map(%{
"v" => 10,
"user" => %{"id" => "1", "username" => "bot"},
"guilds" => [%{"id" => "9", "unavailable" => true}],
"session_id" => "sess",
"resume_gateway_url" => "wss://example"
})
assert ev.v == 10
assert %Dexcord.User{username: "bot"} = ev.user
assert [%Dexcord.UnavailableGuild{id: 9, unavailable: true}] = ev.guilds
assert ev.session_id == "sess"
end
end
end

Some files were not shown because too many files have changed in this diff Show more