api-surface #1

Merged
luna merged 51 commits from api-surface into mistress 2026-07-05 14:43:13 +00:00
3 changed files with 723 additions and 26 deletions
Showing only changes of commit ce772b369e - Show all commits

docs: alamedya migration guide v2 + typed-contract README/moduledoc refresh

Luna 2026-07-05 08:32:10 -03:00

View file

@ -112,18 +112,21 @@ whether prefix or slash routing also fires.
### 1. Raw handler events
Every gateway dispatch event reaches your `Dexcord.Handler` as a
`{event_name, data}` tuple, where `data` is the raw string-keyed payload map
Discord sent (no atom keys, no structs):
`{event_name, data}` tuple. `data` is decoded exactly once, in the dispatcher,
into a typed model **struct** with typed fields and **integer** snowflake ids -
`%Dexcord.Message{}`, `%Dexcord.Guild{}`, `%Dexcord.Interaction{}`, and friends
(a malformed payload that fails to decode falls back to the raw string-keyed map
so dispatch never stalls):
```elixir
defmodule MyBot.Handler do
use Dexcord.Handler
def handle_event({:MESSAGE_CREATE, msg}) do
IO.puts("#{msg["author"]["username"]}: #{msg["content"]}")
def handle_event({:MESSAGE_CREATE, %Dexcord.Message{} = msg}) do
IO.puts("#{msg.author.username}: #{msg.content}")
end
def handle_event({:PRESENCE_UPDATE, presence}) do
def handle_event({:PRESENCE_UPDATE, %Dexcord.Presence{} = presence}) do
# only fires if cache_presences: true and presences are being cached
end
end
@ -144,28 +147,30 @@ handler's `MESSAGE_CREATE` clause:
defmodule MyBot.Commands do
use Dexcord.Prefix.Router
def handle_command("ping", _args, msg) do
Dexcord.Api.create_message(msg["channel_id"], "pong")
def handle_command("ping", _args, %Dexcord.Message{} = msg) do
Dexcord.Message.reply(msg, "pong")
end
def handle_command("echo", args, msg) do
Dexcord.Api.create_message(msg["channel_id"], Enum.join(args, " "))
def handle_command("echo", args, %Dexcord.Message{} = msg) do
Dexcord.Api.send(msg.channel_id, Enum.join(args, " "))
end
end
defmodule MyBot.Handler do
use Dexcord.Handler
def handle_event({:MESSAGE_CREATE, msg}) do
def handle_event({:MESSAGE_CREATE, %Dexcord.Message{} = msg}) do
Dexcord.Prefix.dispatch(msg, prefix: "!", to: MyBot.Commands)
end
end
```
`dispatch/2` skips messages authored by a bot (including the bot's own
messages, checked both via the payload's `author.bot` flag and against the
cached bot user id) so a command that replies in-channel can't recursively
trigger itself. `Dexcord.Prefix.parse/2` is exposed separately as a pure
The router receives the decoded `%Dexcord.Message{}`, so command handlers read
typed fields (`msg.channel_id`, `msg.author.id`) and reply with
`Dexcord.Message.reply/2` or `Dexcord.Api.send/2`. `dispatch/2` skips messages
authored by a bot (including the bot's own messages, checked both via the
`author.bot` flag and against the cached bot user id) so a command that replies
in-channel can't recursively trigger itself. `Dexcord.Prefix.parse/2` is exposed separately as a pure
function if you want the prefix/command/args split without the bot-author
check or the router dispatch.
@ -327,13 +332,20 @@ limiting - `Dexcord.Api.Ratelimit` learns each route's bucket from response
headers and makes the calling process sleep as needed; you never manage
rate limits yourself.
Typed endpoints (thin wrappers, string-keyed request/response maps):
Typed endpoints take string-keyed request maps (or a bare binary where a
`content`/`name` wrap is natural) and **decode their responses into model
structs** - `{:ok, %Dexcord.Message{}}`, `{:ok, %Dexcord.User{}}`,
`{:ok, %Dexcord.Channel{}}` (the concrete per-type struct), a list of structs
for list endpoints, or `{:error, %Dexcord.Api.Error{}}`:
```elixir
{:ok, %Dexcord.Message{} = sent} = Dexcord.Api.create_message(channel_id, "hi")
{:ok, %Dexcord.User{} = me} = Dexcord.Api.get_current_user()
```
get_gateway_bot/0 get_current_user/0 get_current_application/0
get_user/1 get_channel/1 get_guild/1
create_dm/1 create_message/2 edit_message/3
delete_message/2 create_reaction/3
delete_message/2 create_reaction/3 get_channel_messages/2
create_interaction_response/3
edit_original_interaction_response/3
create_followup_message/3
@ -341,8 +353,15 @@ bulk_overwrite_global_commands/2
bulk_overwrite_guild_commands/3
```
For anything not covered, `Dexcord.Api.request/4` is the escape hatch every
typed endpoint is built on:
`Dexcord.Api.send/2` is the ergonomic front door over `create_message`: it
accepts anything `Dexcord.Messageable` - a channel or thread struct, a
`%Dexcord.Message{}` (posts to its channel), a `%Dexcord.User{}`/`%Dexcord.Member{}`
(lazily opening and caching a DM), or a bare integer channel id - and applies the
configured `allowed_mentions` default. List endpoints also come as lazy streams
(`message_history/2`, `guild_members_stream/2`, ...) that page on demand.
For anything without a typed wrapper, `Dexcord.Api.request/4` is the escape hatch
every typed endpoint is built on - it stays string-keyed both ways:
```elixir
Dexcord.Api.request(:patch, "/guilds/#{guild_id}", %{"name" => "New Name"})
@ -422,16 +441,19 @@ running untouched.
| Nostrum | Dexcord |
|---|---|
| `Nostrum.Consumer` `handle_event/1` callback | `Dexcord.Handler` `handle_event/1` callback (`use Dexcord.Handler`) |
| `Nostrum.Api.*` | `Dexcord.Api.*` (typed endpoints + `request/4` escape hatch) |
| `Nostrum.Api.*` | `Dexcord.Api.*` (typed struct returns + `request/4` escape hatch) |
| `Nostrum.Cache.*` (several cache modules) | `Dexcord.Cache` (one module, one ETS table per entity type) |
| `%Nostrum.Struct.Message{}` etc. (atom-keyed structs) | string-keyed maps everywhere - `msg["content"]`, not `msg.content` |
| `%Nostrum.Struct.Message{}` etc. (atom-keyed structs) | `%Dexcord.Message{}` etc. (typed structs, integer snowflake ids) - `msg.content`, `msg.author.id` |
| snowflake ids parsed to integers | snowflake ids **are** integers on every decoded struct |
| Auto-starting `:nostrum` application | you add `{Dexcord, opts}` to your own supervision tree |
The biggest day-to-day adjustment is the lack of structs: every payload -
messages, guilds, members, interactions - is the raw string-keyed map
Discord sent over the wire. This costs `msg["content"]` instead of
`msg.content`, but means Dexcord never has to guess a struct shape ahead of a
Discord API change, and never mints atoms from wire data.
Like Nostrum, Dexcord hands your handler decoded **structs** with typed fields
and integer ids, so day-to-day access is `msg.content` / `msg.author.id`, not
map indexing. The escape hatch below the model layer - `Dexcord.Api.request/4` -
stays string-keyed both ways for endpoints without a typed wrapper. A full
worked port (READY backfill, MESSAGE_CREATE flow, thread cache reads, integer
snowflakes, REST, hydration) lives in
[`docs/alamedya-migration-v2.md`](docs/alamedya-migration-v2.md).
## Reliability design

View file

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

View file

@ -0,0 +1,193 @@
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