18 KiB
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):
# 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):
# 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):
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):
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):
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):
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):
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):
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:
{: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
:integerEcto column (discord_message_id) takesmsg.iddirectly.
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):
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):
# 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.
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:
{: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):
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):
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):
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 Streams. 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):
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):
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:
-
Clean compile with Nostrum gone — proves no lingering
Nostrum.*references or struct matches, and no leftoverString.to_integeron ids that are now integers:mix compile --warnings-as-errors grep -rn "Nostrum" lib/ config/ # expect zero hits -
Tests (if the app has any touching this code):
mix test. -
End-to-end — the real acceptance test (the failure the whole migration retires): start Alamedya with
DISCORD_TOKENset and no discord.py running. Confirm it connects via Dexcord's own gateway (aREADYlog), 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.