diff --git a/README.md b/README.md index 939dc7a..e0fabad 100644 --- a/README.md +++ b/README.md @@ -112,21 +112,18 @@ 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. `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): +`{event_name, data}` tuple, where `data` is the raw string-keyed payload map +Discord sent (no atom keys, no structs): ```elixir defmodule MyBot.Handler do use Dexcord.Handler - def handle_event({:MESSAGE_CREATE, %Dexcord.Message{} = msg}) do - IO.puts("#{msg.author.username}: #{msg.content}") + def handle_event({:MESSAGE_CREATE, msg}) do + IO.puts("#{msg["author"]["username"]}: #{msg["content"]}") end - def handle_event({:PRESENCE_UPDATE, %Dexcord.Presence{} = presence}) do + def handle_event({:PRESENCE_UPDATE, presence}) do # only fires if cache_presences: true and presences are being cached end end @@ -147,30 +144,28 @@ handler's `MESSAGE_CREATE` clause: defmodule MyBot.Commands do use Dexcord.Prefix.Router - def handle_command("ping", _args, %Dexcord.Message{} = msg) do - Dexcord.Message.reply(msg, "pong") + def handle_command("ping", _args, msg) do + Dexcord.Api.create_message(msg["channel_id"], "pong") end - def handle_command("echo", args, %Dexcord.Message{} = msg) do - Dexcord.Api.send(msg.channel_id, Enum.join(args, " ")) + def handle_command("echo", args, msg) do + Dexcord.Api.create_message(msg["channel_id"], Enum.join(args, " ")) end end defmodule MyBot.Handler do use Dexcord.Handler - def handle_event({:MESSAGE_CREATE, %Dexcord.Message{} = msg}) do + def handle_event({:MESSAGE_CREATE, msg}) do Dexcord.Prefix.dispatch(msg, prefix: "!", to: MyBot.Commands) end end ``` -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 +`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 function if you want the prefix/command/args split without the bot-author check or the router dispatch. @@ -332,20 +327,13 @@ 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 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() +Typed endpoints (thin wrappers, string-keyed request/response maps): +``` 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 get_channel_messages/2 +delete_message/2 create_reaction/3 create_interaction_response/3 edit_original_interaction_response/3 create_followup_message/3 @@ -353,15 +341,8 @@ bulk_overwrite_global_commands/2 bulk_overwrite_guild_commands/3 ``` -`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: +For anything not covered, `Dexcord.Api.request/4` is the escape hatch every +typed endpoint is built on: ```elixir Dexcord.Api.request(:patch, "/guilds/#{guild_id}", %{"name" => "New Name"}) @@ -441,19 +422,16 @@ running untouched. | Nostrum | Dexcord | |---|---| | `Nostrum.Consumer` `handle_event/1` callback | `Dexcord.Handler` `handle_event/1` callback (`use Dexcord.Handler`) | -| `Nostrum.Api.*` | `Dexcord.Api.*` (typed struct returns + `request/4` escape hatch) | +| `Nostrum.Api.*` | `Dexcord.Api.*` (typed endpoints + `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) | `%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 | +| `%Nostrum.Struct.Message{}` etc. (atom-keyed structs) | string-keyed maps everywhere - `msg["content"]`, not `msg.content` | | Auto-starting `:nostrum` application | you add `{Dexcord, opts}` to your own supervision tree | -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). +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. ## Reliability design diff --git a/docs/alamedya-migration-v2.md b/docs/alamedya-migration-v2.md deleted file mode 100644 index a4b862d..0000000 --- a/docs/alamedya-migration-v2.md +++ /dev/null @@ -1,482 +0,0 @@ -# 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 deleted file mode 100644 index 99ce4ea..0000000 --- a/docs/test-plans/2026-07-05-api-surface.md +++ /dev/null @@ -1,84 +0,0 @@ -# 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 df31e9c..bb97fbc 100644 --- a/lib/dexcord.ex +++ b/lib/dexcord.ex @@ -81,8 +81,6 @@ 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, @@ -92,8 +90,7 @@ defmodule Dexcord do request_guild_members: request_guild_members, slash: slash, slash_guild_ids: slash_guild_ids, - gateway_url: gateway_url, - allowed_mentions: allowed_mentions + gateway_url: gateway_url } end @@ -156,21 +153,6 @@ 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 99bbc46..753c67f 100644 --- a/lib/dexcord/api.ex +++ b/lib/dexcord/api.ex @@ -375,238 +375,6 @@ 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 deleted file mode 100644 index 29f28c6..0000000 --- a/lib/dexcord/api/paginate.ex +++ /dev/null @@ -1,54 +0,0 @@ -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 fc40524..e0ad29d 100644 --- a/lib/dexcord/cache.ex +++ b/lib/dexcord/cache.ex @@ -21,17 +21,6 @@ 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 | @@ -70,10 +59,6 @@ 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. @@ -100,7 +85,6 @@ defmodule Dexcord.Cache do :ets.new(@roles, oset) :ets.new(@presences, oset) :ets.new(@voice_states, oset) - :ets.new(@dm_channels, set) {:ok, %{}} end @@ -582,70 +566,6 @@ 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 c352113..7e727fe 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 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. + # 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). 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(decoded, slash_mod) + route_slash(raw, 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 deleted file mode 100644 index dcbe82e..0000000 --- a/lib/dexcord/messageable.ex +++ /dev/null @@ -1,45 +0,0 @@ -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 43dbc42..cc880b8 100644 --- a/lib/dexcord/model/allowed_mentions.ex +++ b/lib/dexcord/model/allowed_mentions.ex @@ -8,27 +8,4 @@ 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 fd67be4..342395f 100644 --- a/lib/dexcord/model/channel.ex +++ b/lib/dexcord/model/channel.ex @@ -30,10 +30,6 @@ 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 @@ -49,10 +45,6 @@ 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 @@ -68,10 +60,6 @@ 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 @@ -87,10 +75,6 @@ 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 @@ -100,10 +84,6 @@ 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 @@ -113,10 +93,6 @@ 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 @@ -135,10 +111,6 @@ 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 @@ -156,10 +128,6 @@ 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 @@ -174,10 +142,6 @@ 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 @@ -197,10 +161,6 @@ 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 @@ -226,10 +186,6 @@ 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 @@ -298,32 +254,6 @@ 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 57710fd..447107a 100644 --- a/lib/dexcord/model/embed.ex +++ b/lib/dexcord/model/embed.ex @@ -1,3 +1,25 @@ +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 @@ -92,67 +114,3 @@ 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 e2d1a02..f37fed6 100644 --- a/lib/dexcord/model/emoji.ex +++ b/lib/dexcord/model/emoji.ex @@ -12,10 +12,6 @@ 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 @@ -26,27 +22,9 @@ 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 f91be66..4998b80 100644 --- a/lib/dexcord/model/guild.ex +++ b/lib/dexcord/model/guild.ex @@ -57,136 +57,6 @@ 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 d9b2245..95f2e2b 100644 --- a/lib/dexcord/model/member.ex +++ b/lib/dexcord/model/member.ex @@ -34,23 +34,6 @@ 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 80145c9..6f21cf6 100644 --- a/lib/dexcord/model/message.ex +++ b/lib/dexcord/model/message.ex @@ -44,47 +44,6 @@ 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 3ff2779..5f781ea 100644 --- a/lib/dexcord/model/role.ex +++ b/lib/dexcord/model/role.ex @@ -17,18 +17,6 @@ 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 6ed188a..873ec01 100644 --- a/lib/dexcord/model/user.ex +++ b/lib/dexcord/model/user.ex @@ -23,18 +23,6 @@ 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 984dbe7..17a3c97 100644 --- a/lib/dexcord/model/webhook.ex +++ b/lib/dexcord/model/webhook.ex @@ -16,8 +16,4 @@ 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 7aafd26..c10c435 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 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 + `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 `Dexcord.Dispatcher` calls it automatically for those types when a `slash:` - module is configured (the event still reaches the handler too). Every callback - receives the full `%Dexcord.Interaction{}` as its second argument. + module is configured (the raw event still reaches the handler). ## Response helpers @@ -56,22 +56,13 @@ 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() | nil, - interaction :: Dexcord.Interaction.t() - ) :: any() + @callback handle_interaction(name :: String.t(), interaction :: map()) :: any() @doc "Handles a routed message-component interaction (type 3) for `custom_id`." - @callback handle_component( - custom_id :: String.t() | nil, - interaction :: Dexcord.Interaction.t() - ) :: any() + @callback handle_component(custom_id :: String.t() | nil, interaction :: map()) :: any() @doc "Handles a routed modal-submit interaction (type 5) for `custom_id`." - @callback handle_modal( - custom_id :: String.t() | nil, - interaction :: Dexcord.Interaction.t() - ) :: any() + @callback handle_modal(custom_id :: String.t() | nil, interaction :: map()) :: 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 @@ -121,42 +112,21 @@ defmodule Dexcord.Slash do # --- routing ------------------------------------------------------------ @doc """ - Routes a decoded `%Dexcord.Interaction{}` to `mod` on its `type` atom. + Routes an `INTERACTION_CREATE` payload to `mod` on its top-level `"type"`. - * `: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` + * 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"]` - 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. + Any other (or missing) type is ignored - the `Dexcord.Dispatcher` only routes + types 2/3/5 here, so this is defensive. """ - @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 + @spec dispatch(map(), module()) :: any() + def dispatch(interaction, mod) when is_map(interaction) and is_atom(mod) do + case interaction["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) @@ -164,12 +134,6 @@ 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 """ @@ -178,16 +142,15 @@ 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(Dexcord.Interaction.t(), String.t() | map()) :: - {:ok, map()} | {:ok, nil} | {:error, term()} + @spec respond(map(), 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(%Dexcord.Interaction{} = interaction, %{} = data) do + def respond(interaction, %{} = data) do Dexcord.Api.create_interaction_response( - interaction.id, - interaction.token, + interaction["id"], + interaction["token"], %{"type" => 4, "data" => message_data(data)} ) end @@ -196,11 +159,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(Dexcord.Interaction.t()) :: {:ok, map()} | {:ok, nil} | {:error, term()} - def respond_later(%Dexcord.Interaction{} = interaction) do + @spec respond_later(map()) :: {:ok, map()} | {:ok, nil} | {:error, term()} + def respond_later(interaction) do Dexcord.Api.create_interaction_response( - interaction.id, - interaction.token, + interaction["id"], + interaction["token"], %{"type" => 5} ) end @@ -210,15 +173,15 @@ defmodule Dexcord.Slash do `text_or_map` is a binary (used as `content`) or a map as in `respond/2`. """ - @spec followup(Dexcord.Interaction.t(), String.t() | map()) :: {:ok, map()} | {:error, term()} + @spec followup(map(), String.t() | map()) :: {:ok, map()} | {:error, term()} def followup(interaction, content) when is_binary(content) do followup(interaction, %{content: content}) end - def followup(%Dexcord.Interaction{} = interaction, %{} = data) do + def followup(interaction, %{} = data) do Dexcord.Api.create_followup_message( - interaction.application_id, - interaction.token, + interaction["application_id"], + interaction["token"], message_data(data) ) end @@ -228,16 +191,15 @@ defmodule Dexcord.Slash do `text_or_map` is a binary (used as `content`) or a map as in `respond/2`. """ - @spec edit_response(Dexcord.Interaction.t(), String.t() | map()) :: - {:ok, map()} | {:error, term()} + @spec edit_response(map(), 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(%Dexcord.Interaction{} = interaction, %{} = data) do + def edit_response(interaction, %{} = data) do Dexcord.Api.edit_original_interaction_response( - interaction.application_id, - interaction.token, + interaction["application_id"], + interaction["token"], message_data(data) ) end @@ -251,7 +213,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), encode_values(value)) + {:ok, value} -> Map.put(acc, Atom.to_string(field), value) :error -> acc end end) @@ -262,14 +224,6 @@ 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 a3fc80e..cd731c5 100644 --- a/lib/dexcord/slash/registrar.ex +++ b/lib/dexcord/slash/registrar.ex @@ -99,15 +99,7 @@ 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 - # 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) + commands = config.slash.commands() 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 deleted file mode 100644 index ccc0d6d..0000000 --- a/lib/dexcord/util.ex +++ /dev/null @@ -1,22 +0,0 @@ -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 deleted file mode 100644 index 6e9174a..0000000 --- a/test/dexcord/cache_fill_test.exs +++ /dev/null @@ -1,103 +0,0 @@ -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 841b494..a591a36 100644 --- a/test/dexcord/config_validation_test.exs +++ b/test/dexcord/config_validation_test.exs @@ -75,25 +75,4 @@ 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 deleted file mode 100644 index 8c4d7f5..0000000 --- a/test/dexcord/ergonomics_helpers_test.exs +++ /dev/null @@ -1,131 +0,0 @@ -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 e1341f6..0b5ac58 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", %Dexcord.Interaction{type: :application_command}}, @timeout + assert_receive {:slash, "ping", ^itx}, @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 f7625bb..f51f7c3 100644 --- a/test/dexcord/gateway_integration_test.exs +++ b/test/dexcord/gateway_integration_test.exs @@ -98,7 +98,6 @@ 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 @@ -259,7 +258,6 @@ 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 @@ -292,7 +290,6 @@ 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 @@ -305,7 +302,6 @@ 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 @@ -551,7 +547,6 @@ 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 @@ -567,7 +562,6 @@ 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 deleted file mode 100644 index 4374ed2..0000000 --- a/test/dexcord/messageable_test.exs +++ /dev/null @@ -1,79 +0,0 @@ -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 deleted file mode 100644 index 939c63d..0000000 --- a/test/dexcord/migration_guide_samples_test.exs +++ /dev/null @@ -1,193 +0,0 @@ -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 7b38e08..3810147 100644 --- a/test/dexcord/model_message_parts_test.exs +++ b/test/dexcord/model_message_parts_test.exs @@ -139,100 +139,4 @@ 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 deleted file mode 100644 index 85d63dd..0000000 --- a/test/dexcord/pagination_test.exs +++ /dev/null @@ -1,177 +0,0 @@ -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 deleted file mode 100644 index 543d704..0000000 --- a/test/dexcord/permissions_compute_test.exs +++ /dev/null @@ -1,153 +0,0 @@ -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 deleted file mode 100644 index e99e4d9..0000000 --- a/test/dexcord/send_test.exs +++ /dev/null @@ -1,219 +0,0 @@ -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 cacc85e..f1aac24 100644 --- a/test/dexcord/slash_test.exs +++ b/test/dexcord/slash_test.exs @@ -5,7 +5,6 @@ defmodule Dexcord.SlashTest do alias Dexcord.Api.Ratelimit alias Dexcord.FakeRest - alias Dexcord.Interaction alias Dexcord.Slash @token "test.token.value" @@ -51,37 +50,25 @@ defmodule Dexcord.SlashTest do end test "dispatch/2 routes a type-2 interaction to handle_interaction/2 by name" do - itx = Interaction.from_map(%{"type" => 2, "data" => %{"name" => "ping"}, "id" => "1"}) + itx = %{"type" => 2, "data" => %{"name" => "ping"}, "id" => "1"} assert Slash.dispatch(itx, Commands) == :ok - assert_received {:handled, "ping", %Dexcord.Interaction{type: :application_command}} + assert_received {:handled, "ping", ^itx} end test "dispatch/2 routes a type-3 (component) interaction to handle_component/2 by custom_id" do - itx = - Interaction.from_map(%{"type" => 3, "data" => %{"custom_id" => "refresh"}, "id" => "2"}) - + itx = %{"type" => 3, "data" => %{"custom_id" => "refresh"}, "id" => "2"} assert Slash.dispatch(itx, Commands) == :ok - assert_received {:component, "refresh", %Dexcord.Interaction{type: :message_component}} + assert_received {:component, "refresh", ^itx} end test "dispatch/2 routes a type-5 (modal) interaction to handle_modal/2 by custom_id" do - itx = - Interaction.from_map(%{"type" => 5, "data" => %{"custom_id" => "feedback"}, "id" => "3"}) - + itx = %{"type" => 5, "data" => %{"custom_id" => "feedback"}, "id" => "3"} assert Slash.dispatch(itx, Commands) == :ok - 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} + assert_received {:modal, "feedback", ^itx} end test "the injected catch-all logs a warning for an unhandled command name" do - itx = Interaction.from_map(%{"type" => 2, "data" => %{"name" => "unknown"}}) + itx = %{"type" => 2, "data" => %{"name" => "unknown"}} log = capture_log(fn -> @@ -93,8 +80,8 @@ defmodule Dexcord.SlashTest do end test "the injected component/modal catch-alls log at debug, not warning" do - component = Interaction.from_map(%{"type" => 3, "data" => %{"custom_id" => "nope"}}) - modal = Interaction.from_map(%{"type" => 5, "data" => %{"custom_id" => "nope"}}) + component = %{"type" => 3, "data" => %{"custom_id" => "nope"}} + modal = %{"type" => 5, "data" => %{"custom_id" => "nope"}} log = capture_log([level: :debug], fn -> @@ -108,12 +95,12 @@ defmodule Dexcord.SlashTest do end test "a module defining only handle_interaction/2 still routes components/modals via defaults" do - itx2 = Interaction.from_map(%{"type" => 2, "data" => %{"name" => "ping"}}) + itx2 = %{"type" => 2, "data" => %{"name" => "ping"}} Slash.dispatch(itx2, LegacyCommands) - assert_received {:legacy, %Dexcord.Interaction{type: :application_command}} + assert_received {:legacy, ^itx2} - component = Interaction.from_map(%{"type" => 3, "data" => %{"custom_id" => "x"}}) - modal = Interaction.from_map(%{"type" => 5, "data" => %{"custom_id" => "y"}}) + component = %{"type" => 3, "data" => %{"custom_id" => "x"}} + modal = %{"type" => 5, "data" => %{"custom_id" => "y"}} capture_log([level: :debug], fn -> assert Slash.dispatch(component, LegacyCommands) == :ignore @@ -138,14 +125,13 @@ 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. 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"} - }) + # `Dexcord.Snowflake.cast/1`. The interaction token is an opaque string. + @itx %{ + "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)) @@ -172,19 +158,6 @@ 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))