dexcord/docs/alamedya-migration-v2.md

482 lines
18 KiB
Markdown

# 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>