api-surface #1
141 changed files with 58439 additions and 795 deletions
|
|
@ -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
14
.gitea/workflows/ci.yml
Normal 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
|
||||
74
README.md
74
README.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
482
docs/alamedya-migration-v2.md
Normal file
482
docs/alamedya-migration-v2.md
Normal 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>
|
||||
388
docs/design-plans/2026-07-04-api-surface.md
Normal file
388
docs/design-plans/2026-07-04-api-surface.md
Normal 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.1–AC2.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.10–AC2.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.15–AC2.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 1–2 (Phase 3 models only needed for later groups).
|
||||
|
||||
**Done when:** All 16 existing endpoints pass FakeRest round-trips returning structs (`api-surface.AC1.1–AC1.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 3–4.
|
||||
|
||||
**Done when:** `mix dexcord.coverage` reports zero missing in-scope endpoints and runs in CI (`api-surface.AC1.8–AC1.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 4–5.
|
||||
|
||||
**Done when:** FakeGateway end-to-end flows deliver typed events, cache returns structs surviving partial updates, hydration fills from cache (`api-surface.AC2.18–AC2.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 4–6.
|
||||
|
||||
**Done when:** Ergonomics ACs pass (`api-surface.AC3.1–AC3.7`); guide exists and compiles against the final API (`api-surface.AC5.1`); full suite green, `mix dexcord.coverage` green (`api-surface.AC4.1–AC4.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.
|
||||
84
docs/test-plans/2026-07-05-api-surface.md
Normal file
84
docs/test-plans/2026-07-05-api-surface.md
Normal 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.1–1.7 | endpoint_macro_test, endpoint_test, api_facade_test | E2E steps 2, 6 |
|
||||
| AC1.8–1.10 | coverage_test, ratelimit_test | Phase B (B4 exercises the gate) |
|
||||
| AC2.1–2.9 | struct_test, enum_test, flags_test, endpoint_test | — (pure) |
|
||||
| AC2.10–2.17 | model_guild/message/channel/interaction/role_test, events_test | E2E step 2 |
|
||||
| AC2.18–2.19 | gateway_integration_test | E2E steps 3–5 |
|
||||
| AC2.20–2.22, AC3.8 | cache_test, cache_fill_test, cache_cascade_test | E2E step 2 |
|
||||
| AC3.1–3.6 | send_test, messageable_test, ergonomics_helpers_test, model_message_parts_test | E2E steps 3–4 |
|
||||
| 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 |
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 newest→oldest 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
104
lib/dexcord/api/channels.ex
Normal 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
|
||||
75
lib/dexcord/api/commands.ex
Normal file
75
lib/dexcord/api/commands.ex
Normal 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
|
||||
86
lib/dexcord/api/coverage.ex
Normal file
86
lib/dexcord/api/coverage.ex
Normal 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
|
||||
69
lib/dexcord/api/emojis_stickers.ex
Normal file
69
lib/dexcord/api/emojis_stickers.ex
Normal 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
336
lib/dexcord/api/endpoint.ex
Normal 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
96
lib/dexcord/api/guilds.ex
Normal 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
|
||||
56
lib/dexcord/api/interactions.ex
Normal file
56
lib/dexcord/api/interactions.ex
Normal 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
|
||||
17
lib/dexcord/api/invites.ex
Normal file
17
lib/dexcord/api/invites.ex
Normal 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
|
||||
81
lib/dexcord/api/members.ex
Normal file
81
lib/dexcord/api/members.ex
Normal 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
142
lib/dexcord/api/messages.ex
Normal 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
31
lib/dexcord/api/misc.ex
Normal 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
|
||||
69
lib/dexcord/api/moderation.ex
Normal file
69
lib/dexcord/api/moderation.ex
Normal 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
|
||||
54
lib/dexcord/api/paginate.ex
Normal file
54
lib/dexcord/api/paginate.ex
Normal 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
|
||||
|
|
@ -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
37
lib/dexcord/api/roles.ex
Normal 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
76
lib/dexcord/api/users.ex
Normal 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
|
||||
79
lib/dexcord/api/webhooks.ex
Normal file
79
lib/dexcord/api/webhooks.ex
Normal 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
|
||||
|
|
@ -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)}")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
63
lib/dexcord/enum.ex
Normal 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
139
lib/dexcord/events.ex
Normal 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
|
||||
50
lib/dexcord/events/channel_events.ex
Normal file
50
lib/dexcord/events/channel_events.ex
Normal 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
|
||||
166
lib/dexcord/events/guild_events.ex
Normal file
166
lib/dexcord/events/guild_events.ex
Normal 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
|
||||
123
lib/dexcord/events/message_events.ex
Normal file
123
lib/dexcord/events/message_events.ex
Normal 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
|
||||
124
lib/dexcord/events/misc_events.ex
Normal file
124
lib/dexcord/events/misc_events.ex
Normal 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
|
||||
14
lib/dexcord/events/ready.ex
Normal file
14
lib/dexcord/events/ready.ex
Normal 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
60
lib/dexcord/flags.ex
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
45
lib/dexcord/messageable.ex
Normal file
45
lib/dexcord/messageable.ex
Normal 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
|
||||
34
lib/dexcord/model/allowed_mentions.ex
Normal file
34
lib/dexcord/model/allowed_mentions.ex
Normal 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
|
||||
80
lib/dexcord/model/application_command.ex
Normal file
80
lib/dexcord/model/application_command.ex
Normal 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
|
||||
56
lib/dexcord/model/application_info.ex
Normal file
56
lib/dexcord/model/application_info.ex
Normal 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
|
||||
26
lib/dexcord/model/attachment.ex
Normal file
26
lib/dexcord/model/attachment.ex
Normal 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
|
||||
71
lib/dexcord/model/audit_log.ex
Normal file
71
lib/dexcord/model/audit_log.ex
Normal 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
|
||||
53
lib/dexcord/model/auto_moderation.ex
Normal file
53
lib/dexcord/model/auto_moderation.ex
Normal 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
|
||||
329
lib/dexcord/model/channel.ex
Normal file
329
lib/dexcord/model/channel.ex
Normal 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
|
||||
50
lib/dexcord/model/channel_parts.ex
Normal file
50
lib/dexcord/model/channel_parts.ex
Normal 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
|
||||
383
lib/dexcord/model/component.ex
Normal file
383
lib/dexcord/model/component.ex
Normal 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 5–8)."
|
||||
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
158
lib/dexcord/model/embed.ex
Normal 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
|
||||
52
lib/dexcord/model/emoji.ex
Normal file
52
lib/dexcord/model/emoji.ex
Normal 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
398
lib/dexcord/model/enums.ex
Normal 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, 33–35, 40–43, 45;
|
||||
# ChannelType 6–9.
|
||||
|
||||
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
164
lib/dexcord/model/flags.ex
Normal 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 4–5, 11–13, 15, 20+; Permissions bit 47; GuildMemberFlags
|
||||
# bit 8; AttachmentFlags bit 4; ChannelFlags bits 0, 2–3, 5–14.
|
||||
|
||||
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
217
lib/dexcord/model/guild.ex
Normal 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
|
||||
149
lib/dexcord/model/guild_extras.ex
Normal file
149
lib/dexcord/model/guild_extras.ex
Normal 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
|
||||
158
lib/dexcord/model/interaction.ex
Normal file
158
lib/dexcord/model/interaction.ex
Normal 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
|
||||
48
lib/dexcord/model/invite.ex
Normal file
48
lib/dexcord/model/invite.ex
Normal 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
|
||||
66
lib/dexcord/model/member.ex
Normal file
66
lib/dexcord/model/member.ex
Normal 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
|
||||
132
lib/dexcord/model/message.ex
Normal file
132
lib/dexcord/model/message.ex
Normal 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
67
lib/dexcord/model/misc.ex
Normal 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
|
||||
11
lib/dexcord/model/overwrite.ex
Normal file
11
lib/dexcord/model/overwrite.ex
Normal 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
55
lib/dexcord/model/poll.ex
Normal 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
|
||||
23
lib/dexcord/model/presence.ex
Normal file
23
lib/dexcord/model/presence.ex
Normal 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
|
||||
23
lib/dexcord/model/reaction.ex
Normal file
23
lib/dexcord/model/reaction.ex
Normal 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
61
lib/dexcord/model/role.ex
Normal 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
|
||||
73
lib/dexcord/model/scheduled_event.ex
Normal file
73
lib/dexcord/model/scheduled_event.ex
Normal 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
|
||||
29
lib/dexcord/model/sticker.ex
Normal file
29
lib/dexcord/model/sticker.ex
Normal 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
24
lib/dexcord/model/team.ex
Normal 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
60
lib/dexcord/model/user.ex
Normal 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
|
||||
20
lib/dexcord/model/voice_state.ex
Normal file
20
lib/dexcord/model/voice_state.ex
Normal 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
|
||||
23
lib/dexcord/model/webhook.ex
Normal file
23
lib/dexcord/model/webhook.ex
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
247
lib/dexcord/struct.ex
Normal 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
159
lib/dexcord/struct/codec.ex
Normal 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
22
lib/dexcord/util.ex
Normal 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
|
||||
22
lib/mix/tasks/dexcord.coverage.ex
Normal file
22
lib/mix/tasks/dexcord.coverage.ex
Normal 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
|
||||
81
lib/mix/tasks/dexcord.coverage.refresh.ex
Normal file
81
lib/mix/tasks/dexcord.coverage.refresh.ex
Normal 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
|
||||
95
priv/coverage_allowlist.exs
Normal file
95
priv/coverage_allowlist.exs
Normal 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
43111
priv/discord_openapi.json
Normal file
File diff suppressed because it is too large
Load diff
178
test/dexcord/api_channels_test.exs
Normal file
178
test/dexcord/api_channels_test.exs
Normal 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
|
||||
140
test/dexcord/api_commands_test.exs
Normal file
140
test/dexcord/api_commands_test.exs
Normal 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
|
||||
114
test/dexcord/api_facade_test.exs
Normal file
114
test/dexcord/api_facade_test.exs
Normal 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
|
||||
233
test/dexcord/api_guilds_test.exs
Normal file
233
test/dexcord/api_guilds_test.exs
Normal 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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
180
test/dexcord/api_messages_test.exs
Normal file
180
test/dexcord/api_messages_test.exs
Normal 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
|
||||
248
test/dexcord/api_moderation_test.exs
Normal file
248
test/dexcord/api_moderation_test.exs
Normal 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
|
||||
169
test/dexcord/api_webhooks_test.exs
Normal file
169
test/dexcord/api_webhooks_test.exs
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
103
test/dexcord/cache_fill_test.exs
Normal file
103
test/dexcord/cache_fill_test.exs
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
73
test/dexcord/coverage_test.exs
Normal file
73
test/dexcord/coverage_test.exs
Normal 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
|
||||
175
test/dexcord/endpoint_macro_test.exs
Normal file
175
test/dexcord/endpoint_macro_test.exs
Normal 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
|
||||
213
test/dexcord/endpoint_test.exs
Normal file
213
test/dexcord/endpoint_test.exs
Normal 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
|
||||
49
test/dexcord/enum_test.exs
Normal file
49
test/dexcord/enum_test.exs
Normal 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
|
||||
131
test/dexcord/ergonomics_helpers_test.exs
Normal file
131
test/dexcord/ergonomics_helpers_test.exs
Normal 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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
252
test/dexcord/events_envelope_test.exs
Normal file
252
test/dexcord/events_envelope_test.exs
Normal 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
Loading…
Add table
Add a link
Reference in a new issue