api-surface #1
3 changed files with 723 additions and 26 deletions
docs: alamedya migration guide v2 + typed-contract README/moduledoc refresh
commit
ce772b369e
74
README.md
74
README.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
482
docs/alamedya-migration-v2.md
Normal file
482
docs/alamedya-migration-v2.md
Normal 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>
|
||||
193
test/dexcord/migration_guide_samples_test.exs
Normal file
193
test/dexcord/migration_guide_samples_test.exs
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue