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