482 lines
18 KiB
Markdown
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>
|