diff --git a/README.md b/README.md index e0fabad..939dc7a 100644 --- a/README.md +++ b/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 diff --git a/docs/alamedya-migration-v2.md b/docs/alamedya-migration-v2.md new file mode 100644 index 0000000..a4b862d --- /dev/null +++ b/docs/alamedya-migration-v2.md @@ -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. + + diff --git a/docs/test-plans/2026-07-05-api-surface.md b/docs/test-plans/2026-07-05-api-surface.md new file mode 100644 index 0000000..99ce4ea --- /dev/null +++ b/docs/test-plans/2026-07-05-api-surface.md @@ -0,0 +1,84 @@ +# Human Test Plan — dexcord Full API Surface (2026-07-05-api-surface) + +Coverage validation: **PASS** — 44/44 acceptance criteria covered by automated tests +(633 tests, 0 failures, 1 excluded `:flaky` with a deterministic default-suite twin). +This plan covers the residue automation cannot prove: hermeticity, CI enablement, +and migration-guide editorial quality, plus an optional live end-to-end. + +## Prerequisites + +- Elixir 1.18 / OTP 27, `mix deps.get` clean. +- `mix test` green from the repo root (baseline: 633 tests, 0 failures, 1 excluded). +- A Gitea repo with push access for the CI checks. +- Optional: a real Discord bot token + a throwaway test guild for the live end-to-end. + +## Phase A: Suite Hermeticity (HV-1 — AC4.1) + +Automated coverage proves the suite is green; this proves green also means *no external network*. + +| Step | Action | Expected | +|------|--------|----------| +| A1 | Add `127.0.0.1 discord.com` (and `docs.discord.com`) to `/etc/hosts`, or run `sudo unshare -n mix test` from repo root | Full suite still exits 0 with the same pass count (633/0/1) | +| A2 | `rg -n 'discord\.com|githubusercontent' test/` | Hits only fixtures, doc-link strings, and the deliberately network-only `dexcord.coverage.refresh` task — never a live call from a test | +| A3 | `rg -n 'coverage\.refresh' test/ .gitea/` | No match — the network-only refresh task is never invoked by tests or CI | +| A4 | Revert `/etc/hosts` | Clean state restored | + +## Phase B: CI Gate Actually Runs & Bites (HV-2 — AC4.3) + +| Step | Action | Expected | +|------|--------|----------| +| B1 | Confirm `.gitea/workflows/ci.yml` lists `- run: mix test` and `- run: mix dexcord.coverage` | Both steps present | +| B2 | In Gitea repo settings, enable **Actions** | Actions enabled (owner infra step; no CI configured today) | +| B3 | Push a normal commit (or open a PR) | Pipeline triggers; both `mix test` and `mix dexcord.coverage` run green | +| B4 | On a throwaway branch, delete one in-scope `endpoint` declaration line (e.g. `get_channel` in `lib/dexcord/api/channels.ex`), push | The `mix dexcord.coverage` step turns the pipeline **red** (non-zero exit, route reported missing) | +| B5 | Delete the throwaway branch | Gate proven to bite; no residue | + +## Phase C: Migration Guide Completeness & Prose (HV-3 — AC5.1) + +The sample-validity half is automated (`migration_guide_samples_test.exs`); this covers +editorial completeness against the real old app. + +| Step | Action | Expected | +|------|--------|----------| +| C1 | Read the old `alamedya-migration.md` (untracked, repo root) and extract its Discord touchpoint checklist | A concrete list, esp. the old string↔int snowflake dance and thread/cache reads specific to alamedya | +| C2 | Open `docs/alamedya-migration-v2.md` and confirm all 8 mandated sections exist | §1 Boot/config, §2 READY backfill, §3 MESSAGE_CREATE, §4 Thread cache reads, §5 Integer snowflakes, §6 Slash commands, §7 REST calls, §8 Hydration | +| C3 | For each old touchpoint from C1, find its before→after mapping in v2 | Nothing dropped; each maps to a v2 section | +| C4 | Confirm the `KEEP IN SYNC` comment sits in both the guide §3 handler and `test/dexcord/migration_guide_samples_test.exs` | Present in both | +| C5 | Read for prose quality | Accurate, adoptable, no stale map-shaped examples | + +## End-to-End: Live Bot Round-Trip (optional; spans Phases 2/6/7) + +Validates decode → dispatch → cache → send against real Discord, which the fakes only approximate. + +1. Configure a real bot token + `allowed_mentions` default per guide §1; boot the app against a test guild. +2. On READY, call `Dexcord.Cache.guilds()` → expect the test guild present; after `GUILD_CREATE`, `Dexcord.Cache.channels(guild_id)` populated with typed channel structs (`%Dexcord.TextChannel{}` etc.). +3. From another account, post `ping!` in a visible channel → the bot replies as a threaded reply (`message_reference` set to your message). +4. `@mention` the bot → it replies. +5. Post as a webhook (or another bot) → the bot sends nothing (webhook/bot authors skipped). +6. `Dexcord.Api.message_history(channel_id) |> Enum.take(5)` in `iex` → exactly 5 `%Dexcord.Message{}` in descending id order, one page fetched. +7. Register a slash command, invoke it → handler receives a typed `%Dexcord.Interaction{}`; `Dexcord.Slash.respond/2` posts a visible reply. + +## Human Verification Required + +| Criterion | Why Manual | Steps | +|-----------|------------|-------| +| AC4.1 (hermeticity) | "green suite" ≠ "no egress" — a mis-stubbed fake could pass while calling out | Phase A | +| AC4.3 (CI runs) | A checked-in YAML can't assert the server executes it; Gitea Actions enablement is owner infra | Phase B | +| AC5.1 (guide completeness/prose) | Coverage-completeness and readability are editorial judgments | Phase C | + +## Traceability + +| Acceptance Criterion | Automated Test | Manual Step | +|----------------------|----------------|-------------| +| AC1.1–1.7 | endpoint_macro_test, endpoint_test, api_facade_test | E2E steps 2, 6 | +| AC1.8–1.10 | coverage_test, ratelimit_test | Phase B (B4 exercises the gate) | +| AC2.1–2.9 | struct_test, enum_test, flags_test, endpoint_test | — (pure) | +| AC2.10–2.17 | model_guild/message/channel/interaction/role_test, events_test | E2E step 2 | +| AC2.18–2.19 | gateway_integration_test | E2E steps 3–5 | +| AC2.20–2.22, AC3.8 | cache_test, cache_fill_test, cache_cascade_test | E2E step 2 | +| AC3.1–3.6 | send_test, messageable_test, ergonomics_helpers_test, model_message_parts_test | E2E steps 3–4 | +| AC3.7 | pagination_test | E2E step 6 | +| AC4.1 | full `mix test` | Phase A | +| AC4.2 | struct/enum/flags/endpoint test files (async categorization) | — | +| AC4.3 | coverage_test + `.gitea/workflows/ci.yml` | Phase B | +| AC5.1 | migration_guide_samples_test | Phase C | diff --git a/lib/dexcord.ex b/lib/dexcord.ex index bb97fbc..df31e9c 100644 --- a/lib/dexcord.ex +++ b/lib/dexcord.ex @@ -81,6 +81,8 @@ defmodule Dexcord do slash_guild_ids = Keyword.get(opts, :slash_guild_ids) validate_slash_guild_ids(slash_guild_ids) + allowed_mentions = validate_allowed_mentions(Keyword.get(opts, :allowed_mentions)) + %{ token: token, handler: handler, @@ -90,7 +92,8 @@ defmodule Dexcord do request_guild_members: request_guild_members, slash: slash, slash_guild_ids: slash_guild_ids, - gateway_url: gateway_url + gateway_url: gateway_url, + allowed_mentions: allowed_mentions } end @@ -153,6 +156,21 @@ defmodule Dexcord do "got: #{inspect(other)}" end + # Accepts a keyword, a map, or a `%Dexcord.AllowedMentions{}`, normalized to a + # string-keyed wire map (only explicitly-provided keys). Absent -> nil. + defp validate_allowed_mentions(nil), do: nil + + defp validate_allowed_mentions(spec) + when is_list(spec) or is_map(spec) do + Dexcord.AllowedMentions.normalize(spec) + end + + defp validate_allowed_mentions(other) do + raise ArgumentError, + "Dexcord :allowed_mentions must be a keyword list, map, or %Dexcord.AllowedMentions{}, " <> + "got: #{inspect(other)}" + end + defp boolean_opt(opts, key, default) do value = Keyword.get(opts, key, default) diff --git a/lib/dexcord/api.ex b/lib/dexcord/api.ex index 753c67f..99bbc46 100644 --- a/lib/dexcord/api.ex +++ b/lib/dexcord/api.ex @@ -375,6 +375,238 @@ defmodule Dexcord.Api do end end + # --- Ergonomic send funnel --------------------------------------------- + + @doc """ + Sends a message to anything `Dexcord.Messageable`: channels, threads, + messages (their channel), users/members (lazy DM), or a bare channel id. + + The target is resolved through `Dexcord.Messageable.resolve/1` — a + non-sendable value (a category/forum/media/directory channel) raises + `Protocol.UndefinedError` here, before any HTTP. A user/member target opens + (and caches) a DM channel on first use; see `Dexcord.Cache.dm_channel/1`. + + `body` follows `Dexcord.Api.Messages.create_message/2`: a binary (wrapped as + `content`), or a keyword/map/struct body. + """ + @spec send(Dexcord.Messageable.t(), term(), keyword()) :: + {:ok, Dexcord.Message.t()} | {:error, Error.t()} + def send(target, body, opts \\ []) do + body = apply_allowed_mentions_default(body) + + case Dexcord.Messageable.resolve(target) do + {:channel, channel_id} -> + Dexcord.Api.Messages.create_message(channel_id, body, opts) + + {:dm_user, user_id} -> + with {:ok, channel_id} <- dm_channel_id(user_id) do + Dexcord.Api.Messages.create_message(channel_id, body, opts) + end + end + end + + # Applies the configured `allowed_mentions` default (if any) with a field-wise + # merge under any per-send value (per-send wins). Only the `send`/`reply` + # ergonomics funnel does this — the raw `create_message` endpoint stays + # mechanical. The body is normalized to a string-keyed map here so the merge + # (and `create_message`'s own encode) both see the same shape. + # + # Skipped entirely when the config key is unset AND the body carries no + # allowed_mentions, so an absent field keeps Discord's own defaults. + defp apply_allowed_mentions_default(body) do + default = Dexcord.Config.get(:allowed_mentions) + normalized = Dexcord.Api.Endpoint.encode_body(body, %{binary_wrap: :content}) + + case normalized do + %{} = map -> + if is_nil(default) and not Map.has_key?(map, "allowed_mentions") do + map + else + Map.update(map, "allowed_mentions", default, fn per_send -> + Dexcord.AllowedMentions.merge(default, Dexcord.AllowedMentions.normalize(per_send)) + end) + end + + other -> + other + end + end + + # Resolve a user id to a DM channel id, opening (and caching) the DM on a miss. + defp dm_channel_id(user_id) do + case Dexcord.Cache.dm_channel(user_id) do + {:ok, channel_id} -> + {:ok, channel_id} + + :error -> + with {:ok, %Dexcord.DMChannel{id: id}} <- Dexcord.Api.Users.create_dm(user_id) do + Dexcord.Cache.put_dm_channel(user_id, id) + {:ok, id} + end + end + end + + # --- Lazy pagination streams ------------------------------------------- + # + # Each returns a lazy `Stream` over `Dexcord.Api.Paginate`: a page is fetched + # only when the consumer walks that far, so `Stream.take/2` on a fresh stream + # makes exactly one wire hit. A page-fetch `{:error, _}` raises + # `Dexcord.Api.Paginate.PageError` mid-stream (streams cannot carry a tagged + # tuple), which the caller can `rescue`. + + @history_page_size 100 + @members_page_size 1000 + @bans_page_size 1000 + @audit_log_page_size 100 + + @doc """ + Streams a channel's message history, resolving `messageable` through + `Dexcord.Messageable`. + + By default it pages newest→oldest using the `before:` anchor (cursor = the + last message's id). Passing `after: id` flips to oldest→newest paging with the + `after:` anchor (mirroring discord.py's `oldest_first`, which defaults true iff + `after` is given). `limit: n` caps the total number of messages yielded. + + The stream is lazy — pages are fetched on demand — and raises + `Dexcord.Api.Paginate.PageError` if a page request fails. + """ + @spec message_history(Dexcord.Messageable.t(), keyword()) :: Enumerable.t() + def message_history(messageable, opts \\ []) do + {:channel, channel_id} = Dexcord.Messageable.resolve(messageable) + + stream = + case Keyword.fetch(opts, :after) do + {:ok, after_id} -> + Dexcord.Api.Paginate.stream( + after_id, + @history_page_size, + fn cursor -> + Dexcord.Api.Messages.get_channel_messages(channel_id, + limit: @history_page_size, + after: cursor + ) + end, + fn last -> last.id end + ) + + :error -> + Dexcord.Api.Paginate.stream( + nil, + @history_page_size, + fn + nil -> + Dexcord.Api.Messages.get_channel_messages(channel_id, limit: @history_page_size) + + cursor -> + Dexcord.Api.Messages.get_channel_messages(channel_id, + limit: @history_page_size, + before: cursor + ) + end, + fn last -> last.id end + ) + end + + maybe_take(stream, Keyword.get(opts, :limit)) + end + + @doc """ + Streams a guild's members ascending, paging with the `after:` anchor (cursor = + the last member's user id), 1000 per page. `limit: n` caps the total. + Lazy; raises `Dexcord.Api.Paginate.PageError` on a failed page. + """ + @spec guild_members_stream(Dexcord.Guild.t() | Dexcord.Snowflake.t(), keyword()) :: + Enumerable.t() + def guild_members_stream(guild_or_id, opts \\ []) do + guild_id = resolve_guild_id(guild_or_id) + after0 = Keyword.get(opts, :after, 0) + + stream = + Dexcord.Api.Paginate.stream( + after0, + @members_page_size, + fn cursor -> + Dexcord.Api.Members.list_guild_members(guild_id, + limit: @members_page_size, + after: cursor + ) + end, + fn last -> member_user_id(last) end + ) + + maybe_take(stream, Keyword.get(opts, :limit)) + end + + @doc """ + Streams a guild's bans ascending, paging with the `after:` anchor (cursor = + the last ban's user id), 1000 per page. `limit: n` caps the total. + Lazy; raises `Dexcord.Api.Paginate.PageError` on a failed page. + """ + @spec guild_bans_stream(Dexcord.Guild.t() | Dexcord.Snowflake.t(), keyword()) :: Enumerable.t() + def guild_bans_stream(guild_or_id, opts \\ []) do + guild_id = resolve_guild_id(guild_or_id) + after0 = Keyword.get(opts, :after, 0) + + stream = + Dexcord.Api.Paginate.stream( + after0, + @bans_page_size, + fn cursor -> + Dexcord.Api.Members.get_guild_bans(guild_id, limit: @bans_page_size, after: cursor) + end, + fn last -> last.user.id end + ) + + maybe_take(stream, Keyword.get(opts, :limit)) + end + + @doc """ + Streams a guild's audit-log entries descending, paging with the `before:` + anchor (cursor = the last entry's id), 100 per page. + + The `get_guild_audit_log` endpoint returns a `%Dexcord.AuditLog{}` container; + this stream yields the `audit_log_entries` out of it (the referenced + users/webhooks/etc. on the container are not threaded through). `limit: n` + caps the total. Lazy; raises `Dexcord.Api.Paginate.PageError` on a failed page. + """ + @spec audit_log_stream(Dexcord.Guild.t() | Dexcord.Snowflake.t(), keyword()) :: Enumerable.t() + def audit_log_stream(guild_or_id, opts \\ []) do + guild_id = resolve_guild_id(guild_or_id) + + stream = + Dexcord.Api.Paginate.stream( + nil, + @audit_log_page_size, + fn + nil -> + with {:ok, %Dexcord.AuditLog{} = al} <- + Dexcord.Api.Guilds.get_guild_audit_log(guild_id, limit: @audit_log_page_size), + do: {:ok, al.audit_log_entries} + + cursor -> + with {:ok, %Dexcord.AuditLog{} = al} <- + Dexcord.Api.Guilds.get_guild_audit_log(guild_id, + limit: @audit_log_page_size, + before: cursor + ), + do: {:ok, al.audit_log_entries} + end, + fn last -> last.id end + ) + + maybe_take(stream, Keyword.get(opts, :limit)) + end + + defp maybe_take(stream, nil), do: stream + defp maybe_take(stream, limit) when is_integer(limit), do: Stream.take(stream, limit) + + defp resolve_guild_id(%{id: id}) when is_integer(id), do: id + defp resolve_guild_id(id) when is_integer(id), do: id + defp resolve_guild_id(other), do: Dexcord.Snowflake.cast!(other) + + defp member_user_id(%{user_id: user_id, user: user}), do: user_id || (user && user.id) + # --- Generated endpoint facade ----------------------------------------- # # The typed endpoint surface lives in the group modules below (declared with diff --git a/lib/dexcord/api/paginate.ex b/lib/dexcord/api/paginate.ex new file mode 100644 index 0000000..29f28c6 --- /dev/null +++ b/lib/dexcord/api/paginate.ex @@ -0,0 +1,54 @@ +defmodule Dexcord.Api.Paginate do + @moduledoc false + # Lazy cursor pagination over Discord's list endpoints. Each page is fetched + # only when the stream is consumed that far (so `Stream.take/2` on a fresh + # stream makes exactly one wire hit). A page shorter than the page size ends + # the stream; an `{:error, _}` from the fetcher raises `PageError` — a stream + # cannot carry a tagged-tuple failure mid-flow, so the error surfaces as a + # raise the caller can `rescue`. + + defmodule PageError do + @moduledoc """ + Raised when a page fetch inside a pagination stream returns + `{:error, _}`. The underlying error is on the `:error` field. + """ + defexception [:error] + + @impl true + def message(%{error: e}), do: "pagination request failed: #{inspect(e)}" + end + + @doc false + # `fetch_page.(cursor)` -> `{:ok, items}` | `{:error, e}`; + # `next.(last_item)` -> the cursor for the following page. + # + # `initial_cursor` seeds the first fetch (may be `nil` for an anchorless first + # page). The stream halts on the first page whose length is < `page_size`. + @spec stream(term(), pos_integer(), (term() -> {:ok, list()} | {:error, term()}), (term() -> + term())) :: + Enumerable.t() + def stream(initial_cursor, page_size, fetch_page, next) + when is_integer(page_size) and page_size > 0 and is_function(fetch_page, 1) and + is_function(next, 1) do + Stream.resource( + fn -> {initial_cursor, :go} end, + fn + {_cursor, :halt} -> + {:halt, nil} + + {cursor, :go} -> + case fetch_page.(cursor) do + {:ok, items} when length(items) < page_size -> + {items, {cursor, :halt}} + + {:ok, items} -> + {items, {next.(List.last(items)), :go}} + + {:error, error} -> + raise PageError, error: error + end + end, + fn _ -> :ok end + ) + end +end diff --git a/lib/dexcord/cache.ex b/lib/dexcord/cache.ex index e0ad29d..fc40524 100644 --- a/lib/dexcord/cache.ex +++ b/lib/dexcord/cache.ex @@ -21,6 +21,17 @@ defmodule Dexcord.Cache do is always the source of truth; the cache is best-effort, and can briefly lag or hold a duplicate after a resume gap. + ### The one sanctioned writer exception: DM channels + + `:dexcord_dm_channels` is the single table written from OUTSIDE the Dispatcher — + the `Dexcord.Api.send/2` funnel calls `put_dm_channel/2` after lazily opening a + DM (`Dexcord.Cache.dm_channel/1` misses → `POST /users/@me/channels` → cache the + id). This breaks the single-writer rule on purpose, and it is safe: DM channel + ids are stable and idempotent (Discord returns the SAME channel for a given + recipient), so two processes racing to open a DM for the same user both compute + and store the same id — a harmless duplicate write, never a conflicting one. The + table is `:set`/`:public` like the rest, so reads stay lock-free. + ## Tables | Table | Type | Key | @@ -59,6 +70,10 @@ defmodule Dexcord.Cache do @roles :dexcord_roles @presences :dexcord_presences @voice_states :dexcord_voice_states + # Written by the `Dexcord.Api.send/2` funnel, not the Dispatcher — the one + # sanctioned exception to single-writer (see moduledoc). Key: user_id -> DM + # channel id. + @dm_channels :dexcord_dm_channels # Guild child collections lifted out of the guild row into their own tables. # `emojis` (and stickers) stay inline on the guild and are replaced wholesale. @@ -85,6 +100,7 @@ defmodule Dexcord.Cache do :ets.new(@roles, oset) :ets.new(@presences, oset) :ets.new(@voice_states, oset) + :ets.new(@dm_channels, set) {:ok, %{}} end @@ -566,6 +582,70 @@ defmodule Dexcord.Cache do @spec voice_states(id()) :: [entity()] def voice_states(guild_id), do: prefix_values(@voice_states, guild_id) + # --- DM channel cache (written by the send funnel, see moduledoc) -------- + + @doc """ + The cached DM channel id for a user, if one has been opened this session. + + Populated lazily by `Dexcord.Api.send/2` the first time it DMs a user; a miss + is `:error` (the funnel then opens the DM and caches the result). + """ + @spec dm_channel(id()) :: {:ok, Dexcord.Snowflake.t()} | :error + def dm_channel(user_id) do + with {:ok, uid} <- Dexcord.Snowflake.cast(user_id), do: fetch(@dm_channels, uid) + end + + @doc false + # Written by the `Dexcord.Api.send/2` funnel — the sole sanctioned non-Dispatcher + # writer (see moduledoc). Idempotent: DM channel ids are stable per recipient, so + # racing writers store the same value. + @spec put_dm_channel(id(), Dexcord.Snowflake.t()) :: :ok + def put_dm_channel(user_id, channel_id) do + with {:ok, uid} <- Dexcord.Snowflake.cast(user_id) do + :ets.insert(@dm_channels, {uid, channel_id}) + end + + :ok + end + + # --- hydration ---------------------------------------------------------- + + @doc """ + Best-effort hydration of an envelope struct's declared `hydrate` slots from + the cache. Each slot is filled from ETS by the id in its `from` field; a cache + miss (or a nil source id) leaves that slot `nil`. A struct with no `hydrate` + declarations is returned unchanged. + + This function reads ETS **only** - it never issues HTTP and never blocks on the + network, so it is safe to call from any process on the hot path + (api-surface.AC3.8). It is meant to be called by **user handler code** when a + handler wants the related objects inline; the `Dexcord.Dispatcher` never calls + it (hydration is opt-in, not a cost paid on every event). + + Already-populated slots are left alone, so `fill/1` is idempotent. + """ + @spec fill(struct()) :: struct() + def fill(%module{} = event) do + if function_exported?(module, :__hydrations__, 0) do + Enum.reduce(module.__hydrations__(), event, fn h, acc -> + with nil <- Map.fetch!(acc, h.name), + id when not is_nil(id) <- Map.fetch!(acc, h.from), + {:ok, value} <- fill_lookup(h.type, id) do + Map.put(acc, h.name, value) + else + _ -> acc + end + end) + else + event + end + end + + defp fill_lookup(Dexcord.User, id), do: user(id) + defp fill_lookup(:channel, id), do: channel(id) + defp fill_lookup(Dexcord.Guild, id), do: guild(id) + defp fill_lookup(_type, _id), do: :error + # --- read helpers ------------------------------------------------------- defp cache_presences?(config), do: Map.get(config, :cache_presences, false) diff --git a/lib/dexcord/dispatcher.ex b/lib/dexcord/dispatcher.ex index 7e727fe..c352113 100644 --- a/lib/dexcord/dispatcher.ex +++ b/lib/dexcord/dispatcher.ex @@ -84,10 +84,10 @@ defmodule Dexcord.Dispatcher do # handler. # # If decode FELL BACK to the raw map (malformed interaction), the struct gate - # can't fire; we degrade to the old integer `"type"` gate so a well-typed-enough - # raw interaction still reaches the slash layer. The RAW map is passed to - # `Slash.dispatch/2` in this task (Task 4 flips the typed branch to pass the - # decoded struct). + # can't fire; we degrade to the old integer `"type"` gate and hand the RAW map to + # `Slash.dispatch/2`'s documented degraded head so a not-quite-decodable + # interaction still reaches the slash layer. A cleanly decoded interaction routes + # the typed `%Dexcord.Interaction{}` struct. defp maybe_route_slash(:INTERACTION_CREATE, decoded, raw, config) do slash_mod = Map.get(config, :slash) @@ -97,7 +97,7 @@ defmodule Dexcord.Dispatcher do match?(%Dexcord.Interaction{}, decoded) and decoded.type in [:application_command, :message_component, :modal_submit] -> - route_slash(raw, slash_mod) + route_slash(decoded, slash_mod) is_map(raw) and raw["type"] in [2, 3, 5] -> route_slash(raw, slash_mod) diff --git a/lib/dexcord/messageable.ex b/lib/dexcord/messageable.ex new file mode 100644 index 0000000..dcbe82e --- /dev/null +++ b/lib/dexcord/messageable.ex @@ -0,0 +1,45 @@ +defprotocol Dexcord.Messageable do + @moduledoc """ + Anything a message can be sent to. `Dexcord.Api.send/2` resolves its target + through this protocol. Category/forum/media/directory channels deliberately + do NOT implement it — sending to one fails with `Protocol.UndefinedError` + at resolve time, before any HTTP. + """ + + @spec resolve(t) :: {:channel, Dexcord.Snowflake.t()} | {:dm_user, Dexcord.Snowflake.t()} + def resolve(target) +end + +defimpl Dexcord.Messageable, + for: [ + Dexcord.TextChannel, + Dexcord.AnnouncementChannel, + Dexcord.VoiceChannel, + Dexcord.StageChannel, + Dexcord.Thread, + Dexcord.DMChannel, + Dexcord.GroupDMChannel + ] do + def resolve(%{id: id}), do: {:channel, id} +end + +defimpl Dexcord.Messageable, for: Dexcord.Message do + def resolve(%{channel_id: id}), do: {:channel, id} +end + +defimpl Dexcord.Messageable, for: Dexcord.Interaction do + def resolve(%{channel_id: id}) when not is_nil(id), do: {:channel, id} +end + +defimpl Dexcord.Messageable, for: Dexcord.User do + def resolve(%{id: id}), do: {:dm_user, id} +end + +defimpl Dexcord.Messageable, for: Dexcord.Member do + def resolve(%{user: %Dexcord.User{id: id}}), do: {:dm_user, id} + def resolve(%{user_id: id}) when not is_nil(id), do: {:dm_user, id} +end + +defimpl Dexcord.Messageable, for: Integer do + def resolve(id) when id >= 0, do: {:channel, id} +end diff --git a/lib/dexcord/model/allowed_mentions.ex b/lib/dexcord/model/allowed_mentions.ex index cc880b8..43dbc42 100644 --- a/lib/dexcord/model/allowed_mentions.ex +++ b/lib/dexcord/model/allowed_mentions.ex @@ -8,4 +8,27 @@ defmodule Dexcord.AllowedMentions do field :users, {:list, :snowflake} field :replied_user, :boolean, default: false end + + @doc false + # Normalizes any allowed-mentions spec to a string-keyed wire map carrying only + # explicitly-provided keys (an absent key means "not set" for merge/2). A struct + # goes through to_map/1, so every non-nil field is explicit. + @spec normalize(nil | struct() | keyword() | map()) :: map() | nil + def normalize(nil), do: nil + def normalize(%{__struct__: __MODULE__} = am), do: to_map(am) + def normalize(kw) when is_list(kw), do: Map.new(kw, fn {k, v} -> {to_string(k), v} end) + def normalize(map) when is_map(map), do: Map.new(map, fn {k, v} -> {to_string(k), v} end) + + @doc """ + Field-wise merge of two normalized allowed-mentions maps: keys present in + `per_send` win; `default` fills the rest (discord.py's documented contract). + + Callers own category-exclusivity — like discord.py, this happily builds + combinations Discord would reject (e.g. `parse: ["users"]` alongside an explicit + `users:` list); it does not validate that. + """ + @spec merge(map() | nil, map() | nil) :: map() | nil + def merge(nil, per_send), do: per_send + def merge(default, nil), do: default + def merge(default, per_send), do: Map.merge(default, per_send) end diff --git a/lib/dexcord/model/channel.ex b/lib/dexcord/model/channel.ex index 342395f..fd67be4 100644 --- a/lib/dexcord/model/channel.ex +++ b/lib/dexcord/model/channel.ex @@ -30,6 +30,10 @@ defmodule Dexcord.TextChannel do field :default_auto_archive_duration, :integer field :default_thread_rate_limit_per_user, :integer end + + @doc "The creation `DateTime` encoded in this channel's id, or `:error`." + @spec created_at(t()) :: {:ok, DateTime.t()} | :error + def created_at(%{id: id}), do: Dexcord.Snowflake.to_datetime(id) end defmodule Dexcord.AnnouncementChannel do @@ -45,6 +49,10 @@ defmodule Dexcord.AnnouncementChannel do field :default_auto_archive_duration, :integer field :default_thread_rate_limit_per_user, :integer end + + @doc "The creation `DateTime` encoded in this channel's id, or `:error`." + @spec created_at(t()) :: {:ok, DateTime.t()} | :error + def created_at(%{id: id}), do: Dexcord.Snowflake.to_datetime(id) end defmodule Dexcord.VoiceChannel do @@ -60,6 +68,10 @@ defmodule Dexcord.VoiceChannel do field :last_message_id, :snowflake field :rate_limit_per_user, :integer end + + @doc "The creation `DateTime` encoded in this channel's id, or `:error`." + @spec created_at(t()) :: {:ok, DateTime.t()} | :error + def created_at(%{id: id}), do: Dexcord.Snowflake.to_datetime(id) end defmodule Dexcord.StageChannel do @@ -75,6 +87,10 @@ defmodule Dexcord.StageChannel do field :last_message_id, :snowflake field :rate_limit_per_user, :integer end + + @doc "The creation `DateTime` encoded in this channel's id, or `:error`." + @spec created_at(t()) :: {:ok, DateTime.t()} | :error + def created_at(%{id: id}), do: Dexcord.Snowflake.to_datetime(id) end defmodule Dexcord.CategoryChannel do @@ -84,6 +100,10 @@ defmodule Dexcord.CategoryChannel do discord_struct do include_fields Dexcord.Model.ChannelShared end + + @doc "The creation `DateTime` encoded in this channel's id, or `:error`." + @spec created_at(t()) :: {:ok, DateTime.t()} | :error + def created_at(%{id: id}), do: Dexcord.Snowflake.to_datetime(id) end defmodule Dexcord.DirectoryChannel do @@ -93,6 +113,10 @@ defmodule Dexcord.DirectoryChannel do discord_struct do include_fields Dexcord.Model.ChannelShared end + + @doc "The creation `DateTime` encoded in this channel's id, or `:error`." + @spec created_at(t()) :: {:ok, DateTime.t()} | :error + def created_at(%{id: id}), do: Dexcord.Snowflake.to_datetime(id) end defmodule Dexcord.ForumChannel do @@ -111,6 +135,10 @@ defmodule Dexcord.ForumChannel do field :default_sort_order, {:enum, Dexcord.SortOrderType} field :default_forum_layout, {:enum, Dexcord.ForumLayoutType} end + + @doc "The creation `DateTime` encoded in this channel's id, or `:error`." + @spec created_at(t()) :: {:ok, DateTime.t()} | :error + def created_at(%{id: id}), do: Dexcord.Snowflake.to_datetime(id) end defmodule Dexcord.MediaChannel do @@ -128,6 +156,10 @@ defmodule Dexcord.MediaChannel do field :default_reaction_emoji, {:struct, Dexcord.DefaultReaction} field :default_sort_order, {:enum, Dexcord.SortOrderType} end + + @doc "The creation `DateTime` encoded in this channel's id, or `:error`." + @spec created_at(t()) :: {:ok, DateTime.t()} | :error + def created_at(%{id: id}), do: Dexcord.Snowflake.to_datetime(id) end defmodule Dexcord.DMChannel do @@ -142,6 +174,10 @@ defmodule Dexcord.DMChannel do field :recipients, {:list, {:struct, Dexcord.User}}, default: [] field :last_pin_timestamp, :datetime end + + @doc "The creation `DateTime` encoded in this channel's id, or `:error`." + @spec created_at(t()) :: {:ok, DateTime.t()} | :error + def created_at(%{id: id}), do: Dexcord.Snowflake.to_datetime(id) end defmodule Dexcord.GroupDMChannel do @@ -161,6 +197,10 @@ defmodule Dexcord.GroupDMChannel do field :application_id, :snowflake field :managed, :boolean end + + @doc "The creation `DateTime` encoded in this channel's id, or `:error`." + @spec created_at(t()) :: {:ok, DateTime.t()} | :error + def created_at(%{id: id}), do: Dexcord.Snowflake.to_datetime(id) end defmodule Dexcord.Thread do @@ -186,6 +226,10 @@ defmodule Dexcord.Thread do field :last_pin_timestamp, :datetime field :newly_created, :boolean, default: false end + + @doc "The creation `DateTime` encoded in this thread's id, or `:error`." + @spec created_at(t()) :: {:ok, DateTime.t()} | :error + def created_at(%{id: id}), do: Dexcord.Snowflake.to_datetime(id) end defmodule Dexcord.UnknownChannel do @@ -254,6 +298,32 @@ defmodule Dexcord.Channel do def from_map(map) when is_map(map), do: Dexcord.UnknownChannel.from_map(map) def from_map(_), do: nil + @doc """ + The mention string (`<#id>`) for any channel struct with an integer `id` — + guild channels, threads, and DM/group-DM channels alike. + """ + @spec mention(%{id: Dexcord.Snowflake.t()}) :: String.t() + def mention(%{id: id}) when is_integer(id), do: "<##{id}>" + @doc false def __type_map__, do: @type_map end + +# `String.Chars` for every channel struct: interpolating a channel produces a +# real `<#id>` mention (delegating to `Dexcord.Channel.mention/1`). +defimpl String.Chars, + for: [ + Dexcord.TextChannel, + Dexcord.AnnouncementChannel, + Dexcord.VoiceChannel, + Dexcord.StageChannel, + Dexcord.CategoryChannel, + Dexcord.DirectoryChannel, + Dexcord.ForumChannel, + Dexcord.MediaChannel, + Dexcord.DMChannel, + Dexcord.GroupDMChannel, + Dexcord.Thread + ] do + def to_string(channel), do: Dexcord.Channel.mention(channel) +end diff --git a/lib/dexcord/model/embed.ex b/lib/dexcord/model/embed.ex index 447107a..57710fd 100644 --- a/lib/dexcord/model/embed.ex +++ b/lib/dexcord/model/embed.ex @@ -1,25 +1,3 @@ -defmodule Dexcord.Embed do - @moduledoc "A message embed. https://docs.discord.com/developers/resources/message" - use Dexcord.Struct - - discord_struct do - field :title, :string - field :type, :string - field :description, :string - field :url, :string - field :timestamp, :datetime - field :color, :integer - field :footer, {:struct, Dexcord.EmbedFooter} - field :image, {:struct, Dexcord.EmbedImage} - field :thumbnail, {:struct, Dexcord.EmbedThumbnail} - field :video, {:struct, Dexcord.EmbedVideo} - field :provider, {:struct, Dexcord.EmbedProvider} - field :author, {:struct, Dexcord.EmbedAuthor} - field :fields, {:list, {:struct, Dexcord.EmbedField}}, default: [] - field :flags, :integer - end -end - defmodule Dexcord.EmbedFooter do @moduledoc false use Dexcord.Struct @@ -114,3 +92,67 @@ defmodule Dexcord.EmbedField do field :inline, :boolean, default: false end end + +defmodule Dexcord.Embed do + @moduledoc "A message embed. https://docs.discord.com/developers/resources/message" + use Dexcord.Struct + + discord_struct do + field :title, :string + field :type, :string + field :description, :string + field :url, :string + field :timestamp, :datetime + field :color, :integer + field :footer, {:struct, Dexcord.EmbedFooter} + field :image, {:struct, Dexcord.EmbedImage} + field :thumbnail, {:struct, Dexcord.EmbedThumbnail} + field :video, {:struct, Dexcord.EmbedVideo} + field :provider, {:struct, Dexcord.EmbedProvider} + field :author, {:struct, Dexcord.EmbedAuthor} + field :fields, {:list, {:struct, Dexcord.EmbedField}}, default: [] + field :flags, :integer + end + + @doc "A new embed. `opts` seed struct fields directly (e.g. `title:`, `color:`)." + @spec new(keyword()) :: t() + def new(opts \\ []), do: struct!(__MODULE__, opts) + + @doc "Sets the embed title." + def title(embed, title), do: %{embed | title: title} + + @doc "Sets the embed description." + def description(embed, description), do: %{embed | description: description} + + @doc "Sets the embed url." + def url(embed, url), do: %{embed | url: url} + + @doc "Sets the embed color (an integer)." + def color(embed, color) when is_integer(color), do: %{embed | color: color} + + @doc "Sets the embed timestamp." + def timestamp(embed, %DateTime{} = dt), do: %{embed | timestamp: dt} + + @doc "Appends a field. `inline:` defaults to `false`." + def field(embed, name, value, opts \\ []) do + f = %Dexcord.EmbedField{name: name, value: value, inline: Keyword.get(opts, :inline, false)} + %{embed | fields: embed.fields ++ [f]} + end + + @doc "Sets the embed footer. `icon_url:` optional." + def footer(embed, text, opts \\ []), + do: %{embed | footer: %Dexcord.EmbedFooter{text: text, icon_url: opts[:icon_url]}} + + @doc "Sets the embed image by url." + def image(embed, url), do: %{embed | image: %Dexcord.EmbedImage{url: url}} + + @doc "Sets the embed thumbnail by url." + def thumbnail(embed, url), do: %{embed | thumbnail: %Dexcord.EmbedThumbnail{url: url}} + + @doc "Sets the embed author. `url:`/`icon_url:` optional." + def author(embed, name, opts \\ []), + do: %{ + embed + | author: %Dexcord.EmbedAuthor{name: name, url: opts[:url], icon_url: opts[:icon_url]} + } +end diff --git a/lib/dexcord/model/emoji.ex b/lib/dexcord/model/emoji.ex index f37fed6..e2d1a02 100644 --- a/lib/dexcord/model/emoji.ex +++ b/lib/dexcord/model/emoji.ex @@ -12,6 +12,10 @@ defmodule Dexcord.Emoji do field :animated, :boolean, default: false field :available, :boolean end + + @doc "The creation `DateTime` encoded in this emoji's id, or `:error` (unicode/nil id)." + @spec created_at(t()) :: {:ok, DateTime.t()} | :error + def created_at(%{id: id}), do: Dexcord.Snowflake.to_datetime(id) end defmodule Dexcord.PartialEmoji do @@ -22,9 +26,27 @@ defmodule Dexcord.PartialEmoji do """ use Dexcord.Struct + # `to_string/1` below shadows the auto-imported `Kernel.to_string/1`. + import Kernel, except: [to_string: 1] + discord_struct do field :id, :snowflake field :name, :string field :animated, :boolean, default: false end + + @doc """ + The send-format string for this emoji. + + Custom static: `<:name:id>`; animated: ``; unicode (nil id): the + raw `name` character. + """ + @spec to_string(t()) :: String.t() + def to_string(%{id: nil, name: name}), do: name + def to_string(%{id: id, name: name, animated: true}), do: "" + def to_string(%{id: id, name: name}), do: "<:#{name}:#{id}>" +end + +defimpl String.Chars, for: Dexcord.PartialEmoji do + def to_string(emoji), do: Dexcord.PartialEmoji.to_string(emoji) end diff --git a/lib/dexcord/model/guild.ex b/lib/dexcord/model/guild.ex index 4998b80..f91be66 100644 --- a/lib/dexcord/model/guild.ex +++ b/lib/dexcord/model/guild.ex @@ -57,6 +57,136 @@ defmodule Dexcord.Guild do field :guild_scheduled_events, {:list, {:struct, Dexcord.GuildScheduledEvent}}, default: [] field :soundboard_sounds, {:list, :raw}, default: [] end + + @doc "The creation `DateTime` encoded in this guild's id, or `:error`." + @spec created_at(t()) :: {:ok, DateTime.t()} | :error + def created_at(%{id: id}), do: Dexcord.Snowflake.to_datetime(id) + + import Bitwise + + @all_permissions Dexcord.Permissions.all() |> Map.values() |> Enum.reduce(0, &Bitwise.bor/2) + @timeout_allowed Bitwise.bor( + Dexcord.Permissions.all()[:view_channel], + Dexcord.Permissions.all()[:read_message_history] + ) + + @doc """ + Computes a member's effective permissions in the guild (arity 2) or in a + specific channel (arity 3, applying permission overwrites and the timeout + rule). + + `guild.roles` must be populated — when reading from the cache, set them first: + `%{guild | roles: Dexcord.Cache.roles(guild.id)}`. Returns the raw permissions + integer; combine with `Dexcord.Permissions.has?/2`. + + Implements Discord's documented algorithm: owner → all; base = the @everyone + role (`role id == guild id`) OR'd with each member role; `ADMINISTRATOR` → all, + skipping overwrites entirely; otherwise channel overwrites in order — + @everyone (deny then allow), aggregated role overwrites (all denies OR'd, all + allows OR'd, deny before allow), then the member overwrite; finally the timeout + rule (a member whose `communication_disabled_until` is in the future keeps only + `VIEW_CHANNEL | READ_MESSAGE_HISTORY`, unless owner/administrator — a null or + past value is NOT a timeout). + """ + @spec member_permissions(t(), Dexcord.Member.t(), term()) :: non_neg_integer() + def member_permissions(guild, member, channel \\ nil) + + def member_permissions(%{} = guild, member, channel) do + user_id = member_user_id(member) + + cond do + guild.owner_id == user_id -> + @all_permissions + + true -> + base = base_permissions(guild, member) + + if has_flag?(base, :administrator) do + @all_permissions + else + base + |> apply_channel_overwrites(guild, member, user_id, channel) + |> apply_timeout(member) + end + end + end + + defp member_user_id(member), do: member.user_id || (member.user && member.user.id) + + defp has_flag?(perms, flag), do: Dexcord.Permissions.has?(perms, flag) + + # base = @everyone role perms OR'd with each member role's perms (unknown role + # ids contribute nothing). + defp base_permissions(guild, member) do + Enum.reduce(member.roles, role_permissions(guild, guild.id), fn role_id, acc -> + bor(acc, role_permissions(guild, role_id)) + end) + end + + defp role_permissions(guild, role_id) do + case Enum.find(guild.roles, fn role -> role.id == role_id end) do + nil -> 0 + role -> role.permissions || 0 + end + end + + defp apply_channel_overwrites(perms, _guild, _member, _user_id, nil), do: perms + + defp apply_channel_overwrites(perms, guild, member, user_id, channel) do + overwrites = channel.permission_overwrites || [] + + perms + |> apply_everyone_overwrite(overwrites, guild.id) + |> apply_role_overwrites(overwrites, member.roles) + |> apply_member_overwrite(overwrites, user_id) + end + + defp apply_everyone_overwrite(perms, overwrites, guild_id) do + case Enum.find(overwrites, fn ow -> ow.id == guild_id end) do + nil -> perms + ow -> apply_deny_allow(perms, ow.deny, ow.allow) + end + end + + # All role overwrites for the member's roles are aggregated: denies OR'd, allows + # OR'd, then applied deny-before-allow as a single tier. + defp apply_role_overwrites(perms, overwrites, member_roles) do + {deny, allow} = + overwrites + |> Enum.filter(fn ow -> ow.id in member_roles end) + |> Enum.reduce({0, 0}, fn ow, {deny, allow} -> + {bor(deny, ow.deny || 0), bor(allow, ow.allow || 0)} + end) + + apply_deny_allow(perms, deny, allow) + end + + defp apply_member_overwrite(perms, overwrites, user_id) do + case Enum.find(overwrites, fn ow -> ow.id == user_id end) do + nil -> perms + ow -> apply_deny_allow(perms, ow.deny, ow.allow) + end + end + + defp apply_deny_allow(perms, deny, allow) do + perms + |> band(bnot(deny || 0)) + |> bor(allow || 0) + end + + defp apply_timeout(perms, member) do + case member.communication_disabled_until do + %DateTime{} = cdu -> + if DateTime.compare(cdu, DateTime.utc_now()) == :gt do + band(perms, @timeout_allowed) + else + perms + end + + _ -> + perms + end + end end defmodule Dexcord.UnavailableGuild do diff --git a/lib/dexcord/model/member.ex b/lib/dexcord/model/member.ex index 95f2e2b..d9b2245 100644 --- a/lib/dexcord/model/member.ex +++ b/lib/dexcord/model/member.ex @@ -34,6 +34,23 @@ defmodule Dexcord.Member do field :user, {:struct, Dexcord.User} include_fields Dexcord.Model.MemberShared end + + @doc "The mention string for this member (`<@id>`), from `user_id` or the nested user." + @spec mention(t()) :: String.t() + def mention(%{} = member), do: "<@#{member_user_id(member)}>" + + @doc "The creation `DateTime` encoded in this member's user id, or `:error`." + @spec created_at(t()) :: {:ok, DateTime.t()} | :error + def created_at(%{} = member), + do: Dexcord.Snowflake.to_datetime(member_user_id(member)) + + defp member_user_id(%{user_id: user_id, user: user}) do + user_id || (user && user.id) + end +end + +defimpl String.Chars, for: Dexcord.Member do + def to_string(member), do: Dexcord.Member.mention(member) end defmodule Dexcord.PartialMember do diff --git a/lib/dexcord/model/message.ex b/lib/dexcord/model/message.ex index 6f21cf6..80145c9 100644 --- a/lib/dexcord/model/message.ex +++ b/lib/dexcord/model/message.ex @@ -44,6 +44,47 @@ defmodule Dexcord.Message do field :call, {:struct, Dexcord.MessageCall} field :shared_client_theme, :raw end + + @doc "The creation `DateTime` encoded in this message's id, or `:error`." + @spec created_at(t()) :: {:ok, DateTime.t()} | :error + def created_at(%{id: id}), do: Dexcord.Snowflake.to_datetime(id) + + @doc """ + Replies to this message. Sets `message_reference` to the source message and + routes through `Dexcord.Api.send/2` (so the allowed-mentions config default + merge applies). + + `mention_author: true | false` overrides `allowed_mentions.replied_user` + (mirroring discord.py) — it is applied AFTER the body is normalized, so it + wins over any `replied_user` a caller passed in the body. Everything else + behaves like `Dexcord.Api.send/3`. + """ + @spec reply(t(), term(), keyword()) :: + {:ok, t()} | {:error, Dexcord.Api.Error.t()} + def reply(msg, body, opts \\ []) + + def reply(%{id: id, channel_id: channel_id}, body, opts) do + {mention_author, opts} = Keyword.pop(opts, :mention_author) + + body = + body + |> Dexcord.Api.Endpoint.encode_body(%{binary_wrap: :content}) + |> Map.put("message_reference", %{"message_id" => Dexcord.Snowflake.dump(id)}) + |> apply_mention_author(mention_author) + + Dexcord.Api.send(channel_id, body, opts) + end + + defp apply_mention_author(body, nil), do: body + + defp apply_mention_author(body, flag) when is_boolean(flag) do + Map.update( + body, + "allowed_mentions", + %{"replied_user" => flag}, + &Map.put(&1, "replied_user", flag) + ) + end end defmodule Dexcord.MessageReference do diff --git a/lib/dexcord/model/role.ex b/lib/dexcord/model/role.ex index 5f781ea..3ff2779 100644 --- a/lib/dexcord/model/role.ex +++ b/lib/dexcord/model/role.ex @@ -17,6 +17,18 @@ defmodule Dexcord.Role do field :tags, {:struct, Dexcord.RoleTags} field :flags, {:flags, Dexcord.RoleFlags} end + + @doc "The mention string for this role (`<@&id>`)." + @spec mention(t()) :: String.t() + def mention(%{id: id}), do: "<@&#{id}>" + + @doc "The creation `DateTime` encoded in this role's id, or `:error`." + @spec created_at(t()) :: {:ok, DateTime.t()} | :error + def created_at(%{id: id}), do: Dexcord.Snowflake.to_datetime(id) +end + +defimpl String.Chars, for: Dexcord.Role do + def to_string(role), do: Dexcord.Role.mention(role) end defmodule Dexcord.RoleColors do diff --git a/lib/dexcord/model/user.ex b/lib/dexcord/model/user.ex index 873ec01..6ed188a 100644 --- a/lib/dexcord/model/user.ex +++ b/lib/dexcord/model/user.ex @@ -23,6 +23,18 @@ defmodule Dexcord.User do field :collectibles, :raw field :primary_guild, {:struct, Dexcord.PrimaryGuild} end + + @doc "The mention string for this user (`<@id>`)." + @spec mention(t()) :: String.t() + def mention(%{id: id}), do: "<@#{id}>" + + @doc "The creation `DateTime` encoded in this user's id, or `:error`." + @spec created_at(t()) :: {:ok, DateTime.t()} | :error + def created_at(%{id: id}), do: Dexcord.Snowflake.to_datetime(id) +end + +defimpl String.Chars, for: Dexcord.User do + def to_string(user), do: Dexcord.User.mention(user) end defmodule Dexcord.AvatarDecorationData do diff --git a/lib/dexcord/model/webhook.ex b/lib/dexcord/model/webhook.ex index 17a3c97..984dbe7 100644 --- a/lib/dexcord/model/webhook.ex +++ b/lib/dexcord/model/webhook.ex @@ -16,4 +16,8 @@ defmodule Dexcord.Webhook do field :source_channel, :raw field :url, :string end + + @doc "The creation `DateTime` encoded in this webhook's id, or `:error`." + @spec created_at(t()) :: {:ok, DateTime.t()} | :error + def created_at(%{id: id}), do: Dexcord.Snowflake.to_datetime(id) end diff --git a/lib/dexcord/slash.ex b/lib/dexcord/slash.ex index c10c435..7aafd26 100644 --- a/lib/dexcord/slash.ex +++ b/lib/dexcord/slash.ex @@ -31,13 +31,13 @@ defmodule Dexcord.Slash do def handle_modal("feedback_form", itx), do: Dexcord.Slash.respond(itx, "thanks!") end - `dispatch/2` routes a raw `INTERACTION_CREATE` payload on its **top-level** - `interaction["type"]`: type 2 (application command) → `handle_interaction/2` - keyed on `interaction["data"]["name"]`; type 3 (message component) → - `handle_component/2` keyed on `interaction["data"]["custom_id"]`; type 5 (modal - submit) → `handle_modal/2` keyed on `interaction["data"]["custom_id"]`. The + `dispatch/2` routes a decoded `%Dexcord.Interaction{}` on its `type` atom: + `:application_command` → `handle_interaction/2` keyed on the data's `name`; + `:message_component` → `handle_component/2` keyed on the data's `custom_id`; + `:modal_submit` → `handle_modal/2` keyed on the data's `custom_id`. The `Dexcord.Dispatcher` calls it automatically for those types when a `slash:` - module is configured (the raw event still reaches the handler). + module is configured (the event still reaches the handler too). Every callback + receives the full `%Dexcord.Interaction{}` as its second argument. ## Response helpers @@ -56,13 +56,22 @@ defmodule Dexcord.Slash do @callback commands() :: [map()] @doc "Handles a routed application-command interaction (type 2) for command `name`." - @callback handle_interaction(name :: String.t(), interaction :: map()) :: any() + @callback handle_interaction( + name :: String.t() | nil, + interaction :: Dexcord.Interaction.t() + ) :: any() @doc "Handles a routed message-component interaction (type 3) for `custom_id`." - @callback handle_component(custom_id :: String.t() | nil, interaction :: map()) :: any() + @callback handle_component( + custom_id :: String.t() | nil, + interaction :: Dexcord.Interaction.t() + ) :: any() @doc "Handles a routed modal-submit interaction (type 5) for `custom_id`." - @callback handle_modal(custom_id :: String.t() | nil, interaction :: map()) :: any() + @callback handle_modal( + custom_id :: String.t() | nil, + interaction :: Dexcord.Interaction.t() + ) :: any() # Only `commands/0` and `handle_interaction/2` are required; a module that never # uses components or modals need not define those callbacks (the injected @@ -112,21 +121,42 @@ defmodule Dexcord.Slash do # --- routing ------------------------------------------------------------ @doc """ - Routes an `INTERACTION_CREATE` payload to `mod` on its top-level `"type"`. + Routes a decoded `%Dexcord.Interaction{}` to `mod` on its `type` atom. - * type 2 (application command) → `mod.handle_interaction/2`, keyed on - `interaction["data"]["name"]` - * type 3 (message component) → `mod.handle_component/2`, keyed on - `interaction["data"]["custom_id"]` - * type 5 (modal submit) → `mod.handle_modal/2`, keyed on - `interaction["data"]["custom_id"]` + * `:application_command` → `mod.handle_interaction/2`, keyed on the data's `name` + * `:message_component` → `mod.handle_component/2`, keyed on the data's `custom_id` + * `:modal_submit` → `mod.handle_modal/2`, keyed on the data's `custom_id` - Any other (or missing) type is ignored - the `Dexcord.Dispatcher` only routes - types 2/3/5 here, so this is defensive. + Any other (or nil) type is ignored - the `Dexcord.Dispatcher` only routes those + three types here, so this is defensive. + + A malformed `INTERACTION_CREATE` that fails to decode into a struct is routed by + the dispatcher's integer-type gate to the **degraded raw-map head** below, which + reproduces the pre-typed routing on the top-level integer `"type"` so a + not-quite-decodable interaction still reaches the handler with the raw map. """ - @spec dispatch(map(), module()) :: any() - def dispatch(interaction, mod) when is_map(interaction) and is_atom(mod) do - case interaction["type"] do + @spec dispatch(Dexcord.Interaction.t() | map(), module()) :: any() + def dispatch(%Dexcord.Interaction{} = interaction, mod) when is_atom(mod) do + case interaction.type do + :application_command -> + mod.handle_interaction(data_field(interaction, :name), interaction) + + :message_component -> + mod.handle_component(data_field(interaction, :custom_id), interaction) + + :modal_submit -> + mod.handle_modal(data_field(interaction, :custom_id), interaction) + + _ -> + :ignore + end + end + + # Degraded raw-map head: a raw INTERACTION_CREATE the decoder could not turn into + # a %Dexcord.Interaction{}. Routes exactly as the pre-typed dispatcher did, on the + # top-level integer "type", handing the raw map to the callback. + def dispatch(%{"type" => type} = interaction, mod) when is_atom(mod) do + case type do 2 -> mod.handle_interaction(get_in(interaction, ["data", "name"]), interaction) 3 -> mod.handle_component(get_in(interaction, ["data", "custom_id"]), interaction) 5 -> mod.handle_modal(get_in(interaction, ["data", "custom_id"]), interaction) @@ -134,6 +164,12 @@ defmodule Dexcord.Slash do end end + # Reads a field from an interaction's typed `data` variant struct (or nil-data). + # `data` is one of the ApplicationCommandData / MessageComponentData / + # ModalSubmitData structs - all plain maps to `Map.get/2`; nil-data yields nil. + defp data_field(%{data: %{} = data}, key), do: Map.get(data, key) + defp data_field(_interaction, _key), do: nil + # --- response helpers --------------------------------------------------- @doc """ @@ -142,15 +178,16 @@ defmodule Dexcord.Slash do `text_or_map` is either a binary (used as `content`) or a map supporting `content`, `embeds`, `components`, and `ephemeral: true`. """ - @spec respond(map(), String.t() | map()) :: {:ok, map()} | {:ok, nil} | {:error, term()} + @spec respond(Dexcord.Interaction.t(), String.t() | map()) :: + {:ok, map()} | {:ok, nil} | {:error, term()} def respond(interaction, content) when is_binary(content) do respond(interaction, %{content: content}) end - def respond(interaction, %{} = data) do + def respond(%Dexcord.Interaction{} = interaction, %{} = data) do Dexcord.Api.create_interaction_response( - interaction["id"], - interaction["token"], + interaction.id, + interaction.token, %{"type" => 4, "data" => message_data(data)} ) end @@ -159,11 +196,11 @@ defmodule Dexcord.Slash do Sends a deferred response (type 5) - shows a loading state while you prepare a followup or edit the original response. """ - @spec respond_later(map()) :: {:ok, map()} | {:ok, nil} | {:error, term()} - def respond_later(interaction) do + @spec respond_later(Dexcord.Interaction.t()) :: {:ok, map()} | {:ok, nil} | {:error, term()} + def respond_later(%Dexcord.Interaction{} = interaction) do Dexcord.Api.create_interaction_response( - interaction["id"], - interaction["token"], + interaction.id, + interaction.token, %{"type" => 5} ) end @@ -173,15 +210,15 @@ defmodule Dexcord.Slash do `text_or_map` is a binary (used as `content`) or a map as in `respond/2`. """ - @spec followup(map(), String.t() | map()) :: {:ok, map()} | {:error, term()} + @spec followup(Dexcord.Interaction.t(), String.t() | map()) :: {:ok, map()} | {:error, term()} def followup(interaction, content) when is_binary(content) do followup(interaction, %{content: content}) end - def followup(interaction, %{} = data) do + def followup(%Dexcord.Interaction{} = interaction, %{} = data) do Dexcord.Api.create_followup_message( - interaction["application_id"], - interaction["token"], + interaction.application_id, + interaction.token, message_data(data) ) end @@ -191,15 +228,16 @@ defmodule Dexcord.Slash do `text_or_map` is a binary (used as `content`) or a map as in `respond/2`. """ - @spec edit_response(map(), String.t() | map()) :: {:ok, map()} | {:error, term()} + @spec edit_response(Dexcord.Interaction.t(), String.t() | map()) :: + {:ok, map()} | {:error, term()} def edit_response(interaction, content) when is_binary(content) do edit_response(interaction, %{content: content}) end - def edit_response(interaction, %{} = data) do + def edit_response(%Dexcord.Interaction{} = interaction, %{} = data) do Dexcord.Api.edit_original_interaction_response( - interaction["application_id"], - interaction["token"], + interaction.application_id, + interaction.token, message_data(data) ) end @@ -213,7 +251,7 @@ defmodule Dexcord.Slash do base = Enum.reduce([:content, :embeds, :components], %{}, fn field, acc -> case fetch_any(data, field) do - {:ok, value} -> Map.put(acc, Atom.to_string(field), value) + {:ok, value} -> Map.put(acc, Atom.to_string(field), encode_values(value)) :error -> acc end end) @@ -224,6 +262,14 @@ defmodule Dexcord.Slash do end end + # Normalizes recognised field values for the wire: Dexcord structs (e.g. + # `%Dexcord.Embed{}`) go through their own `to_map/1`; lists recurse; anything + # else (a binary, a plain atom/string-keyed map) passes through unchanged and is + # serialized directly by the JSON encoder downstream. + defp encode_values(%_{} = struct), do: struct.__struct__.to_map(struct) + defp encode_values(list) when is_list(list), do: Enum.map(list, &encode_values/1) + defp encode_values(value), do: value + # Combines a caller-supplied integer `flags` with the ephemeral bit (64). # Returns nil when neither is present so the key is omitted entirely. defp flags(data) do diff --git a/lib/dexcord/slash/registrar.ex b/lib/dexcord/slash/registrar.ex index cd731c5..a3fc80e 100644 --- a/lib/dexcord/slash/registrar.ex +++ b/lib/dexcord/slash/registrar.ex @@ -99,7 +99,15 @@ defmodule Dexcord.Slash.Registrar do # A single end-to-end registration: resolve the app id, then overwrite commands. # Returns :ok | {:error, message} - never exits. defp try_register(config) do - commands = config.slash.commands() + # Command defs may be plain maps or Dexcord command-builder structs; normalize + # struct defs to their wire maps before the (untouched) downstream register/3. + # `[]` normalizes to `[]`, so the empty-list global guard below still fires. + commands = + config.slash.commands() + |> Enum.map(fn + %_{} = struct -> struct.__struct__.to_map(struct) + map when is_map(map) -> map + end) with {:ok, app_id} <- resolve_app_id() do Dexcord.Config.put_application_id(app_id) diff --git a/lib/dexcord/util.ex b/lib/dexcord/util.ex new file mode 100644 index 0000000..ccc0d6d --- /dev/null +++ b/lib/dexcord/util.ex @@ -0,0 +1,22 @@ +defmodule Dexcord.Util do + @moduledoc "Small formatting helpers for the ergonomics layer." + + # The nine documented Discord timestamp styles (Message Formatting reference): + # t short time, T long time, d short date, D long date, f short date/time, + # F long date/time, s short relative-ish (seconds), S, R relative. + @styles ~w(t T d D f F s S R) + + @doc """ + Renders a Discord timestamp markdown token ``. + + Accepts a `DateTime` or a unix-seconds integer. `style` is one of + `#{Enum.join(@styles, " ")}` and defaults to `"f"` (per Discord's docs). + """ + @spec format_dt(DateTime.t() | integer(), String.t()) :: String.t() + def format_dt(dt_or_unix, style \\ "f") + + def format_dt(%DateTime{} = dt, style), do: format_dt(DateTime.to_unix(dt), style) + + def format_dt(unix, style) when is_integer(unix) and style in @styles, + do: "" +end diff --git a/test/dexcord/cache_fill_test.exs b/test/dexcord/cache_fill_test.exs new file mode 100644 index 0000000..6e9174a --- /dev/null +++ b/test/dexcord/cache_fill_test.exs @@ -0,0 +1,103 @@ +defmodule Dexcord.CacheFillTest do + @moduledoc """ + Unit tests for `Dexcord.Cache.fill/1` (api-surface.AC3.8): best-effort + hydration of an envelope's declared `hydrate` slots from ETS only. + + These tests start ONLY `Dexcord.Cache` - no FakeRest, no Finch pool. `fill/1` + reads ETS and nothing else, so any accidental HTTP round-trip would crash on + the missing connection pool; the suite passing is the proof that `fill/1` + never touches the network. + """ + use ExUnit.Case, async: false + + alias Dexcord.Cache + alias Dexcord.Events + + setup do + Dexcord.EnvSandbox.sandbox_env() + start_supervised!(Dexcord.Cache) + :ok + end + + # Seed the cache exactly as the dispatcher would: decode once, hand the cache + # both the decoded struct and the raw partial map. + defp feed(name, raw) do + Cache.handle_dispatch(name, Events.decode(name, raw), raw, %{cache_presences: false}) + end + + # The golden guild's own ids (see test/fixtures/guild_create.json). + @guild_id 900_000_000_000_000_000 + # channel 100000000000000000 is a type-0 text channel. + @channel_id 100_000_000_000_000_000 + # member/user 800000000000000000 is Nelly. + @user_id 800_000_000_000_000_000 + + defp reaction(overrides) do + raw = + Map.merge( + %{ + "user_id" => to_string(@user_id), + "channel_id" => to_string(@channel_id), + "message_id" => "100000000000000099", + "guild_id" => to_string(@guild_id), + "emoji" => %{"id" => nil, "name" => "👍"} + }, + overrides + ) + + Events.decode(:MESSAGE_REACTION_ADD, raw) + end + + describe "fill/1 hydration (AC3.8)" do + test "populates every declared slot from the cache on a full hit" do + feed(:GUILD_CREATE, Dexcord.Fixtures.load!("guild_create.json")) + + event = reaction(%{}) + # After decode the hydrate slots are always nil. + assert %Dexcord.Events.ReactionAdd{user: nil, channel: nil, guild: nil} = event + + filled = Cache.fill(event) + + assert %Dexcord.Events.ReactionAdd{ + user: %Dexcord.User{id: @user_id}, + channel: %Dexcord.TextChannel{id: @channel_id}, + guild: %Dexcord.Guild{id: @guild_id} + } = filled + end + + test "a cache miss leaves that slot nil while the others still fill" do + feed(:GUILD_CREATE, Dexcord.Fixtures.load!("guild_create.json")) + + # Unknown user id: not in the cache. + event = reaction(%{"user_id" => "111111111111111111"}) + + filled = Cache.fill(event) + + assert filled.user == nil + assert %Dexcord.TextChannel{id: @channel_id} = filled.channel + assert %Dexcord.Guild{id: @guild_id} = filled.guild + end + + test "an unseeded cache leaves every slot nil (never blocks on the network)" do + # No GUILD_CREATE seeded and, deliberately, no FakeRest/Finch started: if + # fill/1 ever reached for HTTP it would crash here instead of returning nils. + filled = Cache.fill(reaction(%{})) + + assert %Dexcord.Events.ReactionAdd{user: nil, channel: nil, guild: nil} = filled + end + + test "fill/1 is idempotent" do + feed(:GUILD_CREATE, Dexcord.Fixtures.load!("guild_create.json")) + + event = reaction(%{}) + once = Cache.fill(event) + + assert Cache.fill(once) == once + end + + test "a struct with no hydration slots passes through unchanged" do + user = %Dexcord.User{id: @user_id, username: "nelly"} + assert Cache.fill(user) == user + end + end +end diff --git a/test/dexcord/config_validation_test.exs b/test/dexcord/config_validation_test.exs index a591a36..841b494 100644 --- a/test/dexcord/config_validation_test.exs +++ b/test/dexcord/config_validation_test.exs @@ -75,4 +75,25 @@ defmodule Dexcord.ConfigValidationTest do assert %{slash_guild_ids: nil} = validate([]) end end + + describe "allowed_mentions" do + test "a keyword is normalized to a string-keyed wire map" do + assert %{allowed_mentions: %{"parse" => []}} = validate(allowed_mentions: [parse: []]) + end + + test "a struct is normalized via to_map" do + assert %{allowed_mentions: %{"parse" => [], "replied_user" => false}} = + validate(allowed_mentions: %Dexcord.AllowedMentions{}) + end + + test "absent defaults to nil" do + assert %{allowed_mentions: nil} = validate([]) + end + + test "an invalid type raises a friendly ArgumentError" do + assert_raise ArgumentError, ~r/:allowed_mentions must be/, fn -> + validate(allowed_mentions: 123) + end + end + end end diff --git a/test/dexcord/ergonomics_helpers_test.exs b/test/dexcord/ergonomics_helpers_test.exs new file mode 100644 index 0000000..8c4d7f5 --- /dev/null +++ b/test/dexcord/ergonomics_helpers_test.exs @@ -0,0 +1,131 @@ +defmodule Dexcord.ErgonomicsHelpersTest do + @moduledoc """ + Pure helper tests for the discord.py taste layer (api-surface.AC3.4): mentions, + `String.Chars` interpolation, `Dexcord.PartialEmoji.to_string/1`, `created_at/1`, + and `Dexcord.Util.format_dt/2`. + """ + use ExUnit.Case, async: true + + alias Dexcord.Util + + # Discord's documented example snowflake -> 2016-04-30T11:18:25.796Z. + @known_snowflake 175_928_847_299_117_063 + @known_unix_ms 1_462_015_105_796 + + describe "mention/1" do + test "User renders <@id>" do + assert Dexcord.User.mention(%Dexcord.User{id: 1}) == "<@1>" + end + + test "Member renders <@id> from user_id" do + assert Dexcord.Member.mention(%Dexcord.Member{user_id: 7}) == "<@7>" + end + + test "Member renders <@id> from the nested user when user_id is nil" do + assert Dexcord.Member.mention(%Dexcord.Member{user: %Dexcord.User{id: 8}}) == "<@8>" + end + + test "Role renders <@&id>" do + assert Dexcord.Role.mention(%Dexcord.Role{id: 2}) == "<@&2>" + end + + test "Dexcord.Channel.mention/1 renders <#id> for any channel struct" do + assert Dexcord.Channel.mention(%Dexcord.TextChannel{id: 3}) == "<#3>" + assert Dexcord.Channel.mention(%Dexcord.VoiceChannel{id: 4}) == "<#4>" + assert Dexcord.Channel.mention(%Dexcord.Thread{id: 5}) == "<#5>" + assert Dexcord.Channel.mention(%Dexcord.DMChannel{id: 6}) == "<#6>" + end + end + + describe "String.Chars interpolation" do + test "a user interpolates as a real ping" do + assert "hey #{%Dexcord.User{id: 1}}" == "hey <@1>" + end + + test "a member interpolates as a real ping" do + assert "#{%Dexcord.Member{user_id: 9}}" == "<@9>" + end + + test "a role interpolates as <@&id>" do + assert "#{%Dexcord.Role{id: 2}}" == "<@&2>" + end + + test "a text channel interpolates as <#id>" do + assert "#{%Dexcord.TextChannel{id: 3}}" == "<#3>" + end + + test "a thread interpolates as <#id>" do + assert "#{%Dexcord.Thread{id: 5}}" == "<#5>" + end + end + + describe "PartialEmoji.to_string/1" do + test "a custom (static) emoji renders <:name:id>" do + emoji = %Dexcord.PartialEmoji{id: 100, name: "blob", animated: false} + assert Dexcord.PartialEmoji.to_string(emoji) == "<:blob:100>" + end + + test "an animated emoji renders " do + emoji = %Dexcord.PartialEmoji{id: 200, name: "party", animated: true} + assert Dexcord.PartialEmoji.to_string(emoji) == "" + end + + test "a unicode emoji (nil id) renders its raw name" do + emoji = %Dexcord.PartialEmoji{id: nil, name: "🔥"} + assert Dexcord.PartialEmoji.to_string(emoji) == "🔥" + end + + test "interpolates via String.Chars" do + assert "#{%Dexcord.PartialEmoji{id: 100, name: "blob"}}" == "<:blob:100>" + assert "#{%Dexcord.PartialEmoji{id: nil, name: "🔥"}}" == "🔥" + end + end + + describe "Dexcord.Util.format_dt/2" do + test "renders every documented style" do + for style <- ~w(t T d D f F s S R) do + assert Util.format_dt(1_700_000_000, style) == "" + end + end + + test "defaults to the f style" do + assert Util.format_dt(1_700_000_000) == "" + end + + test "accepts a DateTime and converts to unix" do + dt = DateTime.from_unix!(1_700_000_000) + assert Util.format_dt(dt) == "" + assert Util.format_dt(dt, "R") == "" + end + end + + describe "created_at/1" do + test "round-trips a known snowflake vector on a User" do + assert {:ok, dt} = Dexcord.User.created_at(%Dexcord.User{id: @known_snowflake}) + assert DateTime.to_unix(dt, :millisecond) == @known_unix_ms + end + + test "works on a Message via its id" do + assert {:ok, dt} = Dexcord.Message.created_at(%Dexcord.Message{id: @known_snowflake}) + assert DateTime.to_unix(dt, :millisecond) == @known_unix_ms + end + + test "works on a Member via user_id" do + assert {:ok, dt} = + Dexcord.Member.created_at(%Dexcord.Member{user_id: @known_snowflake}) + + assert DateTime.to_unix(dt, :millisecond) == @known_unix_ms + end + + test "works on a guild channel struct" do + assert {:ok, dt} = + Dexcord.TextChannel.created_at(%Dexcord.TextChannel{id: @known_snowflake}) + + assert DateTime.to_unix(dt, :millisecond) == @known_unix_ms + end + + test "an emoji with a nil id returns :error" do + assert Dexcord.Emoji.created_at(%Dexcord.Emoji{id: nil}) == :error + end + end +end diff --git a/test/dexcord/ergonomics_integration_test.exs b/test/dexcord/ergonomics_integration_test.exs index 0b5ac58..e1341f6 100644 --- a/test/dexcord/ergonomics_integration_test.exs +++ b/test/dexcord/ergonomics_integration_test.exs @@ -116,7 +116,7 @@ defmodule Dexcord.ErgonomicsIntegrationTest do itx = %{"id" => "i2", "type" => 2, "token" => "tok", "data" => %{"name" => "ping"}} FakeGateway.push_dispatch(fake, "INTERACTION_CREATE", itx, 20) - assert_receive {:slash, "ping", ^itx}, @timeout + assert_receive {:slash, "ping", %Dexcord.Interaction{type: :application_command}}, @timeout assert_receive {:raw, :INTERACTION_CREATE, %Dexcord.Interaction{type: :application_command}}, @timeout diff --git a/test/dexcord/gateway_integration_test.exs b/test/dexcord/gateway_integration_test.exs index f51f7c3..f7625bb 100644 --- a/test/dexcord/gateway_integration_test.exs +++ b/test/dexcord/gateway_integration_test.exs @@ -98,6 +98,7 @@ defmodule Dexcord.GatewayIntegrationTest do # A dispatch reaches the user handler... FakeGateway.push_dispatch(fake, "MESSAGE_CREATE", %{"content" => "hi"}, 7) + assert_receive {:handler_event, {:MESSAGE_CREATE, %Dexcord.Message{content: "hi"}}}, @event_timeout @@ -258,6 +259,7 @@ defmodule Dexcord.GatewayIntegrationTest do # The statem survived: a following real dispatch still reaches the user handler. FakeGateway.push_dispatch(fake, "MESSAGE_CREATE", %{"content" => "alive"}, 6) + assert_receive {:handler_event, {:MESSAGE_CREATE, %Dexcord.Message{content: "alive"}}}, @event_timeout @@ -290,6 +292,7 @@ defmodule Dexcord.GatewayIntegrationTest do # Advance the live session's seq. FakeGateway.push_dispatch(fake, "MESSAGE_CREATE", %{"content" => "x"}, 42) + assert_receive {:handler_event, {:MESSAGE_CREATE, %Dexcord.Message{content: "x"}}}, @event_timeout @@ -302,6 +305,7 @@ defmodule Dexcord.GatewayIntegrationTest do # Trickle a dispatch from the abandoned session during the reidentify wait. FakeGateway.push_dispatch(fake, "MESSAGE_CREATE", %{"content" => "noise"}, 9_999) + assert_receive {:handler_event, {:MESSAGE_CREATE, %Dexcord.Message{content: "noise"}}}, @event_timeout @@ -547,6 +551,7 @@ defmodule Dexcord.GatewayIntegrationTest do assert_frame(2) assert_receive {:handler_event, {:READY, _}}, @event_timeout FakeGateway.push_dispatch(fake, "MESSAGE_CREATE", %{"content" => "seed"}, 5) + assert_receive {:handler_event, {:MESSAGE_CREATE, %Dexcord.Message{content: "seed"}}}, @event_timeout @@ -562,6 +567,7 @@ defmodule Dexcord.GatewayIntegrationTest do assert_receive {:handler_event, {:MESSAGE_CREATE, %Dexcord.Message{content: "c"}}}, @event_timeout + assert_receive {:handler_event, {:RESUMED, _}}, @event_timeout # Seq advanced to the last replayed dispatch. diff --git a/test/dexcord/messageable_test.exs b/test/dexcord/messageable_test.exs new file mode 100644 index 0000000..4374ed2 --- /dev/null +++ b/test/dexcord/messageable_test.exs @@ -0,0 +1,79 @@ +defmodule Dexcord.MessageableTest do + @moduledoc """ + Pure resolve tests for the `Dexcord.Messageable` protocol (api-surface.AC3.1's + match-time layer). No HTTP: every case exercises `Dexcord.Messageable.resolve/1` + directly, including the deliberate non-implementations that must fail BEFORE any + network call. + """ + use ExUnit.Case, async: true + + alias Dexcord.Messageable + + describe "channel-like targets resolve to {:channel, id}" do + test "guild text/announcement/voice/stage channels" do + assert Messageable.resolve(%Dexcord.TextChannel{id: 1}) == {:channel, 1} + assert Messageable.resolve(%Dexcord.AnnouncementChannel{id: 2}) == {:channel, 2} + assert Messageable.resolve(%Dexcord.VoiceChannel{id: 3}) == {:channel, 3} + assert Messageable.resolve(%Dexcord.StageChannel{id: 4}) == {:channel, 4} + end + + test "threads and DM/group-DM channels" do + assert Messageable.resolve(%Dexcord.Thread{id: 9}) == {:channel, 9} + assert Messageable.resolve(%Dexcord.DMChannel{id: 10}) == {:channel, 10} + assert Messageable.resolve(%Dexcord.GroupDMChannel{id: 11}) == {:channel, 11} + end + + test "a message resolves to its channel" do + assert Messageable.resolve(%Dexcord.Message{id: 100, channel_id: 5}) == {:channel, 5} + end + + test "an interaction resolves to its channel" do + assert Messageable.resolve(%Dexcord.Interaction{id: 1, channel_id: 7}) == {:channel, 7} + end + + test "a bare non-negative integer passes through" do + assert Messageable.resolve(123) == {:channel, 123} + assert Messageable.resolve(0) == {:channel, 0} + end + end + + describe "user-like targets resolve to {:dm_user, id}" do + test "a user" do + assert Messageable.resolve(%Dexcord.User{id: 42}) == {:dm_user, 42} + end + + test "a member via its nested user" do + assert Messageable.resolve(%Dexcord.Member{user: %Dexcord.User{id: 43}}) == + {:dm_user, 43} + end + + test "a member via its user_id back-reference (cache shape, no nested user)" do + assert Messageable.resolve(%Dexcord.Member{user: nil, user_id: 44}) == {:dm_user, 44} + end + end + + describe "deliberate non-implementations fail at resolve time" do + # These structs are built via `struct/2` rather than a literal so the + # set-theoretic type checker can't statically flag the (intentional) + # protocol violation we're asserting on at runtime. + test "category channels are not Messageable" do + target = struct(Dexcord.CategoryChannel, id: 1) + assert_raise Protocol.UndefinedError, fn -> Messageable.resolve(target) end + end + + test "forum channels are not Messageable" do + target = struct(Dexcord.ForumChannel, id: 1) + assert_raise Protocol.UndefinedError, fn -> Messageable.resolve(target) end + end + + test "media channels are not Messageable" do + target = struct(Dexcord.MediaChannel, id: 1) + assert_raise Protocol.UndefinedError, fn -> Messageable.resolve(target) end + end + + test "directory channels are not Messageable" do + target = struct(Dexcord.DirectoryChannel, id: 1) + assert_raise Protocol.UndefinedError, fn -> Messageable.resolve(target) end + end + end +end diff --git a/test/dexcord/migration_guide_samples_test.exs b/test/dexcord/migration_guide_samples_test.exs new file mode 100644 index 0000000..939c63d --- /dev/null +++ b/test/dexcord/migration_guide_samples_test.exs @@ -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 diff --git a/test/dexcord/model_message_parts_test.exs b/test/dexcord/model_message_parts_test.exs index 3810147..7b38e08 100644 --- a/test/dexcord/model_message_parts_test.exs +++ b/test/dexcord/model_message_parts_test.exs @@ -139,4 +139,100 @@ defmodule Dexcord.ModelMessagePartsTest do assert am.replied_user == true end end + + describe "AllowedMentions.normalize/1" do + test "nil stays nil" do + assert Dexcord.AllowedMentions.normalize(nil) == nil + end + + test "a struct normalizes to its wire map (all set fields explicit)" do + assert Dexcord.AllowedMentions.normalize(%Dexcord.AllowedMentions{}) == + %{"parse" => [], "replied_user" => false} + end + + test "a keyword stringifies its keys, keeping only provided keys" do + assert Dexcord.AllowedMentions.normalize(parse: [], users: [5]) == + %{"parse" => [], "users" => [5]} + end + + test "a map with atom or string keys stringifies keys" do + assert Dexcord.AllowedMentions.normalize(%{parse: []}) == %{"parse" => []} + assert Dexcord.AllowedMentions.normalize(%{"users" => ["5"]}) == %{"users" => ["5"]} + end + end + + describe "AllowedMentions.merge/2" do + test "a nil default yields the per-send value" do + assert Dexcord.AllowedMentions.merge(nil, %{"users" => ["5"]}) == %{"users" => ["5"]} + end + + test "a nil per-send yields the default" do + assert Dexcord.AllowedMentions.merge(%{"parse" => []}, nil) == %{"parse" => []} + end + + test "field-wise: per-send keys win, default fills the rest" do + assert Dexcord.AllowedMentions.merge(%{"parse" => []}, %{"users" => ["5"]}) == + %{"parse" => [], "users" => ["5"]} + end + + test "a per-send key overrides the same key in the default" do + assert Dexcord.AllowedMentions.merge(%{"parse" => ["users"]}, %{"parse" => []}) == + %{"parse" => []} + end + end + + describe "Embed builder (AC3.6)" do + test "the builder chain produces the exact wire map" do + embed = + Dexcord.Embed.new() + |> Dexcord.Embed.title("t") + |> Dexcord.Embed.color(0xFF00FF) + |> Dexcord.Embed.field("a", "b", inline: true) + |> Dexcord.Embed.footer("f") + |> Dexcord.Embed.timestamp(~U[2026-07-04 12:00:00Z]) + + assert Dexcord.Embed.to_map(embed) == %{ + "title" => "t", + "color" => 0xFF00FF, + "fields" => [%{"name" => "a", "value" => "b", "inline" => true}], + "footer" => %{"text" => "f"}, + "timestamp" => "2026-07-04T12:00:00Z" + } + end + + test "description/url/image/thumbnail/author land on the struct and encode" do + embed = + Dexcord.Embed.new() + |> Dexcord.Embed.description("d") + |> Dexcord.Embed.url("https://e.com") + |> Dexcord.Embed.image("https://e.com/i.png") + |> Dexcord.Embed.thumbnail("https://e.com/t.png") + |> Dexcord.Embed.author("me", url: "https://e.com/me", icon_url: "https://e.com/me.png") + + assert Dexcord.Embed.to_map(embed) == %{ + "description" => "d", + "url" => "https://e.com", + "image" => %{"url" => "https://e.com/i.png"}, + "thumbnail" => %{"url" => "https://e.com/t.png"}, + "author" => %{ + "name" => "me", + "url" => "https://e.com/me", + "icon_url" => "https://e.com/me.png" + }, + "fields" => [] + } + end + + test "fields append in order" do + embed = + Dexcord.Embed.new() + |> Dexcord.Embed.field("a", "1") + |> Dexcord.Embed.field("b", "2", inline: true) + + assert [ + %Dexcord.EmbedField{name: "a", value: "1", inline: false}, + %Dexcord.EmbedField{name: "b", value: "2", inline: true} + ] = embed.fields + end + end end diff --git a/test/dexcord/pagination_test.exs b/test/dexcord/pagination_test.exs new file mode 100644 index 0000000..85d63dd --- /dev/null +++ b/test/dexcord/pagination_test.exs @@ -0,0 +1,177 @@ +defmodule Dexcord.PaginationTest do + @moduledoc """ + Wire tests for the lazy pagination streams on `Dexcord.Api` + (api-surface.AC3.7), driven through `Dexcord.FakeRest`. + + The point of these streams is laziness: a page is fetched only when the + consumer walks that far, so `Stream.take/2` on a fresh stream makes exactly + one wire hit. These tests assert that directly (the second hit is refuted). + """ + use ExUnit.Case, async: false + + alias Dexcord.Api + alias Dexcord.Api.Paginate + alias Dexcord.Api.Ratelimit + alias Dexcord.FakeRest + + @token "test.token.value" + + setup do + Dexcord.EnvSandbox.sandbox_env() + Dexcord.Config.put(%{token: @token, handler: nil, 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 + + # Build a JSON array body of message objects with the given ids (as strings). + defp messages_json(ids), + do: "[" <> Enum.map_join(ids, ",", fn id -> ~s({"id":"#{id}"}) end) <> "]" + + # Build a JSON array of member objects (nested user id) with the given ids. + defp members_json(ids), + do: "[" <> Enum.map_join(ids, ",", fn id -> ~s({"user":{"id":"#{id}"}}) end) <> "]" + + describe "AC3.7: message_history pages lazily across multiple hits" do + test "two sequential pages yield every message in order, before= cursor on hit 2" do + page1_ids = Enum.to_list(200..101//-1) + page2_ids = [100, 99, 98] + assert length(page1_ids) == 100 + + FakeRest.stub( + :get, + "/channels/1/messages", + FakeRest.resp(200, body: messages_json(page1_ids)) + ) + + FakeRest.stub( + :get, + "/channels/1/messages", + FakeRest.resp(200, body: messages_json(page2_ids)) + ) + + result = Api.message_history(1) |> Enum.to_list() + + assert Enum.map(result, & &1.id) == page1_ids ++ page2_ids + + # Exactly two hits; the second carries before=. + assert_receive {:rest_hit, %{method: "GET", path: "/channels/1/messages", query_string: q1}} + refute q1 =~ "before=" + assert_receive {:rest_hit, %{path: "/channels/1/messages", query_string: q2}} + assert q2 =~ "before=101" + refute_receive {:rest_hit, %{path: "/channels/1/messages"}}, 50 + end + + test "LAZINESS: Stream.take(5) makes exactly one wire hit" do + # A single, sticky 100-message page: if the stream were eager it would + # loop forever hitting the wire. take(5) must fetch exactly one page. + full_page = Enum.to_list(500..401//-1) + assert length(full_page) == 100 + + FakeRest.stub( + :get, + "/channels/7/messages", + FakeRest.resp(200, body: messages_json(full_page)) + ) + + result = Api.message_history(7) |> Stream.take(5) |> Enum.to_list() + + assert Enum.map(result, & &1.id) == [500, 499, 498, 497, 496] + assert_receive {:rest_hit, %{path: "/channels/7/messages"}} + refute_receive {:rest_hit, %{path: "/channels/7/messages"}}, 50 + end + + test "after: flips to ascending paging with after= cursor" do + page1_ids = Enum.to_list(101..200) + page2_ids = [201, 202, 203] + assert length(page1_ids) == 100 + + FakeRest.stub( + :get, + "/channels/2/messages", + FakeRest.resp(200, body: messages_json(page1_ids)) + ) + + FakeRest.stub( + :get, + "/channels/2/messages", + FakeRest.resp(200, body: messages_json(page2_ids)) + ) + + result = Api.message_history(2, after: 100) |> Enum.to_list() + + assert Enum.map(result, & &1.id) == page1_ids ++ page2_ids + + assert_receive {:rest_hit, %{path: "/channels/2/messages", query_string: q1}} + assert q1 =~ "after=100" + assert_receive {:rest_hit, %{path: "/channels/2/messages", query_string: q2}} + assert q2 =~ "after=200" + refute q2 =~ "before=" + end + + test "limit: caps the total number of messages via Stream.take" do + full_page = Enum.to_list(300..201//-1) + + FakeRest.stub( + :get, + "/channels/3/messages", + FakeRest.resp(200, body: messages_json(full_page)) + ) + + result = Api.message_history(3, limit: 3) |> Enum.to_list() + + assert Enum.map(result, & &1.id) == [300, 299, 298] + assert_receive {:rest_hit, %{path: "/channels/3/messages"}} + refute_receive {:rest_hit, %{path: "/channels/3/messages"}}, 50 + end + end + + describe "guild_members_stream pages ascending on the last user_id" do + test "cursor is the last member's user id" do + page1_ids = Enum.to_list(1..1000) + page2_ids = [1001, 1002] + assert length(page1_ids) == 1000 + + FakeRest.stub(:get, "/guilds/9/members", FakeRest.resp(200, body: members_json(page1_ids))) + FakeRest.stub(:get, "/guilds/9/members", FakeRest.resp(200, body: members_json(page2_ids))) + + result = Api.guild_members_stream(9) |> Enum.to_list() + + assert Enum.map(result, & &1.user.id) == page1_ids ++ page2_ids + + assert_receive {:rest_hit, %{path: "/guilds/9/members", query_string: q1}} + assert q1 =~ "after=0" + assert_receive {:rest_hit, %{path: "/guilds/9/members", query_string: q2}} + assert q2 =~ "after=1000" + end + end + + describe "error mid-stream" do + test "an {:error, _} page raises Dexcord.Api.Paginate.PageError" do + full_page = Enum.to_list(200..101//-1) + + FakeRest.stub( + :get, + "/channels/5/messages", + FakeRest.resp(200, body: messages_json(full_page)) + ) + + FakeRest.stub( + :get, + "/channels/5/messages", + FakeRest.resp(500, body: ~s({"message":"boom"})) + ) + + assert_raise Paginate.PageError, fn -> + Api.message_history(5) |> Enum.to_list() + end + end + end +end diff --git a/test/dexcord/permissions_compute_test.exs b/test/dexcord/permissions_compute_test.exs new file mode 100644 index 0000000..543d704 --- /dev/null +++ b/test/dexcord/permissions_compute_test.exs @@ -0,0 +1,153 @@ +defmodule Dexcord.PermissionsComputeTest do + @moduledoc """ + Pure tests for `Dexcord.Guild.member_permissions/2,3` — the documented + overwrite-resolution algorithm (owner → base roles → ADMINISTRATOR short-circuit + → @everyone/role/member overwrites → timeout rule), verified verbatim. + """ + use ExUnit.Case, async: true + + import Bitwise + + alias Dexcord.Guild + alias Dexcord.Member + alias Dexcord.Overwrite + alias Dexcord.Permissions + alias Dexcord.Role + + @send Permissions.from_list([:send_messages]) + @view Permissions.from_list([:view_channel]) + @rmh Permissions.from_list([:read_message_history]) + @admin Permissions.from_list([:administrator]) + @ban Permissions.from_list([:ban_members]) + @all Permissions.all() |> Map.values() |> Enum.reduce(0, &bor/2) + @timeout_allowed bor(@view, @rmh) + + # Guild id 1; @everyone role has id == guild id (== 1). + defp guild(roles), do: %Guild{id: 1, owner_id: 100, roles: roles} + defp everyone(perms), do: %Role{id: 1, permissions: perms} + defp role(id, perms), do: %Role{id: id, permissions: perms} + defp member(user_id, role_ids), do: %Member{user_id: user_id, roles: role_ids} + + test "the guild owner gets all permissions" do + g = guild([everyone(0)]) + m = member(100, []) + assert Guild.member_permissions(g, m) == @all + end + + test "an ADMINISTRATOR member gets all permissions, even with a deny-everything channel overwrite" do + g = guild([everyone(0), role(20, @admin)]) + m = member(200, [20]) + + channel = %Dexcord.TextChannel{ + id: 5, + permission_overwrites: [%Overwrite{id: 1, type: :role, allow: 0, deny: @all}] + } + + assert Guild.member_permissions(g, m, channel) == @all + end + + test "base permissions are the OR of @everyone and every member role" do + g = guild([everyone(@view), role(10, @send), role(11, @ban)]) + m = member(200, [10, 11]) + + assert Guild.member_permissions(g, m) == (@view ||| @send ||| @ban) + end + + test "unknown member role ids are skipped" do + g = guild([everyone(@view), role(10, @send)]) + m = member(200, [10, 999]) + + assert Guild.member_permissions(g, m) == (@view ||| @send) + end + + test "an @everyone channel overwrite that denies SEND_MESSAGES removes it" do + g = guild([everyone(@view), role(10, @send)]) + m = member(200, [10]) + + channel = %Dexcord.TextChannel{ + id: 5, + permission_overwrites: [%Overwrite{id: 1, type: :role, allow: 0, deny: @send}] + } + + result = Guild.member_permissions(g, m, channel) + refute Permissions.has?(result, :send_messages) + assert Permissions.has?(result, :view_channel) + end + + test "a role overwrite allow restores a bit denied by @everyone (deny then allow order)" do + g = guild([everyone(@view), role(10, @send)]) + m = member(200, [10]) + + channel = %Dexcord.TextChannel{ + id: 5, + permission_overwrites: [ + %Overwrite{id: 1, type: :role, allow: 0, deny: @send}, + %Overwrite{id: 10, type: :role, allow: @send, deny: 0} + ] + } + + result = Guild.member_permissions(g, m, channel) + assert Permissions.has?(result, :send_messages) + end + + test "a member overwrite deny wins over a role overwrite allow (member applied last)" do + g = guild([everyone(@view), role(10, 0)]) + m = member(200, [10]) + + channel = %Dexcord.TextChannel{ + id: 5, + permission_overwrites: [ + %Overwrite{id: 10, type: :role, allow: @send, deny: 0}, + %Overwrite{id: 200, type: :member, allow: 0, deny: @send} + ] + } + + result = Guild.member_permissions(g, m, channel) + refute Permissions.has?(result, :send_messages) + end + + test "within the role tier, an allow OR-aggregates over a deny of the same bit (allow wins)" do + g = guild([everyone(@view), role(10, 0), role(11, 0)]) + m = member(200, [10, 11]) + + channel = %Dexcord.TextChannel{ + id: 5, + permission_overwrites: [ + %Overwrite{id: 10, type: :role, allow: 0, deny: @send}, + %Overwrite{id: 11, type: :role, allow: @send, deny: 0} + ] + } + + result = Guild.member_permissions(g, m, channel) + assert Permissions.has?(result, :send_messages) + end + + test "a timed-out member keeps exactly VIEW_CHANNEL | READ_MESSAGE_HISTORY" do + future = DateTime.add(DateTime.utc_now(), 3600, :second) + g = guild([everyone(@view ||| @send ||| @rmh)]) + + m = %Member{user_id: 200, roles: [], communication_disabled_until: future} + + assert Guild.member_permissions(g, m) == @timeout_allowed + end + + test "a timed-out administrator is unaffected (owner/admin skip the timeout rule)" do + future = DateTime.add(DateTime.utc_now(), 3600, :second) + g = guild([everyone(0), role(20, @admin)]) + + m = %Member{user_id: 200, roles: [20], communication_disabled_until: future} + + assert Guild.member_permissions(g, m) == @all + end + + test "a PAST communication_disabled_until is NOT a timeout" do + past = DateTime.add(DateTime.utc_now(), -3600, :second) + g = guild([everyone(@view ||| @send)]) + + m = %Member{user_id: 200, roles: [], communication_disabled_until: past} + + result = Guild.member_permissions(g, m) + assert Permissions.has?(result, :send_messages) + assert Permissions.has?(result, :view_channel) + end +end diff --git a/test/dexcord/send_test.exs b/test/dexcord/send_test.exs new file mode 100644 index 0000000..e99e4d9 --- /dev/null +++ b/test/dexcord/send_test.exs @@ -0,0 +1,219 @@ +defmodule Dexcord.SendTest do + @moduledoc """ + Wire tests for the `Dexcord.Api.send/2,3` funnel and its lazy DM dance + (api-surface.AC3.1, api-surface.AC3.2), driven through `Dexcord.FakeRest`. + """ + use ExUnit.Case, async: false + + alias Dexcord.Api + alias Dexcord.Api.Ratelimit + alias Dexcord.FakeRest + + @token "test.token.value" + + setup do + Dexcord.EnvSandbox.sandbox_env() + Dexcord.Config.put(%{token: @token, handler: nil, 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 + + describe "AC3.1: send/2 resolves channels, threads, messages, and bare ids to the right route" do + test "a text channel struct" do + FakeRest.stub(:post, "/channels/1/messages", FakeRest.resp(200, body: ~s({"id":"1"}))) + + assert {:ok, %Dexcord.Message{}} = Api.send(%Dexcord.TextChannel{id: 1}, "hi") + + assert_receive {:rest_hit, %{method: "POST", path: "/channels/1/messages"}} + end + + test "a thread struct" do + FakeRest.stub(:post, "/channels/9/messages", FakeRest.resp(200, body: ~s({"id":"2"}))) + + assert {:ok, %Dexcord.Message{}} = Api.send(%Dexcord.Thread{id: 9}, "hi") + + assert_receive {:rest_hit, %{path: "/channels/9/messages"}} + end + + test "a bare snowflake" do + FakeRest.stub(:post, "/channels/123/messages", FakeRest.resp(200, body: ~s({"id":"3"}))) + + assert {:ok, %Dexcord.Message{}} = Api.send(123, "hi") + + assert_receive {:rest_hit, %{path: "/channels/123/messages"}} + end + + test "a message resolves to its channel" do + FakeRest.stub(:post, "/channels/5/messages", FakeRest.resp(200, body: ~s({"id":"4"}))) + + assert {:ok, %Dexcord.Message{}} = Api.send(%Dexcord.Message{channel_id: 5}, "hi") + + assert_receive {:rest_hit, %{path: "/channels/5/messages"}} + end + end + + describe "AC3.2: send/2 to a user lazily creates the DM once, then reuses it" do + setup do + FakeRest.stub( + :post, + "/users/@me/channels", + FakeRest.resp(200, body: ~s({"id":"77","type":1})) + ) + + FakeRest.stub(:post, "/channels/77/messages", FakeRest.resp(200, body: ~s({"id":"9"}))) + :ok + end + + test "first send hits create-DM then the message; second send skips create-DM" do + # First send: create the DM channel, then post the message. + assert {:ok, %Dexcord.Message{}} = Api.send(%Dexcord.User{id: 42}, "yo") + + assert_receive {:rest_hit, %{method: "POST", path: "/users/@me/channels"}} + assert_receive {:rest_hit, %{method: "POST", path: "/channels/77/messages"}} + + # Second send: the DM channel id is cached, so ONLY a message hit occurs. + assert {:ok, %Dexcord.Message{}} = Api.send(%Dexcord.User{id: 42}, "again") + + assert_receive {:rest_hit, %{method: "POST", path: "/channels/77/messages"}} + refute_receive {:rest_hit, %{path: "/users/@me/channels"}}, 50 + end + + test "a member (via nested user) reuses the same cached DM as the user" do + assert {:ok, %Dexcord.Message{}} = Api.send(%Dexcord.User{id: 42}, "first") + assert_receive {:rest_hit, %{path: "/users/@me/channels"}} + assert_receive {:rest_hit, %{path: "/channels/77/messages"}} + + assert {:ok, %Dexcord.Message{}} = + Api.send(%Dexcord.Member{user: %Dexcord.User{id: 42}}, "second") + + assert_receive {:rest_hit, %{path: "/channels/77/messages"}} + refute_receive {:rest_hit, %{path: "/users/@me/channels"}}, 50 + end + end + + describe "AC3.3: Message.reply/2,3 sets message_reference and mention_author" do + setup do + FakeRest.stub(:post, "/channels/5/messages", FakeRest.resp(200, body: ~s({"id":"1"}))) + %{msg: %Dexcord.Message{id: 555, channel_id: 5}} + end + + test "reply/2 sets message_reference to the source message", %{msg: msg} do + assert {:ok, %Dexcord.Message{}} = Dexcord.Message.reply(msg, "pong") + + assert_receive {:rest_hit, %{path: "/channels/5/messages", body: body}} + decoded = JSON.decode!(body) + + assert decoded["content"] == "pong" + assert decoded["message_reference"] == %{"message_id" => "555"} + refute Map.has_key?(decoded, "allowed_mentions") + end + + test "mention_author: false sets allowed_mentions.replied_user to false", %{msg: msg} do + assert {:ok, %Dexcord.Message{}} = Dexcord.Message.reply(msg, "pong", mention_author: false) + + assert_receive {:rest_hit, %{path: "/channels/5/messages", body: body}} + decoded = JSON.decode!(body) + + assert decoded["allowed_mentions"] == %{"replied_user" => false} + assert decoded["message_reference"] == %{"message_id" => "555"} + end + + test "mention_author: true preserves other allowed_mentions keys and wins over the body", + %{msg: msg} do + body = %{ + "content" => "pong", + "allowed_mentions" => %{"users" => ["7"], "replied_user" => false} + } + + assert {:ok, %Dexcord.Message{}} = Dexcord.Message.reply(msg, body, mention_author: true) + + assert_receive {:rest_hit, %{path: "/channels/5/messages", body: raw}} + decoded = JSON.decode!(raw) + + assert decoded["allowed_mentions"] == %{"users" => ["7"], "replied_user" => true} + end + end + + describe "AC3.5: config-level allowed_mentions default merges field-wise per send" do + setup do + FakeRest.stub(:post, "/channels/1/messages", FakeRest.resp(200, body: ~s({"id":"1"}))) + :ok + end + + defp put_config_with(allowed_mentions) do + Dexcord.Config.put(%{ + token: @token, + handler: nil, + intents: 0, + allowed_mentions: allowed_mentions + }) + end + + test "the configured default applies when the send carries none" do + put_config_with(%{"parse" => []}) + + assert {:ok, %Dexcord.Message{}} = Api.send(1, "hi") + + assert_receive {:rest_hit, %{path: "/channels/1/messages", body: body}} + decoded = JSON.decode!(body) + + assert decoded["allowed_mentions"] == %{"parse" => []} + end + + test "a per-send value merges field-wise over the default (both keys survive)" do + put_config_with(%{"parse" => []}) + + body = %{"content" => "hi", "allowed_mentions" => %{"users" => ["5"]}} + assert {:ok, %Dexcord.Message{}} = Api.send(1, body) + + assert_receive {:rest_hit, %{path: "/channels/1/messages", body: raw}} + decoded = JSON.decode!(raw) + + assert decoded["allowed_mentions"] == %{"parse" => [], "users" => ["5"]} + end + + test "no config default and no per-send value leaves allowed_mentions absent" do + # Base config (no :allowed_mentions) is already in place from the top setup. + assert {:ok, %Dexcord.Message{}} = Api.send(1, "hi") + + assert_receive {:rest_hit, %{path: "/channels/1/messages", body: body}} + decoded = JSON.decode!(body) + + refute Map.has_key?(decoded, "allowed_mentions") + end + end + + describe "AC3.6: an Embed built with the builder rides Api.send as valid wire JSON" do + setup do + FakeRest.stub(:post, "/channels/1/messages", FakeRest.resp(200, body: ~s({"id":"1"}))) + :ok + end + + test "send/2 with embeds: [embed] serializes the embed" do + embed = + Dexcord.Embed.new() + |> Dexcord.Embed.title("t") + |> Dexcord.Embed.field("a", "b", inline: true) + + assert {:ok, %Dexcord.Message{}} = Api.send(1, embeds: [embed]) + + assert_receive {:rest_hit, %{path: "/channels/1/messages", body: body}} + decoded = JSON.decode!(body) + + assert decoded["embeds"] == [ + %{ + "title" => "t", + "fields" => [%{"name" => "a", "value" => "b", "inline" => true}] + } + ] + end + end +end diff --git a/test/dexcord/slash_test.exs b/test/dexcord/slash_test.exs index f1aac24..cacc85e 100644 --- a/test/dexcord/slash_test.exs +++ b/test/dexcord/slash_test.exs @@ -5,6 +5,7 @@ defmodule Dexcord.SlashTest do alias Dexcord.Api.Ratelimit alias Dexcord.FakeRest + alias Dexcord.Interaction alias Dexcord.Slash @token "test.token.value" @@ -50,25 +51,37 @@ defmodule Dexcord.SlashTest do end test "dispatch/2 routes a type-2 interaction to handle_interaction/2 by name" do - itx = %{"type" => 2, "data" => %{"name" => "ping"}, "id" => "1"} + itx = Interaction.from_map(%{"type" => 2, "data" => %{"name" => "ping"}, "id" => "1"}) assert Slash.dispatch(itx, Commands) == :ok - assert_received {:handled, "ping", ^itx} + assert_received {:handled, "ping", %Dexcord.Interaction{type: :application_command}} end test "dispatch/2 routes a type-3 (component) interaction to handle_component/2 by custom_id" do - itx = %{"type" => 3, "data" => %{"custom_id" => "refresh"}, "id" => "2"} + itx = + Interaction.from_map(%{"type" => 3, "data" => %{"custom_id" => "refresh"}, "id" => "2"}) + assert Slash.dispatch(itx, Commands) == :ok - assert_received {:component, "refresh", ^itx} + assert_received {:component, "refresh", %Dexcord.Interaction{type: :message_component}} end test "dispatch/2 routes a type-5 (modal) interaction to handle_modal/2 by custom_id" do - itx = %{"type" => 5, "data" => %{"custom_id" => "feedback"}, "id" => "3"} + itx = + Interaction.from_map(%{"type" => 5, "data" => %{"custom_id" => "feedback"}, "id" => "3"}) + assert Slash.dispatch(itx, Commands) == :ok - assert_received {:modal, "feedback", ^itx} + assert_received {:modal, "feedback", %Dexcord.Interaction{type: :modal_submit}} + end + + test "the raw-map degraded path still routes on the integer type" do + # A malformed interaction the dispatcher could not decode into a struct is + # routed here as a raw map; it must still reach the callback by integer type. + raw = %{"type" => 2, "data" => %{"name" => "ping"}, "id" => "1"} + assert Slash.dispatch(raw, Commands) == :ok + assert_received {:handled, "ping", ^raw} end test "the injected catch-all logs a warning for an unhandled command name" do - itx = %{"type" => 2, "data" => %{"name" => "unknown"}} + itx = Interaction.from_map(%{"type" => 2, "data" => %{"name" => "unknown"}}) log = capture_log(fn -> @@ -80,8 +93,8 @@ defmodule Dexcord.SlashTest do end test "the injected component/modal catch-alls log at debug, not warning" do - component = %{"type" => 3, "data" => %{"custom_id" => "nope"}} - modal = %{"type" => 5, "data" => %{"custom_id" => "nope"}} + component = Interaction.from_map(%{"type" => 3, "data" => %{"custom_id" => "nope"}}) + modal = Interaction.from_map(%{"type" => 5, "data" => %{"custom_id" => "nope"}}) log = capture_log([level: :debug], fn -> @@ -95,12 +108,12 @@ defmodule Dexcord.SlashTest do end test "a module defining only handle_interaction/2 still routes components/modals via defaults" do - itx2 = %{"type" => 2, "data" => %{"name" => "ping"}} + itx2 = Interaction.from_map(%{"type" => 2, "data" => %{"name" => "ping"}}) Slash.dispatch(itx2, LegacyCommands) - assert_received {:legacy, ^itx2} + assert_received {:legacy, %Dexcord.Interaction{type: :application_command}} - component = %{"type" => 3, "data" => %{"custom_id" => "x"}} - modal = %{"type" => 5, "data" => %{"custom_id" => "y"}} + component = Interaction.from_map(%{"type" => 3, "data" => %{"custom_id" => "x"}}) + modal = Interaction.from_map(%{"type" => 5, "data" => %{"custom_id" => "y"}}) capture_log([level: :debug], fn -> assert Slash.dispatch(component, LegacyCommands) == :ignore @@ -125,13 +138,14 @@ defmodule Dexcord.SlashTest do # Interaction/application ids must be numeric snowflakes: the typed endpoint # surface casts `:interaction_id` / `:application_id` path params via - # `Dexcord.Snowflake.cast/1`. The interaction token is an opaque string. - @itx %{ - "id" => "100", - "token" => "int-token", - "application_id" => "200", - "data" => %{"name" => "ping"} - } + # `Dexcord.Snowflake.cast/1`. The interaction token is an opaque string. The + # response helpers take a decoded `%Dexcord.Interaction{}`, so build one. + @itx Interaction.from_map(%{ + "id" => "100", + "token" => "int-token", + "application_id" => "200", + "data" => %{"name" => "ping"} + }) test "respond/2 with a string sends a type-4 content response to the callback route" do FakeRest.stub(:post, "/interactions/100/int-token/callback", FakeRest.resp(204)) @@ -158,6 +172,19 @@ defmodule Dexcord.SlashTest do assert body["data"]["embeds"] == [%{"title" => "x"}] end + test "respond/2 encodes struct data values (e.g. an %Embed{}) to wire maps" do + FakeRest.stub(:post, "/interactions/100/int-token/callback", FakeRest.resp(204)) + + assert {:ok, nil} = Slash.respond(@itx, %{embeds: [%Dexcord.Embed{title: "t"}]}) + + assert_receive {:rest_hit, info} + body = JSON.decode!(info.body) + # The %Embed{} struct is run through its own to_map/1: string-keyed wire map, + # title preserved, empty-list defaults (e.g. fields) retained as the encoder emits. + assert [%{"title" => "t"} = embed] = body["data"]["embeds"] + assert embed["fields"] == [] + end + test "respond/2 accepts string-keyed data maps too" do FakeRest.stub(:post, "/interactions/100/int-token/callback", FakeRest.resp(204))