Compare commits

..

No commits in common. "ca5b3cf599acfab7ea034a53eb582dd3d6f48a7a" and "cdca1f13ceb9af97f7d6ca2662cbdaf8cbe6f6e2" have entirely different histories.

34 changed files with 114 additions and 2785 deletions

View file

@ -112,21 +112,18 @@ 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. `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):
`{event_name, data}` tuple, where `data` is the raw string-keyed payload map
Discord sent (no atom keys, no structs):
```elixir
defmodule MyBot.Handler do
use Dexcord.Handler
def handle_event({:MESSAGE_CREATE, %Dexcord.Message{} = msg}) do
IO.puts("#{msg.author.username}: #{msg.content}")
def handle_event({:MESSAGE_CREATE, msg}) do
IO.puts("#{msg["author"]["username"]}: #{msg["content"]}")
end
def handle_event({:PRESENCE_UPDATE, %Dexcord.Presence{} = presence}) do
def handle_event({:PRESENCE_UPDATE, presence}) do
# only fires if cache_presences: true and presences are being cached
end
end
@ -147,30 +144,28 @@ handler's `MESSAGE_CREATE` clause:
defmodule MyBot.Commands do
use Dexcord.Prefix.Router
def handle_command("ping", _args, %Dexcord.Message{} = msg) do
Dexcord.Message.reply(msg, "pong")
def handle_command("ping", _args, msg) do
Dexcord.Api.create_message(msg["channel_id"], "pong")
end
def handle_command("echo", args, %Dexcord.Message{} = msg) do
Dexcord.Api.send(msg.channel_id, Enum.join(args, " "))
def handle_command("echo", args, msg) do
Dexcord.Api.create_message(msg["channel_id"], Enum.join(args, " "))
end
end
defmodule MyBot.Handler do
use Dexcord.Handler
def handle_event({:MESSAGE_CREATE, %Dexcord.Message{} = msg}) do
def handle_event({:MESSAGE_CREATE, msg}) do
Dexcord.Prefix.dispatch(msg, prefix: "!", to: MyBot.Commands)
end
end
```
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
`dispatch/2` skips messages authored by a bot (including the bot's own
messages, checked both via the payload's `author.bot` flag and against the
cached bot user id) so a command that replies in-channel can't recursively
trigger itself. `Dexcord.Prefix.parse/2` is exposed separately as a pure
function if you want the prefix/command/args split without the bot-author
check or the router dispatch.
@ -332,20 +327,13 @@ 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 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()
Typed endpoints (thin wrappers, string-keyed request/response maps):
```
get_gateway_bot/0 get_current_user/0 get_current_application/0
get_user/1 get_channel/1 get_guild/1
create_dm/1 create_message/2 edit_message/3
delete_message/2 create_reaction/3 get_channel_messages/2
delete_message/2 create_reaction/3
create_interaction_response/3
edit_original_interaction_response/3
create_followup_message/3
@ -353,15 +341,8 @@ bulk_overwrite_global_commands/2
bulk_overwrite_guild_commands/3
```
`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:
For anything not covered, `Dexcord.Api.request/4` is the escape hatch every
typed endpoint is built on:
```elixir
Dexcord.Api.request(:patch, "/guilds/#{guild_id}", %{"name" => "New Name"})
@ -441,19 +422,16 @@ running untouched.
| Nostrum | Dexcord |
|---|---|
| `Nostrum.Consumer` `handle_event/1` callback | `Dexcord.Handler` `handle_event/1` callback (`use Dexcord.Handler`) |
| `Nostrum.Api.*` | `Dexcord.Api.*` (typed struct returns + `request/4` escape hatch) |
| `Nostrum.Api.*` | `Dexcord.Api.*` (typed endpoints + `request/4` escape hatch) |
| `Nostrum.Cache.*` (several cache modules) | `Dexcord.Cache` (one module, one ETS table per entity type) |
| `%Nostrum.Struct.Message{}` etc. (atom-keyed structs) | `%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 |
| `%Nostrum.Struct.Message{}` etc. (atom-keyed structs) | string-keyed maps everywhere - `msg["content"]`, not `msg.content` |
| Auto-starting `:nostrum` application | you add `{Dexcord, opts}` to your own supervision tree |
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).
The biggest day-to-day adjustment is the lack of structs: every payload -
messages, guilds, members, interactions - is the raw string-keyed map
Discord sent over the wire. This costs `msg["content"]` instead of
`msg.content`, but means Dexcord never has to guess a struct shape ahead of a
Discord API change, and never mints atoms from wire data.
## Reliability design

View file

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

View file

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

View file

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

View file

@ -375,238 +375,6 @@ defmodule Dexcord.Api do
end
end
# --- 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
end
# 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})
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
other ->
other
end
end
# 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}
: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
# --- 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`.
@history_page_size 100
@members_page_size 1000
@bans_page_size 1000
@audit_log_page_size 100
@doc """
Streams a channel's message history, resolving `messageable` through
`Dexcord.Messageable`.
By default it pages newestoldest using the `before:` anchor (cursor = the
last message's id). Passing `after: id` flips to oldest→newest paging with the
`after:` anchor (mirroring discord.py's `oldest_first`, which defaults true iff
`after` is given). `limit: n` caps the total number of messages yielded.
The stream is lazy pages are fetched on demand and raises
`Dexcord.Api.Paginate.PageError` if a page request fails.
"""
@spec 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
maybe_take(stream, Keyword.get(opts, :limit))
end
@doc """
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 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 """
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 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)
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 """
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
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

View file

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

View file

@ -21,17 +21,6 @@ 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 |
@ -70,10 +59,6 @@ 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.
@ -100,7 +85,6 @@ defmodule Dexcord.Cache do
:ets.new(@roles, oset)
:ets.new(@presences, oset)
:ets.new(@voice_states, oset)
:ets.new(@dm_channels, set)
{:ok, %{}}
end
@ -582,70 +566,6 @@ defmodule Dexcord.Cache do
@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)

View file

@ -84,10 +84,10 @@ defmodule Dexcord.Dispatcher do
# 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.
# can't fire; we degrade to the old integer `"type"` gate so a well-typed-enough
# raw interaction still reaches the slash layer. The RAW map is passed to
# `Slash.dispatch/2` in this task (Task 4 flips the typed branch to pass the
# decoded struct).
defp maybe_route_slash(:INTERACTION_CREATE, decoded, raw, config) do
slash_mod = Map.get(config, :slash)
@ -97,7 +97,7 @@ defmodule Dexcord.Dispatcher do
match?(%Dexcord.Interaction{}, decoded) and
decoded.type in [:application_command, :message_component, :modal_submit] ->
route_slash(decoded, slash_mod)
route_slash(raw, slash_mod)
is_map(raw) and raw["type"] in [2, 3, 5] ->
route_slash(raw, slash_mod)

View file

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

View file

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

View file

@ -30,10 +30,6 @@ defmodule Dexcord.TextChannel do
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
@ -49,10 +45,6 @@ defmodule Dexcord.AnnouncementChannel do
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
@ -68,10 +60,6 @@ defmodule Dexcord.VoiceChannel do
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
@ -87,10 +75,6 @@ defmodule Dexcord.StageChannel do
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
@ -100,10 +84,6 @@ defmodule Dexcord.CategoryChannel do
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
@ -113,10 +93,6 @@ defmodule Dexcord.DirectoryChannel do
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
@ -135,10 +111,6 @@ defmodule Dexcord.ForumChannel do
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
@ -156,10 +128,6 @@ defmodule Dexcord.MediaChannel do
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
@ -174,10 +142,6 @@ defmodule Dexcord.DMChannel do
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
@ -197,10 +161,6 @@ defmodule Dexcord.GroupDMChannel do
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
@ -226,10 +186,6 @@ defmodule Dexcord.Thread do
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
@ -298,32 +254,6 @@ defmodule Dexcord.Channel do
def from_map(map) when is_map(map), do: Dexcord.UnknownChannel.from_map(map)
def from_map(_), do: nil
@doc """
The mention string (`<#id>`) for any channel struct with an integer `id` —
guild channels, threads, and DM/group-DM channels alike.
"""
@spec mention(%{id: Dexcord.Snowflake.t()}) :: String.t()
def mention(%{id: id}) when is_integer(id), do: "<##{id}>"
@doc false
def __type_map__, do: @type_map
end
# `String.Chars` for every channel struct: interpolating a channel produces a
# real `<#id>` mention (delegating to `Dexcord.Channel.mention/1`).
defimpl String.Chars,
for: [
Dexcord.TextChannel,
Dexcord.AnnouncementChannel,
Dexcord.VoiceChannel,
Dexcord.StageChannel,
Dexcord.CategoryChannel,
Dexcord.DirectoryChannel,
Dexcord.ForumChannel,
Dexcord.MediaChannel,
Dexcord.DMChannel,
Dexcord.GroupDMChannel,
Dexcord.Thread
] do
def to_string(channel), do: Dexcord.Channel.mention(channel)
end

View file

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

View file

@ -12,10 +12,6 @@ defmodule Dexcord.Emoji do
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
@ -26,27 +22,9 @@ defmodule Dexcord.PartialEmoji do
"""
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

View file

@ -57,136 +57,6 @@ defmodule Dexcord.Guild do
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

View file

@ -34,23 +34,6 @@ defmodule Dexcord.Member 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

View file

@ -44,47 +44,6 @@ defmodule Dexcord.Message do
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

View file

@ -17,18 +17,6 @@ defmodule Dexcord.Role do
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

View file

@ -23,18 +23,6 @@ defmodule Dexcord.User do
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

View file

@ -16,8 +16,4 @@ defmodule Dexcord.Webhook do
field :source_channel, :raw
field :url, :string
end
@doc "The creation `DateTime` encoded in this webhook's id, or `:error`."
@spec created_at(t()) :: {:ok, DateTime.t()} | :error
def created_at(%{id: id}), do: Dexcord.Snowflake.to_datetime(id)
end

View file

@ -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 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
`dispatch/2` routes a raw `INTERACTION_CREATE` payload on its **top-level**
`interaction["type"]`: type 2 (application command) `handle_interaction/2`
keyed on `interaction["data"]["name"]`; type 3 (message component)
`handle_component/2` keyed on `interaction["data"]["custom_id"]`; type 5 (modal
submit) `handle_modal/2` keyed on `interaction["data"]["custom_id"]`. The
`Dexcord.Dispatcher` calls it automatically for those types when a `slash:`
module is configured (the event still reaches the handler too). Every callback
receives the full `%Dexcord.Interaction{}` as its second argument.
module is configured (the raw event still reaches the handler).
## Response helpers
@ -56,22 +56,13 @@ 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() | nil,
interaction :: Dexcord.Interaction.t()
) :: any()
@callback handle_interaction(name :: String.t(), interaction :: map()) :: any()
@doc "Handles a routed message-component interaction (type 3) for `custom_id`."
@callback handle_component(
custom_id :: String.t() | nil,
interaction :: Dexcord.Interaction.t()
) :: any()
@callback handle_component(custom_id :: String.t() | nil, interaction :: map()) :: any()
@doc "Handles a routed modal-submit interaction (type 5) for `custom_id`."
@callback handle_modal(
custom_id :: String.t() | nil,
interaction :: Dexcord.Interaction.t()
) :: any()
@callback handle_modal(custom_id :: String.t() | nil, interaction :: map()) :: any()
# Only `commands/0` and `handle_interaction/2` are required; a module that never
# uses components or modals need not define those callbacks (the injected
@ -121,42 +112,21 @@ defmodule Dexcord.Slash do
# --- routing ------------------------------------------------------------
@doc """
Routes a decoded `%Dexcord.Interaction{}` to `mod` on its `type` atom.
Routes an `INTERACTION_CREATE` payload to `mod` on its top-level `"type"`.
* `: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`
* type 2 (application command) `mod.handle_interaction/2`, keyed on
`interaction["data"]["name"]`
* type 3 (message component) `mod.handle_component/2`, keyed on
`interaction["data"]["custom_id"]`
* type 5 (modal submit) `mod.handle_modal/2`, keyed on
`interaction["data"]["custom_id"]`
Any other (or 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.
Any other (or missing) type is ignored - the `Dexcord.Dispatcher` only routes
types 2/3/5 here, so this is defensive.
"""
@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
@spec dispatch(map(), module()) :: any()
def dispatch(interaction, mod) when is_map(interaction) and is_atom(mod) do
case interaction["type"] do
2 -> mod.handle_interaction(get_in(interaction, ["data", "name"]), interaction)
3 -> mod.handle_component(get_in(interaction, ["data", "custom_id"]), interaction)
5 -> mod.handle_modal(get_in(interaction, ["data", "custom_id"]), interaction)
@ -164,12 +134,6 @@ 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 """
@ -178,16 +142,15 @@ 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(Dexcord.Interaction.t(), String.t() | map()) ::
{:ok, map()} | {:ok, nil} | {:error, term()}
@spec respond(map(), String.t() | map()) :: {:ok, map()} | {:ok, nil} | {:error, term()}
def respond(interaction, content) when is_binary(content) do
respond(interaction, %{content: content})
end
def respond(%Dexcord.Interaction{} = interaction, %{} = data) do
def respond(interaction, %{} = data) do
Dexcord.Api.create_interaction_response(
interaction.id,
interaction.token,
interaction["id"],
interaction["token"],
%{"type" => 4, "data" => message_data(data)}
)
end
@ -196,11 +159,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(Dexcord.Interaction.t()) :: {:ok, map()} | {:ok, nil} | {:error, term()}
def respond_later(%Dexcord.Interaction{} = interaction) do
@spec respond_later(map()) :: {:ok, map()} | {:ok, nil} | {:error, term()}
def respond_later(interaction) do
Dexcord.Api.create_interaction_response(
interaction.id,
interaction.token,
interaction["id"],
interaction["token"],
%{"type" => 5}
)
end
@ -210,15 +173,15 @@ defmodule Dexcord.Slash do
`text_or_map` is a binary (used as `content`) or a map as in `respond/2`.
"""
@spec followup(Dexcord.Interaction.t(), String.t() | map()) :: {:ok, map()} | {:error, term()}
@spec followup(map(), String.t() | map()) :: {:ok, map()} | {:error, term()}
def followup(interaction, content) when is_binary(content) do
followup(interaction, %{content: content})
end
def followup(%Dexcord.Interaction{} = interaction, %{} = data) do
def followup(interaction, %{} = data) do
Dexcord.Api.create_followup_message(
interaction.application_id,
interaction.token,
interaction["application_id"],
interaction["token"],
message_data(data)
)
end
@ -228,16 +191,15 @@ defmodule Dexcord.Slash do
`text_or_map` is a binary (used as `content`) or a map as in `respond/2`.
"""
@spec edit_response(Dexcord.Interaction.t(), String.t() | map()) ::
{:ok, map()} | {:error, term()}
@spec edit_response(map(), String.t() | map()) :: {:ok, map()} | {:error, term()}
def edit_response(interaction, content) when is_binary(content) do
edit_response(interaction, %{content: content})
end
def edit_response(%Dexcord.Interaction{} = interaction, %{} = data) do
def edit_response(interaction, %{} = data) do
Dexcord.Api.edit_original_interaction_response(
interaction.application_id,
interaction.token,
interaction["application_id"],
interaction["token"],
message_data(data)
)
end
@ -251,7 +213,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), encode_values(value))
{:ok, value} -> Map.put(acc, Atom.to_string(field), value)
:error -> acc
end
end)
@ -262,14 +224,6 @@ defmodule Dexcord.Slash do
end
end
# Normalizes recognised field values for the wire: Dexcord structs (e.g.
# `%Dexcord.Embed{}`) go through their own `to_map/1`; lists recurse; anything
# else (a binary, a plain atom/string-keyed map) passes through unchanged and is
# serialized directly by the JSON encoder downstream.
defp encode_values(%_{} = struct), do: struct.__struct__.to_map(struct)
defp encode_values(list) when is_list(list), do: Enum.map(list, &encode_values/1)
defp encode_values(value), do: value
# Combines a caller-supplied integer `flags` with the ephemeral bit (64).
# Returns nil when neither is present so the key is omitted entirely.
defp flags(data) do

View file

@ -99,15 +99,7 @@ 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
# 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)
commands = config.slash.commands()
with {:ok, app_id} <- resolve_app_id() do
Dexcord.Config.put_application_id(app_id)

View file

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

View file

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

View file

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

View file

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

View file

@ -116,7 +116,7 @@ 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", %Dexcord.Interaction{type: :application_command}}, @timeout
assert_receive {:slash, "ping", ^itx}, @timeout
assert_receive {:raw, :INTERACTION_CREATE, %Dexcord.Interaction{type: :application_command}},
@timeout

View file

@ -98,7 +98,6 @@ defmodule Dexcord.GatewayIntegrationTest do
# A dispatch reaches the user handler...
FakeGateway.push_dispatch(fake, "MESSAGE_CREATE", %{"content" => "hi"}, 7)
assert_receive {:handler_event, {:MESSAGE_CREATE, %Dexcord.Message{content: "hi"}}},
@event_timeout
@ -259,7 +258,6 @@ defmodule Dexcord.GatewayIntegrationTest do
# The statem survived: a following real dispatch still reaches the user handler.
FakeGateway.push_dispatch(fake, "MESSAGE_CREATE", %{"content" => "alive"}, 6)
assert_receive {:handler_event, {:MESSAGE_CREATE, %Dexcord.Message{content: "alive"}}},
@event_timeout
@ -292,7 +290,6 @@ defmodule Dexcord.GatewayIntegrationTest do
# Advance the live session's seq.
FakeGateway.push_dispatch(fake, "MESSAGE_CREATE", %{"content" => "x"}, 42)
assert_receive {:handler_event, {:MESSAGE_CREATE, %Dexcord.Message{content: "x"}}},
@event_timeout
@ -305,7 +302,6 @@ defmodule Dexcord.GatewayIntegrationTest do
# Trickle a dispatch from the abandoned session during the reidentify wait.
FakeGateway.push_dispatch(fake, "MESSAGE_CREATE", %{"content" => "noise"}, 9_999)
assert_receive {:handler_event, {:MESSAGE_CREATE, %Dexcord.Message{content: "noise"}}},
@event_timeout
@ -551,7 +547,6 @@ defmodule Dexcord.GatewayIntegrationTest do
assert_frame(2)
assert_receive {:handler_event, {:READY, _}}, @event_timeout
FakeGateway.push_dispatch(fake, "MESSAGE_CREATE", %{"content" => "seed"}, 5)
assert_receive {:handler_event, {:MESSAGE_CREATE, %Dexcord.Message{content: "seed"}}},
@event_timeout
@ -567,7 +562,6 @@ defmodule Dexcord.GatewayIntegrationTest do
assert_receive {:handler_event, {:MESSAGE_CREATE, %Dexcord.Message{content: "c"}}},
@event_timeout
assert_receive {:handler_event, {:RESUMED, _}}, @event_timeout
# Seq advanced to the last replayed dispatch.

View file

@ -1,79 +0,0 @@
defmodule Dexcord.MessageableTest do
@moduledoc """
Pure resolve tests for the `Dexcord.Messageable` protocol (api-surface.AC3.1's
match-time layer). No HTTP: every case exercises `Dexcord.Messageable.resolve/1`
directly, including the deliberate non-implementations that must fail BEFORE any
network call.
"""
use ExUnit.Case, async: true
alias Dexcord.Messageable
describe "channel-like targets resolve to {:channel, id}" do
test "guild text/announcement/voice/stage channels" do
assert Messageable.resolve(%Dexcord.TextChannel{id: 1}) == {:channel, 1}
assert Messageable.resolve(%Dexcord.AnnouncementChannel{id: 2}) == {:channel, 2}
assert Messageable.resolve(%Dexcord.VoiceChannel{id: 3}) == {:channel, 3}
assert Messageable.resolve(%Dexcord.StageChannel{id: 4}) == {:channel, 4}
end
test "threads and DM/group-DM channels" do
assert Messageable.resolve(%Dexcord.Thread{id: 9}) == {:channel, 9}
assert Messageable.resolve(%Dexcord.DMChannel{id: 10}) == {:channel, 10}
assert Messageable.resolve(%Dexcord.GroupDMChannel{id: 11}) == {:channel, 11}
end
test "a message resolves to its channel" do
assert Messageable.resolve(%Dexcord.Message{id: 100, channel_id: 5}) == {:channel, 5}
end
test "an interaction resolves to its channel" do
assert Messageable.resolve(%Dexcord.Interaction{id: 1, channel_id: 7}) == {:channel, 7}
end
test "a bare non-negative integer passes through" do
assert Messageable.resolve(123) == {:channel, 123}
assert Messageable.resolve(0) == {:channel, 0}
end
end
describe "user-like targets resolve to {:dm_user, id}" do
test "a user" do
assert Messageable.resolve(%Dexcord.User{id: 42}) == {:dm_user, 42}
end
test "a member via its nested user" do
assert Messageable.resolve(%Dexcord.Member{user: %Dexcord.User{id: 43}}) ==
{:dm_user, 43}
end
test "a member via its user_id back-reference (cache shape, no nested user)" do
assert Messageable.resolve(%Dexcord.Member{user: nil, user_id: 44}) == {:dm_user, 44}
end
end
describe "deliberate non-implementations fail at resolve time" do
# These structs are built via `struct/2` rather than a literal so the
# set-theoretic type checker can't statically flag the (intentional)
# protocol violation we're asserting on at runtime.
test "category channels are not Messageable" do
target = struct(Dexcord.CategoryChannel, id: 1)
assert_raise Protocol.UndefinedError, fn -> Messageable.resolve(target) end
end
test "forum channels are not Messageable" do
target = struct(Dexcord.ForumChannel, id: 1)
assert_raise Protocol.UndefinedError, fn -> Messageable.resolve(target) end
end
test "media channels are not Messageable" do
target = struct(Dexcord.MediaChannel, id: 1)
assert_raise Protocol.UndefinedError, fn -> Messageable.resolve(target) end
end
test "directory channels are not Messageable" do
target = struct(Dexcord.DirectoryChannel, id: 1)
assert_raise Protocol.UndefinedError, fn -> Messageable.resolve(target) end
end
end
end

View file

@ -1,193 +0,0 @@
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
defmodule Dexcord.MigrationGuideSamplesTest do
@moduledoc """
Mechanically pins `docs/alamedya-migration-v2.md` to the final API (AC5.1's
"code samples are valid against the final API").
The §3 handler module above is embedded VERBATIM from the guide (the keep-in-sync
comment appears in both files). We decode real MESSAGE_CREATE fixtures and drive
it through `Dexcord.FakeRest`, asserting it routes: bot/webhook/self authors send
nothing; a `"ping!"` or self-mention replies to the source channel. The second
block asserts every API the guide's snippets call still exists at the right
arity / with the right struct fields, so a rename in the library breaks this test
rather than silently rotting the guide.
"""
use ExUnit.Case, async: false
alias Dexcord.Events
alias Dexcord.FakeRest
alias Dexcord.Api.Ratelimit
@token "test.token.value"
@self_id 1_135_637_126_222_987_365
setup do
Dexcord.EnvSandbox.sandbox_env()
Dexcord.Config.put(%{token: @token, handler: AlamedyaDiscord.Handler, intents: 0})
start_supervised!({Finch, name: Dexcord.Finch})
start_supervised!(Ratelimit)
start_supervised!(Dexcord.Cache)
start_supervised!(FakeRest)
Application.put_env(:dexcord, :api_base_url, FakeRest.base_url())
FakeRest.subscribe(self())
:ok
end
defp message(raw), do: Events.decode(:MESSAGE_CREATE, raw)
describe "§3 MESSAGE_CREATE handler routes on typed struct fields" do
test "a webhook message is skipped before any send" do
msg =
message(%{
"webhook_id" => "999",
"channel_id" => "100",
"content" => "ping!",
"author" => %{"id" => "555", "username" => "hook"}
})
assert :ignore = AlamedyaDiscord.Handler.handle_event({:MESSAGE_CREATE, msg})
refute_receive {:rest_hit, _}, 50
end
test "a bot author is skipped" do
msg =
message(%{
"channel_id" => "100",
"content" => "ping!",
"author" => %{"id" => "7", "bot" => true}
})
assert :ignore = AlamedyaDiscord.Handler.handle_event({:MESSAGE_CREATE, msg})
refute_receive {:rest_hit, _}, 50
end
test "the bot's own message (@self_id) is skipped" do
msg =
message(%{
"channel_id" => "100",
"content" => "ping!",
"author" => %{"id" => Integer.to_string(@self_id)}
})
assert :ignore = AlamedyaDiscord.Handler.handle_event({:MESSAGE_CREATE, msg})
refute_receive {:rest_hit, _}, 50
end
test "a plain \"ping!\" replies \"helo\" to the source channel with a message_reference" do
FakeRest.stub(:post, "/channels/100/messages", FakeRest.resp(200, body: ~s({"id":"1"})))
msg =
message(%{
"id" => "500",
"channel_id" => "100",
"content" => "ping!",
"author" => %{"id" => "7", "bot" => false}
})
assert {:ok, %Dexcord.Message{}} =
AlamedyaDiscord.Handler.handle_event({:MESSAGE_CREATE, msg})
assert_receive {:rest_hit, %{method: "POST", path: "/channels/100/messages", body: body}}
decoded = JSON.decode!(body)
assert decoded["content"] == "helo"
assert decoded["message_reference"] == %{"message_id" => "500"}
end
test "a self-mention replies \"you rang?\"" do
FakeRest.stub(:post, "/channels/101/messages", FakeRest.resp(200, body: ~s({"id":"2"})))
msg =
message(%{
"id" => "501",
"channel_id" => "101",
"content" => "hey bot",
"author" => %{"id" => "8", "bot" => false},
"mentions" => [%{"id" => Integer.to_string(@self_id)}]
})
assert {:ok, %Dexcord.Message{}} =
AlamedyaDiscord.Handler.handle_event({:MESSAGE_CREATE, msg})
assert_receive {:rest_hit, %{path: "/channels/101/messages", body: body}}
assert JSON.decode!(body)["content"] == "you rang?"
end
end
describe "every API the guide's snippets use exists at the documented shape" do
test "functions the guide calls are exported at the right arity" do
for {mod, fun, arity} <- [
# §3 — reply/3 (reply(msg, body, opts \\ []))
{Dexcord.Message, :reply, 3},
# §4 — thread cache reads
{Dexcord.Cache, :threads, 1},
{Dexcord.Cache, :channel, 1},
# §6 — slash respond
{Dexcord.Slash, :respond, 2},
# §7 — the send funnel + history stream
{Dexcord.Api, :send, 2},
{Dexcord.Api, :message_history, 2},
# §8 — hydration
{Dexcord.Cache, :fill, 1},
# §5 — snowflake boundary cast
{Dexcord.Snowflake, :cast, 1},
{Dexcord.Snowflake, :cast!, 1}
] do
Code.ensure_loaded!(mod)
assert function_exported?(mod, fun, arity),
"#{inspect(mod)}.#{fun}/#{arity} is called by the guide but is not exported"
end
end
test "struct fields the guide accesses exist on their structs" do
assert_fields(%Dexcord.Message{}, [:author, :content, :channel_id, :webhook_id, :mentions])
assert_fields(%Dexcord.User{}, [:id, :bot, :username])
assert_fields(%Dexcord.Thread{}, [:id, :parent_id, :thread_metadata])
assert_fields(%Dexcord.ThreadMetadata{}, [:archived])
assert_fields(%Dexcord.Interaction{}, [:id, :token, :data])
assert_fields(%Dexcord.Events.Ready{}, [:user, :guilds])
assert_fields(%Dexcord.UnavailableGuild{}, [:id, :unavailable])
assert_fields(%Dexcord.Events.ReactionAdd{}, [:user_id, :user, :channel, :guild])
end
end
defp assert_fields(struct, fields) do
for field <- fields do
assert Map.has_key?(struct, field),
"#{inspect(struct.__struct__)} is missing the `#{field}` field the guide accesses"
end
end
end

View file

@ -139,100 +139,4 @@ defmodule Dexcord.ModelMessagePartsTest do
assert am.replied_user == true
end
end
describe "AllowedMentions.normalize/1" do
test "nil stays nil" do
assert Dexcord.AllowedMentions.normalize(nil) == nil
end
test "a struct normalizes to its wire map (all set fields explicit)" do
assert Dexcord.AllowedMentions.normalize(%Dexcord.AllowedMentions{}) ==
%{"parse" => [], "replied_user" => false}
end
test "a keyword stringifies its keys, keeping only provided keys" do
assert Dexcord.AllowedMentions.normalize(parse: [], users: [5]) ==
%{"parse" => [], "users" => [5]}
end
test "a map with atom or string keys stringifies keys" do
assert Dexcord.AllowedMentions.normalize(%{parse: []}) == %{"parse" => []}
assert Dexcord.AllowedMentions.normalize(%{"users" => ["5"]}) == %{"users" => ["5"]}
end
end
describe "AllowedMentions.merge/2" do
test "a nil default yields the per-send value" do
assert Dexcord.AllowedMentions.merge(nil, %{"users" => ["5"]}) == %{"users" => ["5"]}
end
test "a nil per-send yields the default" do
assert Dexcord.AllowedMentions.merge(%{"parse" => []}, nil) == %{"parse" => []}
end
test "field-wise: per-send keys win, default fills the rest" do
assert Dexcord.AllowedMentions.merge(%{"parse" => []}, %{"users" => ["5"]}) ==
%{"parse" => [], "users" => ["5"]}
end
test "a per-send key overrides the same key in the default" do
assert Dexcord.AllowedMentions.merge(%{"parse" => ["users"]}, %{"parse" => []}) ==
%{"parse" => []}
end
end
describe "Embed builder (AC3.6)" do
test "the builder chain produces the exact wire map" do
embed =
Dexcord.Embed.new()
|> Dexcord.Embed.title("t")
|> Dexcord.Embed.color(0xFF00FF)
|> Dexcord.Embed.field("a", "b", inline: true)
|> Dexcord.Embed.footer("f")
|> Dexcord.Embed.timestamp(~U[2026-07-04 12:00:00Z])
assert Dexcord.Embed.to_map(embed) == %{
"title" => "t",
"color" => 0xFF00FF,
"fields" => [%{"name" => "a", "value" => "b", "inline" => true}],
"footer" => %{"text" => "f"},
"timestamp" => "2026-07-04T12:00:00Z"
}
end
test "description/url/image/thumbnail/author land on the struct and encode" do
embed =
Dexcord.Embed.new()
|> Dexcord.Embed.description("d")
|> Dexcord.Embed.url("https://e.com")
|> Dexcord.Embed.image("https://e.com/i.png")
|> Dexcord.Embed.thumbnail("https://e.com/t.png")
|> Dexcord.Embed.author("me", url: "https://e.com/me", icon_url: "https://e.com/me.png")
assert Dexcord.Embed.to_map(embed) == %{
"description" => "d",
"url" => "https://e.com",
"image" => %{"url" => "https://e.com/i.png"},
"thumbnail" => %{"url" => "https://e.com/t.png"},
"author" => %{
"name" => "me",
"url" => "https://e.com/me",
"icon_url" => "https://e.com/me.png"
},
"fields" => []
}
end
test "fields append in order" do
embed =
Dexcord.Embed.new()
|> Dexcord.Embed.field("a", "1")
|> Dexcord.Embed.field("b", "2", inline: true)
assert [
%Dexcord.EmbedField{name: "a", value: "1", inline: false},
%Dexcord.EmbedField{name: "b", value: "2", inline: true}
] = embed.fields
end
end
end

View file

@ -1,177 +0,0 @@
defmodule Dexcord.PaginationTest do
@moduledoc """
Wire tests for the lazy pagination streams on `Dexcord.Api`
(api-surface.AC3.7), driven through `Dexcord.FakeRest`.
The point of these streams is laziness: a page is fetched only when the
consumer walks that far, so `Stream.take/2` on a fresh stream makes exactly
one wire hit. These tests assert that directly (the second hit is refuted).
"""
use ExUnit.Case, async: false
alias Dexcord.Api
alias Dexcord.Api.Paginate
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!(Dexcord.Cache)
start_supervised!(FakeRest)
Application.put_env(:dexcord, :api_base_url, FakeRest.base_url())
FakeRest.subscribe(self())
:ok
end
# Build a JSON array body of message objects with the given ids (as strings).
defp messages_json(ids),
do: "[" <> Enum.map_join(ids, ",", fn id -> ~s({"id":"#{id}"}) end) <> "]"
# Build a JSON array of member objects (nested user id) with the given ids.
defp members_json(ids),
do: "[" <> Enum.map_join(ids, ",", fn id -> ~s({"user":{"id":"#{id}"}}) end) <> "]"
describe "AC3.7: message_history pages lazily across multiple hits" do
test "two sequential pages yield every message in order, before= cursor on hit 2" do
page1_ids = Enum.to_list(200..101//-1)
page2_ids = [100, 99, 98]
assert length(page1_ids) == 100
FakeRest.stub(
:get,
"/channels/1/messages",
FakeRest.resp(200, body: messages_json(page1_ids))
)
FakeRest.stub(
:get,
"/channels/1/messages",
FakeRest.resp(200, body: messages_json(page2_ids))
)
result = Api.message_history(1) |> Enum.to_list()
assert Enum.map(result, & &1.id) == page1_ids ++ page2_ids
# Exactly two hits; the second carries before=<last id of page 1>.
assert_receive {:rest_hit, %{method: "GET", path: "/channels/1/messages", query_string: q1}}
refute q1 =~ "before="
assert_receive {:rest_hit, %{path: "/channels/1/messages", query_string: q2}}
assert q2 =~ "before=101"
refute_receive {:rest_hit, %{path: "/channels/1/messages"}}, 50
end
test "LAZINESS: Stream.take(5) makes exactly one wire hit" do
# A single, sticky 100-message page: if the stream were eager it would
# loop forever hitting the wire. take(5) must fetch exactly one page.
full_page = Enum.to_list(500..401//-1)
assert length(full_page) == 100
FakeRest.stub(
:get,
"/channels/7/messages",
FakeRest.resp(200, body: messages_json(full_page))
)
result = Api.message_history(7) |> Stream.take(5) |> Enum.to_list()
assert Enum.map(result, & &1.id) == [500, 499, 498, 497, 496]
assert_receive {:rest_hit, %{path: "/channels/7/messages"}}
refute_receive {:rest_hit, %{path: "/channels/7/messages"}}, 50
end
test "after: flips to ascending paging with after= cursor" do
page1_ids = Enum.to_list(101..200)
page2_ids = [201, 202, 203]
assert length(page1_ids) == 100
FakeRest.stub(
:get,
"/channels/2/messages",
FakeRest.resp(200, body: messages_json(page1_ids))
)
FakeRest.stub(
:get,
"/channels/2/messages",
FakeRest.resp(200, body: messages_json(page2_ids))
)
result = Api.message_history(2, after: 100) |> Enum.to_list()
assert Enum.map(result, & &1.id) == page1_ids ++ page2_ids
assert_receive {:rest_hit, %{path: "/channels/2/messages", query_string: q1}}
assert q1 =~ "after=100"
assert_receive {:rest_hit, %{path: "/channels/2/messages", query_string: q2}}
assert q2 =~ "after=200"
refute q2 =~ "before="
end
test "limit: caps the total number of messages via Stream.take" do
full_page = Enum.to_list(300..201//-1)
FakeRest.stub(
:get,
"/channels/3/messages",
FakeRest.resp(200, body: messages_json(full_page))
)
result = Api.message_history(3, limit: 3) |> Enum.to_list()
assert Enum.map(result, & &1.id) == [300, 299, 298]
assert_receive {:rest_hit, %{path: "/channels/3/messages"}}
refute_receive {:rest_hit, %{path: "/channels/3/messages"}}, 50
end
end
describe "guild_members_stream pages ascending on the last user_id" do
test "cursor is the last member's user id" do
page1_ids = Enum.to_list(1..1000)
page2_ids = [1001, 1002]
assert length(page1_ids) == 1000
FakeRest.stub(:get, "/guilds/9/members", FakeRest.resp(200, body: members_json(page1_ids)))
FakeRest.stub(:get, "/guilds/9/members", FakeRest.resp(200, body: members_json(page2_ids)))
result = Api.guild_members_stream(9) |> Enum.to_list()
assert Enum.map(result, & &1.user.id) == page1_ids ++ page2_ids
assert_receive {:rest_hit, %{path: "/guilds/9/members", query_string: q1}}
assert q1 =~ "after=0"
assert_receive {:rest_hit, %{path: "/guilds/9/members", query_string: q2}}
assert q2 =~ "after=1000"
end
end
describe "error mid-stream" do
test "an {:error, _} page raises Dexcord.Api.Paginate.PageError" do
full_page = Enum.to_list(200..101//-1)
FakeRest.stub(
:get,
"/channels/5/messages",
FakeRest.resp(200, body: messages_json(full_page))
)
FakeRest.stub(
:get,
"/channels/5/messages",
FakeRest.resp(500, body: ~s({"message":"boom"}))
)
assert_raise Paginate.PageError, fn ->
Api.message_history(5) |> Enum.to_list()
end
end
end
end

View file

@ -1,153 +0,0 @@
defmodule Dexcord.PermissionsComputeTest do
@moduledoc """
Pure tests for `Dexcord.Guild.member_permissions/2,3` the documented
overwrite-resolution algorithm (owner base roles ADMINISTRATOR short-circuit
@everyone/role/member overwrites timeout rule), verified verbatim.
"""
use ExUnit.Case, async: true
import Bitwise
alias Dexcord.Guild
alias Dexcord.Member
alias Dexcord.Overwrite
alias Dexcord.Permissions
alias Dexcord.Role
@send Permissions.from_list([:send_messages])
@view Permissions.from_list([:view_channel])
@rmh Permissions.from_list([:read_message_history])
@admin Permissions.from_list([:administrator])
@ban Permissions.from_list([:ban_members])
@all Permissions.all() |> Map.values() |> Enum.reduce(0, &bor/2)
@timeout_allowed bor(@view, @rmh)
# Guild id 1; @everyone role has id == guild id (== 1).
defp guild(roles), do: %Guild{id: 1, owner_id: 100, roles: roles}
defp everyone(perms), do: %Role{id: 1, permissions: perms}
defp role(id, perms), do: %Role{id: id, permissions: perms}
defp member(user_id, role_ids), do: %Member{user_id: user_id, roles: role_ids}
test "the guild owner gets all permissions" do
g = guild([everyone(0)])
m = member(100, [])
assert Guild.member_permissions(g, m) == @all
end
test "an ADMINISTRATOR member gets all permissions, even with a deny-everything channel overwrite" do
g = guild([everyone(0), role(20, @admin)])
m = member(200, [20])
channel = %Dexcord.TextChannel{
id: 5,
permission_overwrites: [%Overwrite{id: 1, type: :role, allow: 0, deny: @all}]
}
assert Guild.member_permissions(g, m, channel) == @all
end
test "base permissions are the OR of @everyone and every member role" do
g = guild([everyone(@view), role(10, @send), role(11, @ban)])
m = member(200, [10, 11])
assert Guild.member_permissions(g, m) == (@view ||| @send ||| @ban)
end
test "unknown member role ids are skipped" do
g = guild([everyone(@view), role(10, @send)])
m = member(200, [10, 999])
assert Guild.member_permissions(g, m) == (@view ||| @send)
end
test "an @everyone channel overwrite that denies SEND_MESSAGES removes it" do
g = guild([everyone(@view), role(10, @send)])
m = member(200, [10])
channel = %Dexcord.TextChannel{
id: 5,
permission_overwrites: [%Overwrite{id: 1, type: :role, allow: 0, deny: @send}]
}
result = Guild.member_permissions(g, m, channel)
refute Permissions.has?(result, :send_messages)
assert Permissions.has?(result, :view_channel)
end
test "a role overwrite allow restores a bit denied by @everyone (deny then allow order)" do
g = guild([everyone(@view), role(10, @send)])
m = member(200, [10])
channel = %Dexcord.TextChannel{
id: 5,
permission_overwrites: [
%Overwrite{id: 1, type: :role, allow: 0, deny: @send},
%Overwrite{id: 10, type: :role, allow: @send, deny: 0}
]
}
result = Guild.member_permissions(g, m, channel)
assert Permissions.has?(result, :send_messages)
end
test "a member overwrite deny wins over a role overwrite allow (member applied last)" do
g = guild([everyone(@view), role(10, 0)])
m = member(200, [10])
channel = %Dexcord.TextChannel{
id: 5,
permission_overwrites: [
%Overwrite{id: 10, type: :role, allow: @send, deny: 0},
%Overwrite{id: 200, type: :member, allow: 0, deny: @send}
]
}
result = Guild.member_permissions(g, m, channel)
refute Permissions.has?(result, :send_messages)
end
test "within the role tier, an allow OR-aggregates over a deny of the same bit (allow wins)" do
g = guild([everyone(@view), role(10, 0), role(11, 0)])
m = member(200, [10, 11])
channel = %Dexcord.TextChannel{
id: 5,
permission_overwrites: [
%Overwrite{id: 10, type: :role, allow: 0, deny: @send},
%Overwrite{id: 11, type: :role, allow: @send, deny: 0}
]
}
result = Guild.member_permissions(g, m, channel)
assert Permissions.has?(result, :send_messages)
end
test "a timed-out member keeps exactly VIEW_CHANNEL | READ_MESSAGE_HISTORY" do
future = DateTime.add(DateTime.utc_now(), 3600, :second)
g = guild([everyone(@view ||| @send ||| @rmh)])
m = %Member{user_id: 200, roles: [], communication_disabled_until: future}
assert Guild.member_permissions(g, m) == @timeout_allowed
end
test "a timed-out administrator is unaffected (owner/admin skip the timeout rule)" do
future = DateTime.add(DateTime.utc_now(), 3600, :second)
g = guild([everyone(0), role(20, @admin)])
m = %Member{user_id: 200, roles: [20], communication_disabled_until: future}
assert Guild.member_permissions(g, m) == @all
end
test "a PAST communication_disabled_until is NOT a timeout" do
past = DateTime.add(DateTime.utc_now(), -3600, :second)
g = guild([everyone(@view ||| @send)])
m = %Member{user_id: 200, roles: [], communication_disabled_until: past}
result = Guild.member_permissions(g, m)
assert Permissions.has?(result, :send_messages)
assert Permissions.has?(result, :view_channel)
end
end

View file

@ -1,219 +0,0 @@
defmodule Dexcord.SendTest do
@moduledoc """
Wire tests for the `Dexcord.Api.send/2,3` funnel and its lazy DM dance
(api-surface.AC3.1, api-surface.AC3.2), driven through `Dexcord.FakeRest`.
"""
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!(Dexcord.Cache)
start_supervised!(FakeRest)
Application.put_env(:dexcord, :api_base_url, FakeRest.base_url())
FakeRest.subscribe(self())
:ok
end
describe "AC3.1: send/2 resolves channels, threads, messages, and bare ids to the right route" do
test "a text channel struct" do
FakeRest.stub(:post, "/channels/1/messages", FakeRest.resp(200, body: ~s({"id":"1"})))
assert {:ok, %Dexcord.Message{}} = Api.send(%Dexcord.TextChannel{id: 1}, "hi")
assert_receive {:rest_hit, %{method: "POST", path: "/channels/1/messages"}}
end
test "a thread struct" do
FakeRest.stub(:post, "/channels/9/messages", FakeRest.resp(200, body: ~s({"id":"2"})))
assert {:ok, %Dexcord.Message{}} = Api.send(%Dexcord.Thread{id: 9}, "hi")
assert_receive {:rest_hit, %{path: "/channels/9/messages"}}
end
test "a bare snowflake" do
FakeRest.stub(:post, "/channels/123/messages", FakeRest.resp(200, body: ~s({"id":"3"})))
assert {:ok, %Dexcord.Message{}} = Api.send(123, "hi")
assert_receive {:rest_hit, %{path: "/channels/123/messages"}}
end
test "a message resolves to its channel" do
FakeRest.stub(:post, "/channels/5/messages", FakeRest.resp(200, body: ~s({"id":"4"})))
assert {:ok, %Dexcord.Message{}} = Api.send(%Dexcord.Message{channel_id: 5}, "hi")
assert_receive {:rest_hit, %{path: "/channels/5/messages"}}
end
end
describe "AC3.2: send/2 to a user lazily creates the DM once, then reuses it" do
setup do
FakeRest.stub(
:post,
"/users/@me/channels",
FakeRest.resp(200, body: ~s({"id":"77","type":1}))
)
FakeRest.stub(:post, "/channels/77/messages", FakeRest.resp(200, body: ~s({"id":"9"})))
:ok
end
test "first send hits create-DM then the message; second send skips create-DM" do
# First send: create the DM channel, then post the message.
assert {:ok, %Dexcord.Message{}} = Api.send(%Dexcord.User{id: 42}, "yo")
assert_receive {:rest_hit, %{method: "POST", path: "/users/@me/channels"}}
assert_receive {:rest_hit, %{method: "POST", path: "/channels/77/messages"}}
# Second send: the DM channel id is cached, so ONLY a message hit occurs.
assert {:ok, %Dexcord.Message{}} = Api.send(%Dexcord.User{id: 42}, "again")
assert_receive {:rest_hit, %{method: "POST", path: "/channels/77/messages"}}
refute_receive {:rest_hit, %{path: "/users/@me/channels"}}, 50
end
test "a member (via nested user) reuses the same cached DM as the user" do
assert {:ok, %Dexcord.Message{}} = Api.send(%Dexcord.User{id: 42}, "first")
assert_receive {:rest_hit, %{path: "/users/@me/channels"}}
assert_receive {:rest_hit, %{path: "/channels/77/messages"}}
assert {:ok, %Dexcord.Message{}} =
Api.send(%Dexcord.Member{user: %Dexcord.User{id: 42}}, "second")
assert_receive {:rest_hit, %{path: "/channels/77/messages"}}
refute_receive {:rest_hit, %{path: "/users/@me/channels"}}, 50
end
end
describe "AC3.3: Message.reply/2,3 sets message_reference and mention_author" do
setup do
FakeRest.stub(:post, "/channels/5/messages", FakeRest.resp(200, body: ~s({"id":"1"})))
%{msg: %Dexcord.Message{id: 555, channel_id: 5}}
end
test "reply/2 sets message_reference to the source message", %{msg: msg} do
assert {:ok, %Dexcord.Message{}} = Dexcord.Message.reply(msg, "pong")
assert_receive {:rest_hit, %{path: "/channels/5/messages", body: body}}
decoded = JSON.decode!(body)
assert decoded["content"] == "pong"
assert decoded["message_reference"] == %{"message_id" => "555"}
refute Map.has_key?(decoded, "allowed_mentions")
end
test "mention_author: false sets allowed_mentions.replied_user to false", %{msg: msg} do
assert {:ok, %Dexcord.Message{}} = Dexcord.Message.reply(msg, "pong", mention_author: false)
assert_receive {:rest_hit, %{path: "/channels/5/messages", body: body}}
decoded = JSON.decode!(body)
assert decoded["allowed_mentions"] == %{"replied_user" => false}
assert decoded["message_reference"] == %{"message_id" => "555"}
end
test "mention_author: true preserves other allowed_mentions keys and wins over the body",
%{msg: msg} do
body = %{
"content" => "pong",
"allowed_mentions" => %{"users" => ["7"], "replied_user" => false}
}
assert {:ok, %Dexcord.Message{}} = Dexcord.Message.reply(msg, body, mention_author: true)
assert_receive {:rest_hit, %{path: "/channels/5/messages", body: raw}}
decoded = JSON.decode!(raw)
assert decoded["allowed_mentions"] == %{"users" => ["7"], "replied_user" => true}
end
end
describe "AC3.5: config-level allowed_mentions default merges field-wise per send" do
setup do
FakeRest.stub(:post, "/channels/1/messages", FakeRest.resp(200, body: ~s({"id":"1"})))
:ok
end
defp put_config_with(allowed_mentions) do
Dexcord.Config.put(%{
token: @token,
handler: nil,
intents: 0,
allowed_mentions: allowed_mentions
})
end
test "the configured default applies when the send carries none" do
put_config_with(%{"parse" => []})
assert {:ok, %Dexcord.Message{}} = Api.send(1, "hi")
assert_receive {:rest_hit, %{path: "/channels/1/messages", body: body}}
decoded = JSON.decode!(body)
assert decoded["allowed_mentions"] == %{"parse" => []}
end
test "a per-send value merges field-wise over the default (both keys survive)" do
put_config_with(%{"parse" => []})
body = %{"content" => "hi", "allowed_mentions" => %{"users" => ["5"]}}
assert {:ok, %Dexcord.Message{}} = Api.send(1, body)
assert_receive {:rest_hit, %{path: "/channels/1/messages", body: raw}}
decoded = JSON.decode!(raw)
assert decoded["allowed_mentions"] == %{"parse" => [], "users" => ["5"]}
end
test "no config default and no per-send value leaves allowed_mentions absent" do
# Base config (no :allowed_mentions) is already in place from the top setup.
assert {:ok, %Dexcord.Message{}} = Api.send(1, "hi")
assert_receive {:rest_hit, %{path: "/channels/1/messages", body: body}}
decoded = JSON.decode!(body)
refute Map.has_key?(decoded, "allowed_mentions")
end
end
describe "AC3.6: an Embed built with the builder rides Api.send as valid wire JSON" do
setup do
FakeRest.stub(:post, "/channels/1/messages", FakeRest.resp(200, body: ~s({"id":"1"})))
:ok
end
test "send/2 with embeds: [embed] serializes the embed" do
embed =
Dexcord.Embed.new()
|> Dexcord.Embed.title("t")
|> Dexcord.Embed.field("a", "b", inline: true)
assert {:ok, %Dexcord.Message{}} = Api.send(1, embeds: [embed])
assert_receive {:rest_hit, %{path: "/channels/1/messages", body: body}}
decoded = JSON.decode!(body)
assert decoded["embeds"] == [
%{
"title" => "t",
"fields" => [%{"name" => "a", "value" => "b", "inline" => true}]
}
]
end
end
end

View file

@ -5,7 +5,6 @@ defmodule Dexcord.SlashTest do
alias Dexcord.Api.Ratelimit
alias Dexcord.FakeRest
alias Dexcord.Interaction
alias Dexcord.Slash
@token "test.token.value"
@ -51,37 +50,25 @@ defmodule Dexcord.SlashTest do
end
test "dispatch/2 routes a type-2 interaction to handle_interaction/2 by name" do
itx = Interaction.from_map(%{"type" => 2, "data" => %{"name" => "ping"}, "id" => "1"})
itx = %{"type" => 2, "data" => %{"name" => "ping"}, "id" => "1"}
assert Slash.dispatch(itx, Commands) == :ok
assert_received {:handled, "ping", %Dexcord.Interaction{type: :application_command}}
assert_received {:handled, "ping", ^itx}
end
test "dispatch/2 routes a type-3 (component) interaction to handle_component/2 by custom_id" do
itx =
Interaction.from_map(%{"type" => 3, "data" => %{"custom_id" => "refresh"}, "id" => "2"})
itx = %{"type" => 3, "data" => %{"custom_id" => "refresh"}, "id" => "2"}
assert Slash.dispatch(itx, Commands) == :ok
assert_received {:component, "refresh", %Dexcord.Interaction{type: :message_component}}
assert_received {:component, "refresh", ^itx}
end
test "dispatch/2 routes a type-5 (modal) interaction to handle_modal/2 by custom_id" do
itx =
Interaction.from_map(%{"type" => 5, "data" => %{"custom_id" => "feedback"}, "id" => "3"})
itx = %{"type" => 5, "data" => %{"custom_id" => "feedback"}, "id" => "3"}
assert Slash.dispatch(itx, Commands) == :ok
assert_received {:modal, "feedback", %Dexcord.Interaction{type: :modal_submit}}
end
test "the raw-map degraded path still routes on the integer type" do
# A malformed interaction the dispatcher could not decode into a struct is
# routed here as a raw map; it must still reach the callback by integer type.
raw = %{"type" => 2, "data" => %{"name" => "ping"}, "id" => "1"}
assert Slash.dispatch(raw, Commands) == :ok
assert_received {:handled, "ping", ^raw}
assert_received {:modal, "feedback", ^itx}
end
test "the injected catch-all logs a warning for an unhandled command name" do
itx = Interaction.from_map(%{"type" => 2, "data" => %{"name" => "unknown"}})
itx = %{"type" => 2, "data" => %{"name" => "unknown"}}
log =
capture_log(fn ->
@ -93,8 +80,8 @@ defmodule Dexcord.SlashTest do
end
test "the injected component/modal catch-alls log at debug, not warning" do
component = Interaction.from_map(%{"type" => 3, "data" => %{"custom_id" => "nope"}})
modal = Interaction.from_map(%{"type" => 5, "data" => %{"custom_id" => "nope"}})
component = %{"type" => 3, "data" => %{"custom_id" => "nope"}}
modal = %{"type" => 5, "data" => %{"custom_id" => "nope"}}
log =
capture_log([level: :debug], fn ->
@ -108,12 +95,12 @@ defmodule Dexcord.SlashTest do
end
test "a module defining only handle_interaction/2 still routes components/modals via defaults" do
itx2 = Interaction.from_map(%{"type" => 2, "data" => %{"name" => "ping"}})
itx2 = %{"type" => 2, "data" => %{"name" => "ping"}}
Slash.dispatch(itx2, LegacyCommands)
assert_received {:legacy, %Dexcord.Interaction{type: :application_command}}
assert_received {:legacy, ^itx2}
component = Interaction.from_map(%{"type" => 3, "data" => %{"custom_id" => "x"}})
modal = Interaction.from_map(%{"type" => 5, "data" => %{"custom_id" => "y"}})
component = %{"type" => 3, "data" => %{"custom_id" => "x"}}
modal = %{"type" => 5, "data" => %{"custom_id" => "y"}}
capture_log([level: :debug], fn ->
assert Slash.dispatch(component, LegacyCommands) == :ignore
@ -138,14 +125,13 @@ defmodule Dexcord.SlashTest do
# Interaction/application ids must be numeric snowflakes: the typed endpoint
# surface casts `:interaction_id` / `:application_id` path params via
# `Dexcord.Snowflake.cast/1`. The interaction token is an opaque string. The
# response helpers take a decoded `%Dexcord.Interaction{}`, so build one.
@itx Interaction.from_map(%{
# `Dexcord.Snowflake.cast/1`. The interaction token is an opaque string.
@itx %{
"id" => "100",
"token" => "int-token",
"application_id" => "200",
"data" => %{"name" => "ping"}
})
}
test "respond/2 with a string sends a type-4 content response to the callback route" do
FakeRest.stub(:post, "/interactions/100/int-token/callback", FakeRest.resp(204))
@ -172,19 +158,6 @@ defmodule Dexcord.SlashTest do
assert body["data"]["embeds"] == [%{"title" => "x"}]
end
test "respond/2 encodes struct data values (e.g. an %Embed{}) to wire maps" do
FakeRest.stub(:post, "/interactions/100/int-token/callback", FakeRest.resp(204))
assert {:ok, nil} = Slash.respond(@itx, %{embeds: [%Dexcord.Embed{title: "t"}]})
assert_receive {:rest_hit, info}
body = JSON.decode!(info.body)
# The %Embed{} struct is run through its own to_map/1: string-keyed wire map,
# title preserved, empty-list defaults (e.g. fields) retained as the encoder emits.
assert [%{"title" => "t"} = embed] = body["data"]["embeds"]
assert embed["fields"] == []
end
test "respond/2 accepts string-keyed data maps too" do
FakeRest.stub(:post, "/interactions/100/int-token/callback", FakeRest.resp(204))