Compare commits
10 commits
cdca1f13ce
...
ca5b3cf599
| Author | SHA1 | Date | |
|---|---|---|---|
| ca5b3cf599 | |||
| ce772b369e | |||
| eabc87288d | |||
| b9f1ced136 | |||
| 8070f11aa2 | |||
| 99208f2212 | |||
| 8a80957faf | |||
| 5169107563 | |||
| c3c483017d | |||
| 07ca39e5a7 |
34 changed files with 2785 additions and 114 deletions
74
README.md
74
README.md
|
|
@ -112,18 +112,21 @@ whether prefix or slash routing also fires.
|
||||||
### 1. Raw handler events
|
### 1. Raw handler events
|
||||||
|
|
||||||
Every gateway dispatch event reaches your `Dexcord.Handler` as a
|
Every gateway dispatch event reaches your `Dexcord.Handler` as a
|
||||||
`{event_name, data}` tuple, where `data` is the raw string-keyed payload map
|
`{event_name, data}` tuple. `data` is decoded exactly once, in the dispatcher,
|
||||||
Discord sent (no atom keys, no structs):
|
into a typed model **struct** with typed fields and **integer** snowflake ids -
|
||||||
|
`%Dexcord.Message{}`, `%Dexcord.Guild{}`, `%Dexcord.Interaction{}`, and friends
|
||||||
|
(a malformed payload that fails to decode falls back to the raw string-keyed map
|
||||||
|
so dispatch never stalls):
|
||||||
|
|
||||||
```elixir
|
```elixir
|
||||||
defmodule MyBot.Handler do
|
defmodule MyBot.Handler do
|
||||||
use Dexcord.Handler
|
use Dexcord.Handler
|
||||||
|
|
||||||
def handle_event({:MESSAGE_CREATE, msg}) do
|
def handle_event({:MESSAGE_CREATE, %Dexcord.Message{} = msg}) do
|
||||||
IO.puts("#{msg["author"]["username"]}: #{msg["content"]}")
|
IO.puts("#{msg.author.username}: #{msg.content}")
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_event({:PRESENCE_UPDATE, presence}) do
|
def handle_event({:PRESENCE_UPDATE, %Dexcord.Presence{} = presence}) do
|
||||||
# only fires if cache_presences: true and presences are being cached
|
# only fires if cache_presences: true and presences are being cached
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -144,28 +147,30 @@ handler's `MESSAGE_CREATE` clause:
|
||||||
defmodule MyBot.Commands do
|
defmodule MyBot.Commands do
|
||||||
use Dexcord.Prefix.Router
|
use Dexcord.Prefix.Router
|
||||||
|
|
||||||
def handle_command("ping", _args, msg) do
|
def handle_command("ping", _args, %Dexcord.Message{} = msg) do
|
||||||
Dexcord.Api.create_message(msg["channel_id"], "pong")
|
Dexcord.Message.reply(msg, "pong")
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_command("echo", args, msg) do
|
def handle_command("echo", args, %Dexcord.Message{} = msg) do
|
||||||
Dexcord.Api.create_message(msg["channel_id"], Enum.join(args, " "))
|
Dexcord.Api.send(msg.channel_id, Enum.join(args, " "))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defmodule MyBot.Handler do
|
defmodule MyBot.Handler do
|
||||||
use Dexcord.Handler
|
use Dexcord.Handler
|
||||||
|
|
||||||
def handle_event({:MESSAGE_CREATE, msg}) do
|
def handle_event({:MESSAGE_CREATE, %Dexcord.Message{} = msg}) do
|
||||||
Dexcord.Prefix.dispatch(msg, prefix: "!", to: MyBot.Commands)
|
Dexcord.Prefix.dispatch(msg, prefix: "!", to: MyBot.Commands)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
```
|
```
|
||||||
|
|
||||||
`dispatch/2` skips messages authored by a bot (including the bot's own
|
The router receives the decoded `%Dexcord.Message{}`, so command handlers read
|
||||||
messages, checked both via the payload's `author.bot` flag and against the
|
typed fields (`msg.channel_id`, `msg.author.id`) and reply with
|
||||||
cached bot user id) so a command that replies in-channel can't recursively
|
`Dexcord.Message.reply/2` or `Dexcord.Api.send/2`. `dispatch/2` skips messages
|
||||||
trigger itself. `Dexcord.Prefix.parse/2` is exposed separately as a pure
|
authored by a bot (including the bot's own messages, checked both via the
|
||||||
|
`author.bot` flag and against the cached bot user id) so a command that replies
|
||||||
|
in-channel can't recursively trigger itself. `Dexcord.Prefix.parse/2` is exposed separately as a pure
|
||||||
function if you want the prefix/command/args split without the bot-author
|
function if you want the prefix/command/args split without the bot-author
|
||||||
check or the router dispatch.
|
check or the router dispatch.
|
||||||
|
|
||||||
|
|
@ -327,13 +332,20 @@ limiting - `Dexcord.Api.Ratelimit` learns each route's bucket from response
|
||||||
headers and makes the calling process sleep as needed; you never manage
|
headers and makes the calling process sleep as needed; you never manage
|
||||||
rate limits yourself.
|
rate limits yourself.
|
||||||
|
|
||||||
Typed endpoints (thin wrappers, string-keyed request/response maps):
|
Typed endpoints take string-keyed request maps (or a bare binary where a
|
||||||
|
`content`/`name` wrap is natural) and **decode their responses into model
|
||||||
|
structs** - `{:ok, %Dexcord.Message{}}`, `{:ok, %Dexcord.User{}}`,
|
||||||
|
`{:ok, %Dexcord.Channel{}}` (the concrete per-type struct), a list of structs
|
||||||
|
for list endpoints, or `{:error, %Dexcord.Api.Error{}}`:
|
||||||
|
|
||||||
|
```elixir
|
||||||
|
{:ok, %Dexcord.Message{} = sent} = Dexcord.Api.create_message(channel_id, "hi")
|
||||||
|
{:ok, %Dexcord.User{} = me} = Dexcord.Api.get_current_user()
|
||||||
|
|
||||||
```
|
|
||||||
get_gateway_bot/0 get_current_user/0 get_current_application/0
|
get_gateway_bot/0 get_current_user/0 get_current_application/0
|
||||||
get_user/1 get_channel/1 get_guild/1
|
get_user/1 get_channel/1 get_guild/1
|
||||||
create_dm/1 create_message/2 edit_message/3
|
create_dm/1 create_message/2 edit_message/3
|
||||||
delete_message/2 create_reaction/3
|
delete_message/2 create_reaction/3 get_channel_messages/2
|
||||||
create_interaction_response/3
|
create_interaction_response/3
|
||||||
edit_original_interaction_response/3
|
edit_original_interaction_response/3
|
||||||
create_followup_message/3
|
create_followup_message/3
|
||||||
|
|
@ -341,8 +353,15 @@ bulk_overwrite_global_commands/2
|
||||||
bulk_overwrite_guild_commands/3
|
bulk_overwrite_guild_commands/3
|
||||||
```
|
```
|
||||||
|
|
||||||
For anything not covered, `Dexcord.Api.request/4` is the escape hatch every
|
`Dexcord.Api.send/2` is the ergonomic front door over `create_message`: it
|
||||||
typed endpoint is built on:
|
accepts anything `Dexcord.Messageable` - a channel or thread struct, a
|
||||||
|
`%Dexcord.Message{}` (posts to its channel), a `%Dexcord.User{}`/`%Dexcord.Member{}`
|
||||||
|
(lazily opening and caching a DM), or a bare integer channel id - and applies the
|
||||||
|
configured `allowed_mentions` default. List endpoints also come as lazy streams
|
||||||
|
(`message_history/2`, `guild_members_stream/2`, ...) that page on demand.
|
||||||
|
|
||||||
|
For anything without a typed wrapper, `Dexcord.Api.request/4` is the escape hatch
|
||||||
|
every typed endpoint is built on - it stays string-keyed both ways:
|
||||||
|
|
||||||
```elixir
|
```elixir
|
||||||
Dexcord.Api.request(:patch, "/guilds/#{guild_id}", %{"name" => "New Name"})
|
Dexcord.Api.request(:patch, "/guilds/#{guild_id}", %{"name" => "New Name"})
|
||||||
|
|
@ -422,16 +441,19 @@ running untouched.
|
||||||
| Nostrum | Dexcord |
|
| Nostrum | Dexcord |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `Nostrum.Consumer` `handle_event/1` callback | `Dexcord.Handler` `handle_event/1` callback (`use Dexcord.Handler`) |
|
| `Nostrum.Consumer` `handle_event/1` callback | `Dexcord.Handler` `handle_event/1` callback (`use Dexcord.Handler`) |
|
||||||
| `Nostrum.Api.*` | `Dexcord.Api.*` (typed endpoints + `request/4` escape hatch) |
|
| `Nostrum.Api.*` | `Dexcord.Api.*` (typed struct returns + `request/4` escape hatch) |
|
||||||
| `Nostrum.Cache.*` (several cache modules) | `Dexcord.Cache` (one module, one ETS table per entity type) |
|
| `Nostrum.Cache.*` (several cache modules) | `Dexcord.Cache` (one module, one ETS table per entity type) |
|
||||||
| `%Nostrum.Struct.Message{}` etc. (atom-keyed structs) | string-keyed maps everywhere - `msg["content"]`, not `msg.content` |
|
| `%Nostrum.Struct.Message{}` etc. (atom-keyed structs) | `%Dexcord.Message{}` etc. (typed structs, integer snowflake ids) - `msg.content`, `msg.author.id` |
|
||||||
|
| snowflake ids parsed to integers | snowflake ids **are** integers on every decoded struct |
|
||||||
| Auto-starting `:nostrum` application | you add `{Dexcord, opts}` to your own supervision tree |
|
| Auto-starting `:nostrum` application | you add `{Dexcord, opts}` to your own supervision tree |
|
||||||
|
|
||||||
The biggest day-to-day adjustment is the lack of structs: every payload -
|
Like Nostrum, Dexcord hands your handler decoded **structs** with typed fields
|
||||||
messages, guilds, members, interactions - is the raw string-keyed map
|
and integer ids, so day-to-day access is `msg.content` / `msg.author.id`, not
|
||||||
Discord sent over the wire. This costs `msg["content"]` instead of
|
map indexing. The escape hatch below the model layer - `Dexcord.Api.request/4` -
|
||||||
`msg.content`, but means Dexcord never has to guess a struct shape ahead of a
|
stays string-keyed both ways for endpoints without a typed wrapper. A full
|
||||||
Discord API change, and never mints atoms from wire data.
|
worked port (READY backfill, MESSAGE_CREATE flow, thread cache reads, integer
|
||||||
|
snowflakes, REST, hydration) lives in
|
||||||
|
[`docs/alamedya-migration-v2.md`](docs/alamedya-migration-v2.md).
|
||||||
|
|
||||||
## Reliability design
|
## Reliability design
|
||||||
|
|
||||||
|
|
|
||||||
482
docs/alamedya-migration-v2.md
Normal file
482
docs/alamedya-migration-v2.md
Normal file
|
|
@ -0,0 +1,482 @@
|
||||||
|
# Migrating Alamedya from Nostrum to Dexcord (v2 — the typed contract)
|
||||||
|
|
||||||
|
This is the v2 migration guide, rewritten against Dexcord's **typed struct
|
||||||
|
contract**. It supersedes the older internal notes: every gateway payload now
|
||||||
|
arrives as a decoded model **struct** (`%Dexcord.Message{}`, `%Dexcord.Guild{}`,
|
||||||
|
`%Dexcord.Interaction{}`, …) with **integer** snowflake ids and typed nested
|
||||||
|
fields — not the raw string-keyed maps the first draft assumed.
|
||||||
|
|
||||||
|
It is self-contained. It is written for a Claude thread (or engineer) with **no
|
||||||
|
prior context** on either codebase. Follow it top to bottom.
|
||||||
|
|
||||||
|
- **Source app:** Alamedya — a Phoenix app whose Discord side currently runs on
|
||||||
|
Nostrum behind a hand-rolled discord.py bridge.
|
||||||
|
- **Target library:** Dexcord — a reliability-first, single-shard Discord library
|
||||||
|
that owns its own gateway (resume-over-reidentify, zombie detection,
|
||||||
|
crash-surviving sessions).
|
||||||
|
|
||||||
|
## Why this migration exists (read this first)
|
||||||
|
|
||||||
|
Alamedya today runs Discord in a dual-path hack: Nostrum's own gateway shard is
|
||||||
|
disabled, and a **discord.py proxy** connects the real gateway and POSTs every
|
||||||
|
raw payload into a Phoenix controller that re-injects it through Nostrum's
|
||||||
|
internal dispatch. That contraption exists only because Nostrum's gateway drops
|
||||||
|
the websocket (laptop sleep / flaky network) and never recovers.
|
||||||
|
|
||||||
|
Dexcord was built specifically to survive that. So the migration **cuts the
|
||||||
|
Python bridge entirely** and lets Dexcord connect the gateway directly — the
|
||||||
|
intended end state. Alamedya's Discord footprint is small and message-shaped: it
|
||||||
|
consumes `READY` and `MESSAGE_CREATE`, sends messages, reads channel history,
|
||||||
|
creates one thread, and reads guild threads from cache. Nothing Dexcord lacks by
|
||||||
|
design blocks it.
|
||||||
|
|
||||||
|
## What changed since the first draft: string maps → typed structs
|
||||||
|
|
||||||
|
The single biggest thing to internalize: **Dexcord decodes each payload exactly
|
||||||
|
once, in the dispatcher, into a struct, before your handler runs.** The first
|
||||||
|
migration draft told you to reach into raw maps (`msg["author"]["id"]`) and to
|
||||||
|
`String.to_integer/1` every id at the handler boundary. **Delete all of that.**
|
||||||
|
The struct already has typed fields and integer ids:
|
||||||
|
|
||||||
|
| First-draft assumption (raw maps) | v2 reality (typed structs) |
|
||||||
|
|---|---|
|
||||||
|
| `msg["content"]` | `msg.content` |
|
||||||
|
| `msg["author"]["id"]` (a string) | `msg.author.id` (an integer) |
|
||||||
|
| `String.to_integer(msg["channel_id"])` | `msg.channel_id` (already an integer) |
|
||||||
|
| `Dexcord.Cache.threads(gid)` returns maps | returns `[%Dexcord.Thread{}]` |
|
||||||
|
| `Dexcord.Api.create_message/2` returns `{:ok, map}` | returns `{:ok, %Dexcord.Message{}}` |
|
||||||
|
|
||||||
|
The rest of this guide walks every Discord touchpoint Alamedya has, `before`
|
||||||
|
(Nostrum / raw-map style) → `after` (typed).
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
**Elixir must be `>= 1.18`.** Dexcord uses the built-in `JSON` module and ships
|
||||||
|
no Jason. Bump `mix.exs` and confirm `elixir --version` reports ≥1.18 wherever
|
||||||
|
Alamedya builds and runs. Keep Jason as a dep — Phoenix still uses it.
|
||||||
|
|
||||||
|
Depend on Dexcord via `path:` (or `git:`), drop the Nostrum dep, `mix deps.get`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Boot / config
|
||||||
|
|
||||||
|
Dexcord is a library you add as **one child** to your own supervision tree; the
|
||||||
|
event handler is a plain module, not a supervised process. The typed contract
|
||||||
|
adds one boot-time knob worth setting up front: an `allowed_mentions` **default**
|
||||||
|
that every `Dexcord.Api.send/2` and `Dexcord.Message.reply/2` merges under. A bot
|
||||||
|
that should never accidentally `@everyone` sets `parse: []` once, here, instead
|
||||||
|
of threading `allowed_mentions` through every send.
|
||||||
|
|
||||||
|
**Before** (Nostrum auto-starts from application config):
|
||||||
|
|
||||||
|
```elixir
|
||||||
|
# config/config.exs
|
||||||
|
config :nostrum,
|
||||||
|
gateway_intents: :all,
|
||||||
|
num_shards: :manual
|
||||||
|
|
||||||
|
# config/runtime.exs
|
||||||
|
config :nostrum, token: System.get_env("DISCORD_TOKEN")
|
||||||
|
```
|
||||||
|
|
||||||
|
**After** (typed child spec; no application config for the gateway):
|
||||||
|
|
||||||
|
```elixir
|
||||||
|
# lib/alamedya/application.ex
|
||||||
|
children = [
|
||||||
|
# ... Repo, PubSub, Endpoint, your own supervisors ...
|
||||||
|
|
||||||
|
{Dexcord,
|
||||||
|
token: System.fetch_env!("DISCORD_TOKEN"),
|
||||||
|
handler: AlamedyaDiscord.Handler,
|
||||||
|
intents: :all,
|
||||||
|
cache_presences: false,
|
||||||
|
request_guild_members: false,
|
||||||
|
# Field-wise default: per-send values override key-by-key; unset keys fall
|
||||||
|
# back to this. `parse: []` suppresses every mention type unless a send opts
|
||||||
|
# back in.
|
||||||
|
allowed_mentions: [parse: []]}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
`Dexcord.child_spec/1` validates these eagerly and raises `ArgumentError` on
|
||||||
|
anything missing or malformed, so a bad token or unknown intent fails at boot,
|
||||||
|
not at first use. `:allowed_mentions` accepts a keyword list, a map, or a
|
||||||
|
`%Dexcord.AllowedMentions{}`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. READY backfill
|
||||||
|
|
||||||
|
`READY` arrives as a typed `%Dexcord.Events.Ready{}`. Its `guilds` are
|
||||||
|
**stubs** — `%Dexcord.UnavailableGuild{}` (just an `id`, plus an `unavailable`
|
||||||
|
flag) — because at `READY` time the guild objects have not been sent yet. The
|
||||||
|
full `%Dexcord.Guild{}` for each arrives in a subsequent `GUILD_CREATE`, which
|
||||||
|
Dexcord's dispatcher folds into the cache **before** your handler sees it.
|
||||||
|
|
||||||
|
So: do READY-time bootstrapping that only needs ids (locks, per-channel history
|
||||||
|
backfill by channel id) in the `READY` clause; do anything that needs full guild
|
||||||
|
state off `GUILD_CREATE` (or just read it from `Dexcord.Cache` on demand).
|
||||||
|
|
||||||
|
**Before** (Nostrum: `msg` is a struct-ish payload, ids integers already but the
|
||||||
|
guild list shape is Nostrum's):
|
||||||
|
|
||||||
|
```elixir
|
||||||
|
def handle_event({:READY, msg, _ws_state}) do
|
||||||
|
Logger.info("READY! #{inspect(msg)}")
|
||||||
|
Reminder.Scheduler.lock()
|
||||||
|
backfill_channels()
|
||||||
|
Reminder.Scheduler.unlock()
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
**After** (2-tuple; typed struct; stub guilds):
|
||||||
|
|
||||||
|
```elixir
|
||||||
|
def handle_event({:READY, %Dexcord.Events.Ready{user: me, guilds: guilds}}) do
|
||||||
|
Logger.info("READY as #{me.username} (#{me.id}); #{length(guilds)} guild stub(s)")
|
||||||
|
Reminder.Scheduler.lock()
|
||||||
|
backfill_channels()
|
||||||
|
Reminder.Scheduler.unlock()
|
||||||
|
end
|
||||||
|
|
||||||
|
# Full guild objects land here (cache is already populated when this runs).
|
||||||
|
def handle_event({:GUILD_CREATE, %Dexcord.Guild{} = guild}) do
|
||||||
|
Logger.debug("GUILD_CREATE #{guild.name} (#{guild.id}) fully cached")
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
Alamedya has no dedicated `GUILD_CREATE` logic — the `use Dexcord.Handler`
|
||||||
|
catch-all absorbs it, and the dispatcher still caches guilds/threads first. You
|
||||||
|
only need a `GUILD_CREATE` clause if you want to *react* to it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. MESSAGE_CREATE flow
|
||||||
|
|
||||||
|
The core loop. `MESSAGE_CREATE` arrives as `%Dexcord.Message{}` with typed
|
||||||
|
fields: `msg.content` (string), `msg.author` (a `%Dexcord.User{}`),
|
||||||
|
`msg.author.bot` (boolean), `msg.channel_id`/`msg.author.id` (**integers**),
|
||||||
|
`msg.mentions` (a list of `%Dexcord.User{}`), and `msg.webhook_id` (an integer
|
||||||
|
when the message came from a webhook — in which case `msg.author` is a synthetic
|
||||||
|
webhook user, so a `webhook_id`-first guard is the clean way to skip those).
|
||||||
|
|
||||||
|
Replying is a one-liner: `Dexcord.Message.reply(msg, "…")` sets
|
||||||
|
`message_reference` to the source message and routes through the same send funnel
|
||||||
|
(so your `allowed_mentions` default from §1 applies). `mention_author: false`
|
||||||
|
suppresses the reply ping.
|
||||||
|
|
||||||
|
The handler module below is the canonical shape. **It is mirrored verbatim as a
|
||||||
|
compiled test module in `test/dexcord/migration_guide_samples_test.exs` — keep
|
||||||
|
the two in sync.**
|
||||||
|
|
||||||
|
**Before** (raw maps, manual `String.to_integer`, `get_in`):
|
||||||
|
|
||||||
|
```elixir
|
||||||
|
def handle_event({:MESSAGE_CREATE, msg, _ws_state}) do
|
||||||
|
author_id = String.to_integer(msg["author"]["id"])
|
||||||
|
is_bot = get_in(msg, ["author", "bot"]) == true
|
||||||
|
|
||||||
|
cond do
|
||||||
|
is_bot -> :ignore
|
||||||
|
author_id == @self_id -> :ignore
|
||||||
|
msg["content"] == "ping!" ->
|
||||||
|
Nostrum.Api.create_message!(msg["channel_id"], "helo")
|
||||||
|
true -> :ignore
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
**After** (typed struct routing; verbatim shared sample):
|
||||||
|
|
||||||
|
```elixir
|
||||||
|
defmodule AlamedyaDiscord.Handler do
|
||||||
|
# KEEP IN SYNC: mirrored verbatim in
|
||||||
|
# test/dexcord/migration_guide_samples_test.exs (§3 of docs/alamedya-migration-v2.md).
|
||||||
|
use Dexcord.Handler
|
||||||
|
|
||||||
|
@self_id 1_135_637_126_222_987_365
|
||||||
|
|
||||||
|
# Webhook messages carry a `webhook_id` and a synthetic author — skip them
|
||||||
|
# first, before touching `author.bot`.
|
||||||
|
def handle_event({:MESSAGE_CREATE, %Dexcord.Message{webhook_id: id}}) when not is_nil(id),
|
||||||
|
do: :ignore
|
||||||
|
|
||||||
|
# Any bot (including ourselves) — never react.
|
||||||
|
def handle_event({:MESSAGE_CREATE, %Dexcord.Message{author: %Dexcord.User{bot: true}}}),
|
||||||
|
do: :ignore
|
||||||
|
|
||||||
|
def handle_event({:MESSAGE_CREATE, %Dexcord.Message{author: %Dexcord.User{id: @self_id}}}),
|
||||||
|
do: :ignore
|
||||||
|
|
||||||
|
def handle_event({:MESSAGE_CREATE, %Dexcord.Message{} = msg}) do
|
||||||
|
cond do
|
||||||
|
mentions_self?(msg) -> Dexcord.Message.reply(msg, "you rang?")
|
||||||
|
msg.content == "ping!" -> Dexcord.Message.reply(msg, "helo")
|
||||||
|
true -> :ignore
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp mentions_self?(%Dexcord.Message{mentions: mentions}),
|
||||||
|
do: Enum.any?(mentions, fn %Dexcord.User{id: id} -> id == @self_id end)
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
Note there is no `String.to_integer`, no `get_in`, and no map indexing anywhere:
|
||||||
|
the struct is already typed, and `@self_id` (an integer literal) compares
|
||||||
|
directly against `msg.author.id` (an integer). This is the whole point of the
|
||||||
|
typed contract.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Thread cache reads
|
||||||
|
|
||||||
|
Alamedya reads guild threads from cache to decide routing. Dexcord caches
|
||||||
|
threads as `%Dexcord.Thread{}` structs and exposes them per-guild via
|
||||||
|
`Dexcord.Cache.threads/1` (a **list**, not Nostrum's threads map). Thread fields
|
||||||
|
are typed: `thread.parent_id` (integer), `thread.thread_metadata` (a
|
||||||
|
`%Dexcord.ThreadMetadata{}` with `.archived`, `.locked`, …). `Dexcord.Cache.channel/1`
|
||||||
|
returns the concrete per-type struct (`%Dexcord.TextChannel{}`,
|
||||||
|
`%Dexcord.Thread{}`, …), so you can pattern-match the channel type directly.
|
||||||
|
|
||||||
|
**Before** (Nostrum GuildCache threads map, string compare):
|
||||||
|
|
||||||
|
```elixir
|
||||||
|
maybe_thread =
|
||||||
|
Nostrum.Cache.GuildCache.get!(guild_id).threads
|
||||||
|
|> Map.values()
|
||||||
|
|> Enum.find(fn t -> t.id == channel_id end)
|
||||||
|
|
||||||
|
archived? = maybe_thread && maybe_thread.thread_metadata.archived
|
||||||
|
```
|
||||||
|
|
||||||
|
**After** (typed list from the cache, integer compare, typed nested metadata):
|
||||||
|
|
||||||
|
```elixir
|
||||||
|
maybe_thread =
|
||||||
|
Dexcord.Cache.threads(msg.guild_id)
|
||||||
|
|> Enum.find(fn %Dexcord.Thread{} = t -> t.id == msg.channel_id end)
|
||||||
|
|
||||||
|
archived? =
|
||||||
|
case maybe_thread do
|
||||||
|
%Dexcord.Thread{thread_metadata: %Dexcord.ThreadMetadata{archived: a}} -> a
|
||||||
|
_ -> false
|
||||||
|
end
|
||||||
|
|
||||||
|
# parent channel of the thread, if we want it:
|
||||||
|
parent =
|
||||||
|
with %Dexcord.Thread{parent_id: pid} <- maybe_thread,
|
||||||
|
{:ok, channel} <- Dexcord.Cache.channel(pid),
|
||||||
|
do: channel, else: (_ -> nil)
|
||||||
|
```
|
||||||
|
|
||||||
|
Creating a thread and posting into it stays a straight REST pair, now returning a
|
||||||
|
typed channel:
|
||||||
|
|
||||||
|
```elixir
|
||||||
|
{:ok, %Dexcord.Thread{} = thread} =
|
||||||
|
Dexcord.Api.start_thread_with_message(msg.channel_id, msg.id, "request")
|
||||||
|
|
||||||
|
Dexcord.Api.create_message(thread.id, "helo from the new thread")
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Integer snowflakes
|
||||||
|
|
||||||
|
The old draft's `String.to_integer(msg["author"]["id"])` dance is **gone**. Every
|
||||||
|
id on a decoded struct is already an `integer`. That means:
|
||||||
|
|
||||||
|
- Comparisons against integer constants (`@self_id`, a config-mapped channel id)
|
||||||
|
just work — no coercion.
|
||||||
|
- Interpolating an id into a string (`"member #{msg.author.id}"`) just works.
|
||||||
|
- An `:integer` Ecto column (`discord_message_id`) takes `msg.id` directly.
|
||||||
|
|
||||||
|
The **only** place you convert is the app boundary — an id that arrives as a
|
||||||
|
string from *outside* Discord (an env var, a DB row, a web request). Use
|
||||||
|
`Dexcord.Snowflake.cast/1` there, exactly once:
|
||||||
|
|
||||||
|
**Before** (coerce on every access, everywhere):
|
||||||
|
|
||||||
|
```elixir
|
||||||
|
channel_id = String.to_integer(msg["channel_id"])
|
||||||
|
mapped = Application.get_env(:alamedya, :reminders)[:discord_mapping] # integer values
|
||||||
|
if channel_id == mapped[:mins_30], do: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
**After** (struct id is already an integer; cast only the external config value):
|
||||||
|
|
||||||
|
```elixir
|
||||||
|
# discord_mapping values come from env/config as strings — normalize once, at load:
|
||||||
|
mapping =
|
||||||
|
:alamedya
|
||||||
|
|> Application.get_env(:reminders)
|
||||||
|
|> Keyword.fetch!(:discord_mapping)
|
||||||
|
|> Map.new(fn {bucket, raw_id} -> {bucket, Dexcord.Snowflake.cast!(raw_id)} end)
|
||||||
|
|
||||||
|
# thereafter compare directly — both integers:
|
||||||
|
if msg.channel_id == mapping[:mins_30], do: Reminder.add(:mins_30, msg)
|
||||||
|
```
|
||||||
|
|
||||||
|
`Dexcord.Snowflake.cast/1` returns `{:ok, integer} | :error`; `cast!/1` raises on
|
||||||
|
a non-snowflake. It accepts an integer (passthrough), a decimal string, or a
|
||||||
|
struct carrying an `:id`, so it is safe to call on "whatever id you have."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Slash commands
|
||||||
|
|
||||||
|
Alamedya doesn't use slash commands today, but the typed contract makes them
|
||||||
|
cheap enough to add, so here's the shape. Interactions arrive as
|
||||||
|
`%Dexcord.Interaction{}` with a typed, polymorphic `data` field: for an
|
||||||
|
application command it's a `%Dexcord.Interaction.ApplicationCommandData{}` whose
|
||||||
|
`.name` is the command name and whose `.options` are typed. `interaction.token`
|
||||||
|
and `interaction.id` are what the response helpers need; resolved-data maps are
|
||||||
|
keyed by **integer** ids.
|
||||||
|
|
||||||
|
A `Dexcord.Slash` module declares `commands/0` and handles routed interactions;
|
||||||
|
`Dexcord.Slash.respond/2` (unchanged call shape) sends the immediate response.
|
||||||
|
|
||||||
|
```elixir
|
||||||
|
defmodule AlamedyaDiscord.Slash do
|
||||||
|
use Dexcord.Slash
|
||||||
|
|
||||||
|
def commands, do: [%{name: "ping", description: "Replies with pong."}]
|
||||||
|
|
||||||
|
# `name` is the command name; `itx` is the full %Dexcord.Interaction{}.
|
||||||
|
def handle_interaction("ping", itx) do
|
||||||
|
Dexcord.Slash.respond(itx, "pong")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
Wire it up with `slash: AlamedyaDiscord.Slash` (and, in dev,
|
||||||
|
`slash_guild_ids: [dev_guild_id]` for instant registration) in the child spec
|
||||||
|
from §1. `respond/2` takes a binary (used as `content`) or a map
|
||||||
|
(`%{content: "…", ephemeral: true}`); `respond_later/1`, `followup/2`, and
|
||||||
|
`edit_response/2` cover the deferred flow.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. REST calls
|
||||||
|
|
||||||
|
`Dexcord.Api` typed endpoints return **typed structs** on success and the
|
||||||
|
unchanged `{:error, %Dexcord.Api.Error{}}` on failure:
|
||||||
|
|
||||||
|
```elixir
|
||||||
|
{:ok, %Dexcord.Message{} = sent} = Dexcord.Api.create_message(channel_id, "hi")
|
||||||
|
{:ok, %Dexcord.User{} = me} = Dexcord.Api.get_current_user()
|
||||||
|
{:error, %Dexcord.Api.Error{status: 403}} = Dexcord.Api.get_channel(forbidden_id)
|
||||||
|
```
|
||||||
|
|
||||||
|
For anything without a typed wrapper, `Dexcord.Api.request/4` is the escape hatch
|
||||||
|
(string-keyed request/response maps):
|
||||||
|
|
||||||
|
```elixir
|
||||||
|
Dexcord.Api.request(:patch, "/guilds/#{guild_id}", %{"name" => "New Name"})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Hand-rolled pagination loops become streams.** The old draft read channel
|
||||||
|
history with an explicit `get_channel_messages(id, 50, {:after, cursor})` loop.
|
||||||
|
Dexcord ships lazy streams that page for you and only fetch as far as you consume:
|
||||||
|
|
||||||
|
**Before** (manual cursor loop):
|
||||||
|
|
||||||
|
```elixir
|
||||||
|
msgs =
|
||||||
|
Nostrum.Api.get_channel_messages!(channel_id, 50, {:after, last_id})
|
||||||
|
|> Enum.reverse()
|
||||||
|
```
|
||||||
|
|
||||||
|
**After** (lazy stream; `after:` flips to oldest→newest, `limit:` caps the total):
|
||||||
|
|
||||||
|
```elixir
|
||||||
|
msgs =
|
||||||
|
Dexcord.Api.message_history(channel_id, after: last_id, limit: 50)
|
||||||
|
|> Enum.to_list()
|
||||||
|
# each element is a %Dexcord.Message{}; take/2 stops fetching once satisfied.
|
||||||
|
```
|
||||||
|
|
||||||
|
`message_history/2`, `guild_members_stream/2`, `guild_bans_stream/2`, and
|
||||||
|
`audit_log_stream/2` are all lazy `Stream`s. The Nostrum-compatible
|
||||||
|
`Dexcord.Api.get_channel_messages/3` locator arity
|
||||||
|
(`get_channel_messages(id, limit, {:after, cursor})`) still exists if you want a
|
||||||
|
single explicit page instead of a stream.
|
||||||
|
|
||||||
|
`Dexcord.Api.send/2` is the ergonomic front door over `create_message`: it
|
||||||
|
accepts anything `Dexcord.Messageable` — a channel struct, a `%Dexcord.Thread{}`,
|
||||||
|
a `%Dexcord.Message{}` (posts to its channel), a `%Dexcord.User{}`/`%Dexcord.Member{}`
|
||||||
|
(opens and caches a DM lazily), or a bare integer channel id — and applies the
|
||||||
|
`allowed_mentions` default from §1.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Hydration
|
||||||
|
|
||||||
|
Envelope events (reactions, message deletes) carry only **ids**, not the related
|
||||||
|
objects — a `%Dexcord.Events.ReactionAdd{}` has `user_id`, `channel_id`,
|
||||||
|
`guild_id` but leaves `user`, `channel`, `guild` as `nil`. `Dexcord.Cache.fill/1`
|
||||||
|
best-effort fills those slots from the cache (ETS only — no HTTP, safe on the hot
|
||||||
|
path). A cache miss leaves that slot `nil`; already-filled slots are untouched
|
||||||
|
(idempotent).
|
||||||
|
|
||||||
|
**Before** (Nostrum: look each id up in a separate cache module by hand):
|
||||||
|
|
||||||
|
```elixir
|
||||||
|
def handle_event({:MESSAGE_REACTION_ADD, reaction, _ws_state}) do
|
||||||
|
user = Nostrum.Cache.UserCache.get!(reaction.user_id)
|
||||||
|
channel = Nostrum.Cache.ChannelCache.get!(reaction.channel_id)
|
||||||
|
handle_reaction(reaction, user, channel)
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
**After** (one `fill/1` call hydrates every declared slot):
|
||||||
|
|
||||||
|
```elixir
|
||||||
|
def handle_event({:MESSAGE_REACTION_ADD, %Dexcord.Events.ReactionAdd{} = reaction}) do
|
||||||
|
reaction = Dexcord.Cache.fill(reaction)
|
||||||
|
# reaction.user :: %Dexcord.User{} | nil, reaction.channel :: channel struct | nil,
|
||||||
|
# reaction.guild :: %Dexcord.Guild{} | nil — each nil on a cache miss.
|
||||||
|
handle_reaction(reaction)
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
Because `fill/1` never blocks on the network, treat a `nil` slot as "not cached
|
||||||
|
right now" and fall back to a typed REST call (`Dexcord.Api.get_user/1`, …) only
|
||||||
|
when you actually need that object.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
From the Alamedya checkout after the migration:
|
||||||
|
|
||||||
|
1. **Clean compile with Nostrum gone** — proves no lingering `Nostrum.*`
|
||||||
|
references or struct matches, and no leftover `String.to_integer` on ids that
|
||||||
|
are now integers:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
mix compile --warnings-as-errors
|
||||||
|
grep -rn "Nostrum" lib/ config/ # expect zero hits
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Tests** (if the app has any touching this code): `mix test`.
|
||||||
|
|
||||||
|
3. **End-to-end — the real acceptance test** (the failure the whole migration
|
||||||
|
retires): start Alamedya with `DISCORD_TOKEN` set and **no** discord.py
|
||||||
|
running. Confirm it connects via Dexcord's own gateway (a `READY` log), post a
|
||||||
|
message in a mapped channel (a reminder persists), @-mention the bot (it opens
|
||||||
|
a `"request"` thread and replies), then **suspend the machine / drop the
|
||||||
|
network, wait, and wake it** → confirm events resume via a RESUME, repeatedly.
|
||||||
|
That last step is the exact Nostrum failure the bridge was working around;
|
||||||
|
Dexcord must handle it natively.
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
The migration is a single branch. If E2E fails, `git checkout` back, restore the
|
||||||
|
`config :nostrum` block and the bridge, and re-run the discord.py proxy. Nothing
|
||||||
|
in the DB schema changes, so there is no data migration to reverse.
|
||||||
|
</content>
|
||||||
|
</invoke>
|
||||||
84
docs/test-plans/2026-07-05-api-surface.md
Normal file
84
docs/test-plans/2026-07-05-api-surface.md
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
# Human Test Plan — dexcord Full API Surface (2026-07-05-api-surface)
|
||||||
|
|
||||||
|
Coverage validation: **PASS** — 44/44 acceptance criteria covered by automated tests
|
||||||
|
(633 tests, 0 failures, 1 excluded `:flaky` with a deterministic default-suite twin).
|
||||||
|
This plan covers the residue automation cannot prove: hermeticity, CI enablement,
|
||||||
|
and migration-guide editorial quality, plus an optional live end-to-end.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Elixir 1.18 / OTP 27, `mix deps.get` clean.
|
||||||
|
- `mix test` green from the repo root (baseline: 633 tests, 0 failures, 1 excluded).
|
||||||
|
- A Gitea repo with push access for the CI checks.
|
||||||
|
- Optional: a real Discord bot token + a throwaway test guild for the live end-to-end.
|
||||||
|
|
||||||
|
## Phase A: Suite Hermeticity (HV-1 — AC4.1)
|
||||||
|
|
||||||
|
Automated coverage proves the suite is green; this proves green also means *no external network*.
|
||||||
|
|
||||||
|
| Step | Action | Expected |
|
||||||
|
|------|--------|----------|
|
||||||
|
| A1 | Add `127.0.0.1 discord.com` (and `docs.discord.com`) to `/etc/hosts`, or run `sudo unshare -n mix test` from repo root | Full suite still exits 0 with the same pass count (633/0/1) |
|
||||||
|
| A2 | `rg -n 'discord\.com|githubusercontent' test/` | Hits only fixtures, doc-link strings, and the deliberately network-only `dexcord.coverage.refresh` task — never a live call from a test |
|
||||||
|
| A3 | `rg -n 'coverage\.refresh' test/ .gitea/` | No match — the network-only refresh task is never invoked by tests or CI |
|
||||||
|
| A4 | Revert `/etc/hosts` | Clean state restored |
|
||||||
|
|
||||||
|
## Phase B: CI Gate Actually Runs & Bites (HV-2 — AC4.3)
|
||||||
|
|
||||||
|
| Step | Action | Expected |
|
||||||
|
|------|--------|----------|
|
||||||
|
| B1 | Confirm `.gitea/workflows/ci.yml` lists `- run: mix test` and `- run: mix dexcord.coverage` | Both steps present |
|
||||||
|
| B2 | In Gitea repo settings, enable **Actions** | Actions enabled (owner infra step; no CI configured today) |
|
||||||
|
| B3 | Push a normal commit (or open a PR) | Pipeline triggers; both `mix test` and `mix dexcord.coverage` run green |
|
||||||
|
| B4 | On a throwaway branch, delete one in-scope `endpoint` declaration line (e.g. `get_channel` in `lib/dexcord/api/channels.ex`), push | The `mix dexcord.coverage` step turns the pipeline **red** (non-zero exit, route reported missing) |
|
||||||
|
| B5 | Delete the throwaway branch | Gate proven to bite; no residue |
|
||||||
|
|
||||||
|
## Phase C: Migration Guide Completeness & Prose (HV-3 — AC5.1)
|
||||||
|
|
||||||
|
The sample-validity half is automated (`migration_guide_samples_test.exs`); this covers
|
||||||
|
editorial completeness against the real old app.
|
||||||
|
|
||||||
|
| Step | Action | Expected |
|
||||||
|
|------|--------|----------|
|
||||||
|
| C1 | Read the old `alamedya-migration.md` (untracked, repo root) and extract its Discord touchpoint checklist | A concrete list, esp. the old string↔int snowflake dance and thread/cache reads specific to alamedya |
|
||||||
|
| C2 | Open `docs/alamedya-migration-v2.md` and confirm all 8 mandated sections exist | §1 Boot/config, §2 READY backfill, §3 MESSAGE_CREATE, §4 Thread cache reads, §5 Integer snowflakes, §6 Slash commands, §7 REST calls, §8 Hydration |
|
||||||
|
| C3 | For each old touchpoint from C1, find its before→after mapping in v2 | Nothing dropped; each maps to a v2 section |
|
||||||
|
| C4 | Confirm the `KEEP IN SYNC` comment sits in both the guide §3 handler and `test/dexcord/migration_guide_samples_test.exs` | Present in both |
|
||||||
|
| C5 | Read for prose quality | Accurate, adoptable, no stale map-shaped examples |
|
||||||
|
|
||||||
|
## End-to-End: Live Bot Round-Trip (optional; spans Phases 2/6/7)
|
||||||
|
|
||||||
|
Validates decode → dispatch → cache → send against real Discord, which the fakes only approximate.
|
||||||
|
|
||||||
|
1. Configure a real bot token + `allowed_mentions` default per guide §1; boot the app against a test guild.
|
||||||
|
2. On READY, call `Dexcord.Cache.guilds()` → expect the test guild present; after `GUILD_CREATE`, `Dexcord.Cache.channels(guild_id)` populated with typed channel structs (`%Dexcord.TextChannel{}` etc.).
|
||||||
|
3. From another account, post `ping!` in a visible channel → the bot replies as a threaded reply (`message_reference` set to your message).
|
||||||
|
4. `@mention` the bot → it replies.
|
||||||
|
5. Post as a webhook (or another bot) → the bot sends nothing (webhook/bot authors skipped).
|
||||||
|
6. `Dexcord.Api.message_history(channel_id) |> Enum.take(5)` in `iex` → exactly 5 `%Dexcord.Message{}` in descending id order, one page fetched.
|
||||||
|
7. Register a slash command, invoke it → handler receives a typed `%Dexcord.Interaction{}`; `Dexcord.Slash.respond/2` posts a visible reply.
|
||||||
|
|
||||||
|
## Human Verification Required
|
||||||
|
|
||||||
|
| Criterion | Why Manual | Steps |
|
||||||
|
|-----------|------------|-------|
|
||||||
|
| AC4.1 (hermeticity) | "green suite" ≠ "no egress" — a mis-stubbed fake could pass while calling out | Phase A |
|
||||||
|
| AC4.3 (CI runs) | A checked-in YAML can't assert the server executes it; Gitea Actions enablement is owner infra | Phase B |
|
||||||
|
| AC5.1 (guide completeness/prose) | Coverage-completeness and readability are editorial judgments | Phase C |
|
||||||
|
|
||||||
|
## Traceability
|
||||||
|
|
||||||
|
| Acceptance Criterion | Automated Test | Manual Step |
|
||||||
|
|----------------------|----------------|-------------|
|
||||||
|
| AC1.1–1.7 | endpoint_macro_test, endpoint_test, api_facade_test | E2E steps 2, 6 |
|
||||||
|
| AC1.8–1.10 | coverage_test, ratelimit_test | Phase B (B4 exercises the gate) |
|
||||||
|
| AC2.1–2.9 | struct_test, enum_test, flags_test, endpoint_test | — (pure) |
|
||||||
|
| AC2.10–2.17 | model_guild/message/channel/interaction/role_test, events_test | E2E step 2 |
|
||||||
|
| AC2.18–2.19 | gateway_integration_test | E2E steps 3–5 |
|
||||||
|
| AC2.20–2.22, AC3.8 | cache_test, cache_fill_test, cache_cascade_test | E2E step 2 |
|
||||||
|
| AC3.1–3.6 | send_test, messageable_test, ergonomics_helpers_test, model_message_parts_test | E2E steps 3–4 |
|
||||||
|
| AC3.7 | pagination_test | E2E step 6 |
|
||||||
|
| AC4.1 | full `mix test` | Phase A |
|
||||||
|
| AC4.2 | struct/enum/flags/endpoint test files (async categorization) | — |
|
||||||
|
| AC4.3 | coverage_test + `.gitea/workflows/ci.yml` | Phase B |
|
||||||
|
| AC5.1 | migration_guide_samples_test | Phase C |
|
||||||
|
|
@ -81,6 +81,8 @@ defmodule Dexcord do
|
||||||
slash_guild_ids = Keyword.get(opts, :slash_guild_ids)
|
slash_guild_ids = Keyword.get(opts, :slash_guild_ids)
|
||||||
validate_slash_guild_ids(slash_guild_ids)
|
validate_slash_guild_ids(slash_guild_ids)
|
||||||
|
|
||||||
|
allowed_mentions = validate_allowed_mentions(Keyword.get(opts, :allowed_mentions))
|
||||||
|
|
||||||
%{
|
%{
|
||||||
token: token,
|
token: token,
|
||||||
handler: handler,
|
handler: handler,
|
||||||
|
|
@ -90,7 +92,8 @@ defmodule Dexcord do
|
||||||
request_guild_members: request_guild_members,
|
request_guild_members: request_guild_members,
|
||||||
slash: slash,
|
slash: slash,
|
||||||
slash_guild_ids: slash_guild_ids,
|
slash_guild_ids: slash_guild_ids,
|
||||||
gateway_url: gateway_url
|
gateway_url: gateway_url,
|
||||||
|
allowed_mentions: allowed_mentions
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -153,6 +156,21 @@ defmodule Dexcord do
|
||||||
"got: #{inspect(other)}"
|
"got: #{inspect(other)}"
|
||||||
end
|
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
|
defp boolean_opt(opts, key, default) do
|
||||||
value = Keyword.get(opts, key, default)
|
value = Keyword.get(opts, key, default)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -375,6 +375,238 @@ defmodule Dexcord.Api do
|
||||||
end
|
end
|
||||||
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 -----------------------------------------
|
# --- Generated endpoint facade -----------------------------------------
|
||||||
#
|
#
|
||||||
# The typed endpoint surface lives in the group modules below (declared with
|
# The typed endpoint surface lives in the group modules below (declared with
|
||||||
|
|
|
||||||
54
lib/dexcord/api/paginate.ex
Normal file
54
lib/dexcord/api/paginate.ex
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
defmodule Dexcord.Api.Paginate do
|
||||||
|
@moduledoc false
|
||||||
|
# Lazy cursor pagination over Discord's list endpoints. Each page is fetched
|
||||||
|
# only when the stream is consumed that far (so `Stream.take/2` on a fresh
|
||||||
|
# stream makes exactly one wire hit). A page shorter than the page size ends
|
||||||
|
# the stream; an `{:error, _}` from the fetcher raises `PageError` — a stream
|
||||||
|
# cannot carry a tagged-tuple failure mid-flow, so the error surfaces as a
|
||||||
|
# raise the caller can `rescue`.
|
||||||
|
|
||||||
|
defmodule PageError do
|
||||||
|
@moduledoc """
|
||||||
|
Raised when a page fetch inside a pagination stream returns
|
||||||
|
`{:error, _}`. The underlying error is on the `:error` field.
|
||||||
|
"""
|
||||||
|
defexception [:error]
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def message(%{error: e}), do: "pagination request failed: #{inspect(e)}"
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
# `fetch_page.(cursor)` -> `{:ok, items}` | `{:error, e}`;
|
||||||
|
# `next.(last_item)` -> the cursor for the following page.
|
||||||
|
#
|
||||||
|
# `initial_cursor` seeds the first fetch (may be `nil` for an anchorless first
|
||||||
|
# page). The stream halts on the first page whose length is < `page_size`.
|
||||||
|
@spec stream(term(), pos_integer(), (term() -> {:ok, list()} | {:error, term()}), (term() ->
|
||||||
|
term())) ::
|
||||||
|
Enumerable.t()
|
||||||
|
def stream(initial_cursor, page_size, fetch_page, next)
|
||||||
|
when is_integer(page_size) and page_size > 0 and is_function(fetch_page, 1) and
|
||||||
|
is_function(next, 1) do
|
||||||
|
Stream.resource(
|
||||||
|
fn -> {initial_cursor, :go} end,
|
||||||
|
fn
|
||||||
|
{_cursor, :halt} ->
|
||||||
|
{:halt, nil}
|
||||||
|
|
||||||
|
{cursor, :go} ->
|
||||||
|
case fetch_page.(cursor) do
|
||||||
|
{:ok, items} when length(items) < page_size ->
|
||||||
|
{items, {cursor, :halt}}
|
||||||
|
|
||||||
|
{:ok, items} ->
|
||||||
|
{items, {next.(List.last(items)), :go}}
|
||||||
|
|
||||||
|
{:error, error} ->
|
||||||
|
raise PageError, error: error
|
||||||
|
end
|
||||||
|
end,
|
||||||
|
fn _ -> :ok end
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -21,6 +21,17 @@ defmodule Dexcord.Cache do
|
||||||
is always the source of truth; the cache is best-effort, and can briefly lag or
|
is always the source of truth; the cache is best-effort, and can briefly lag or
|
||||||
hold a duplicate after a resume gap.
|
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
|
## Tables
|
||||||
|
|
||||||
| Table | Type | Key |
|
| Table | Type | Key |
|
||||||
|
|
@ -59,6 +70,10 @@ defmodule Dexcord.Cache do
|
||||||
@roles :dexcord_roles
|
@roles :dexcord_roles
|
||||||
@presences :dexcord_presences
|
@presences :dexcord_presences
|
||||||
@voice_states :dexcord_voice_states
|
@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.
|
# 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.
|
# `emojis` (and stickers) stay inline on the guild and are replaced wholesale.
|
||||||
|
|
@ -85,6 +100,7 @@ defmodule Dexcord.Cache do
|
||||||
:ets.new(@roles, oset)
|
:ets.new(@roles, oset)
|
||||||
:ets.new(@presences, oset)
|
:ets.new(@presences, oset)
|
||||||
:ets.new(@voice_states, oset)
|
:ets.new(@voice_states, oset)
|
||||||
|
:ets.new(@dm_channels, set)
|
||||||
|
|
||||||
{:ok, %{}}
|
{:ok, %{}}
|
||||||
end
|
end
|
||||||
|
|
@ -566,6 +582,70 @@ defmodule Dexcord.Cache do
|
||||||
@spec voice_states(id()) :: [entity()]
|
@spec voice_states(id()) :: [entity()]
|
||||||
def voice_states(guild_id), do: prefix_values(@voice_states, guild_id)
|
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 -------------------------------------------------------
|
# --- read helpers -------------------------------------------------------
|
||||||
|
|
||||||
defp cache_presences?(config), do: Map.get(config, :cache_presences, false)
|
defp cache_presences?(config), do: Map.get(config, :cache_presences, false)
|
||||||
|
|
|
||||||
|
|
@ -84,10 +84,10 @@ defmodule Dexcord.Dispatcher do
|
||||||
# handler.
|
# handler.
|
||||||
#
|
#
|
||||||
# If decode FELL BACK to the raw map (malformed interaction), the struct gate
|
# If decode FELL BACK to the raw map (malformed interaction), the struct gate
|
||||||
# can't fire; we degrade to the old integer `"type"` gate so a well-typed-enough
|
# can't fire; we degrade to the old integer `"type"` gate and hand the RAW map to
|
||||||
# raw interaction still reaches the slash layer. The RAW map is passed to
|
# `Slash.dispatch/2`'s documented degraded head so a not-quite-decodable
|
||||||
# `Slash.dispatch/2` in this task (Task 4 flips the typed branch to pass the
|
# interaction still reaches the slash layer. A cleanly decoded interaction routes
|
||||||
# decoded struct).
|
# the typed `%Dexcord.Interaction{}` struct.
|
||||||
defp maybe_route_slash(:INTERACTION_CREATE, decoded, raw, config) do
|
defp maybe_route_slash(:INTERACTION_CREATE, decoded, raw, config) do
|
||||||
slash_mod = Map.get(config, :slash)
|
slash_mod = Map.get(config, :slash)
|
||||||
|
|
||||||
|
|
@ -97,7 +97,7 @@ defmodule Dexcord.Dispatcher do
|
||||||
|
|
||||||
match?(%Dexcord.Interaction{}, decoded) and
|
match?(%Dexcord.Interaction{}, decoded) and
|
||||||
decoded.type in [:application_command, :message_component, :modal_submit] ->
|
decoded.type in [:application_command, :message_component, :modal_submit] ->
|
||||||
route_slash(raw, slash_mod)
|
route_slash(decoded, slash_mod)
|
||||||
|
|
||||||
is_map(raw) and raw["type"] in [2, 3, 5] ->
|
is_map(raw) and raw["type"] in [2, 3, 5] ->
|
||||||
route_slash(raw, slash_mod)
|
route_slash(raw, slash_mod)
|
||||||
|
|
|
||||||
45
lib/dexcord/messageable.ex
Normal file
45
lib/dexcord/messageable.ex
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
defprotocol Dexcord.Messageable do
|
||||||
|
@moduledoc """
|
||||||
|
Anything a message can be sent to. `Dexcord.Api.send/2` resolves its target
|
||||||
|
through this protocol. Category/forum/media/directory channels deliberately
|
||||||
|
do NOT implement it — sending to one fails with `Protocol.UndefinedError`
|
||||||
|
at resolve time, before any HTTP.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@spec resolve(t) :: {:channel, Dexcord.Snowflake.t()} | {:dm_user, Dexcord.Snowflake.t()}
|
||||||
|
def resolve(target)
|
||||||
|
end
|
||||||
|
|
||||||
|
defimpl Dexcord.Messageable,
|
||||||
|
for: [
|
||||||
|
Dexcord.TextChannel,
|
||||||
|
Dexcord.AnnouncementChannel,
|
||||||
|
Dexcord.VoiceChannel,
|
||||||
|
Dexcord.StageChannel,
|
||||||
|
Dexcord.Thread,
|
||||||
|
Dexcord.DMChannel,
|
||||||
|
Dexcord.GroupDMChannel
|
||||||
|
] do
|
||||||
|
def resolve(%{id: id}), do: {:channel, id}
|
||||||
|
end
|
||||||
|
|
||||||
|
defimpl Dexcord.Messageable, for: Dexcord.Message do
|
||||||
|
def resolve(%{channel_id: id}), do: {:channel, id}
|
||||||
|
end
|
||||||
|
|
||||||
|
defimpl Dexcord.Messageable, for: Dexcord.Interaction do
|
||||||
|
def resolve(%{channel_id: id}) when not is_nil(id), do: {:channel, id}
|
||||||
|
end
|
||||||
|
|
||||||
|
defimpl Dexcord.Messageable, for: Dexcord.User do
|
||||||
|
def resolve(%{id: id}), do: {:dm_user, id}
|
||||||
|
end
|
||||||
|
|
||||||
|
defimpl Dexcord.Messageable, for: Dexcord.Member do
|
||||||
|
def resolve(%{user: %Dexcord.User{id: id}}), do: {:dm_user, id}
|
||||||
|
def resolve(%{user_id: id}) when not is_nil(id), do: {:dm_user, id}
|
||||||
|
end
|
||||||
|
|
||||||
|
defimpl Dexcord.Messageable, for: Integer do
|
||||||
|
def resolve(id) when id >= 0, do: {:channel, id}
|
||||||
|
end
|
||||||
|
|
@ -8,4 +8,27 @@ defmodule Dexcord.AllowedMentions do
|
||||||
field :users, {:list, :snowflake}
|
field :users, {:list, :snowflake}
|
||||||
field :replied_user, :boolean, default: false
|
field :replied_user, :boolean, default: false
|
||||||
end
|
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
|
end
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,10 @@ defmodule Dexcord.TextChannel do
|
||||||
field :default_auto_archive_duration, :integer
|
field :default_auto_archive_duration, :integer
|
||||||
field :default_thread_rate_limit_per_user, :integer
|
field :default_thread_rate_limit_per_user, :integer
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
defmodule Dexcord.AnnouncementChannel do
|
defmodule Dexcord.AnnouncementChannel do
|
||||||
|
|
@ -45,6 +49,10 @@ defmodule Dexcord.AnnouncementChannel do
|
||||||
field :default_auto_archive_duration, :integer
|
field :default_auto_archive_duration, :integer
|
||||||
field :default_thread_rate_limit_per_user, :integer
|
field :default_thread_rate_limit_per_user, :integer
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
defmodule Dexcord.VoiceChannel do
|
defmodule Dexcord.VoiceChannel do
|
||||||
|
|
@ -60,6 +68,10 @@ defmodule Dexcord.VoiceChannel do
|
||||||
field :last_message_id, :snowflake
|
field :last_message_id, :snowflake
|
||||||
field :rate_limit_per_user, :integer
|
field :rate_limit_per_user, :integer
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
defmodule Dexcord.StageChannel do
|
defmodule Dexcord.StageChannel do
|
||||||
|
|
@ -75,6 +87,10 @@ defmodule Dexcord.StageChannel do
|
||||||
field :last_message_id, :snowflake
|
field :last_message_id, :snowflake
|
||||||
field :rate_limit_per_user, :integer
|
field :rate_limit_per_user, :integer
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
defmodule Dexcord.CategoryChannel do
|
defmodule Dexcord.CategoryChannel do
|
||||||
|
|
@ -84,6 +100,10 @@ defmodule Dexcord.CategoryChannel do
|
||||||
discord_struct do
|
discord_struct do
|
||||||
include_fields Dexcord.Model.ChannelShared
|
include_fields Dexcord.Model.ChannelShared
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
defmodule Dexcord.DirectoryChannel do
|
defmodule Dexcord.DirectoryChannel do
|
||||||
|
|
@ -93,6 +113,10 @@ defmodule Dexcord.DirectoryChannel do
|
||||||
discord_struct do
|
discord_struct do
|
||||||
include_fields Dexcord.Model.ChannelShared
|
include_fields Dexcord.Model.ChannelShared
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
defmodule Dexcord.ForumChannel do
|
defmodule Dexcord.ForumChannel do
|
||||||
|
|
@ -111,6 +135,10 @@ defmodule Dexcord.ForumChannel do
|
||||||
field :default_sort_order, {:enum, Dexcord.SortOrderType}
|
field :default_sort_order, {:enum, Dexcord.SortOrderType}
|
||||||
field :default_forum_layout, {:enum, Dexcord.ForumLayoutType}
|
field :default_forum_layout, {:enum, Dexcord.ForumLayoutType}
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
defmodule Dexcord.MediaChannel do
|
defmodule Dexcord.MediaChannel do
|
||||||
|
|
@ -128,6 +156,10 @@ defmodule Dexcord.MediaChannel do
|
||||||
field :default_reaction_emoji, {:struct, Dexcord.DefaultReaction}
|
field :default_reaction_emoji, {:struct, Dexcord.DefaultReaction}
|
||||||
field :default_sort_order, {:enum, Dexcord.SortOrderType}
|
field :default_sort_order, {:enum, Dexcord.SortOrderType}
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
defmodule Dexcord.DMChannel do
|
defmodule Dexcord.DMChannel do
|
||||||
|
|
@ -142,6 +174,10 @@ defmodule Dexcord.DMChannel do
|
||||||
field :recipients, {:list, {:struct, Dexcord.User}}, default: []
|
field :recipients, {:list, {:struct, Dexcord.User}}, default: []
|
||||||
field :last_pin_timestamp, :datetime
|
field :last_pin_timestamp, :datetime
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
defmodule Dexcord.GroupDMChannel do
|
defmodule Dexcord.GroupDMChannel do
|
||||||
|
|
@ -161,6 +197,10 @@ defmodule Dexcord.GroupDMChannel do
|
||||||
field :application_id, :snowflake
|
field :application_id, :snowflake
|
||||||
field :managed, :boolean
|
field :managed, :boolean
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
defmodule Dexcord.Thread do
|
defmodule Dexcord.Thread do
|
||||||
|
|
@ -186,6 +226,10 @@ defmodule Dexcord.Thread do
|
||||||
field :last_pin_timestamp, :datetime
|
field :last_pin_timestamp, :datetime
|
||||||
field :newly_created, :boolean, default: false
|
field :newly_created, :boolean, default: false
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
defmodule Dexcord.UnknownChannel do
|
defmodule Dexcord.UnknownChannel do
|
||||||
|
|
@ -254,6 +298,32 @@ defmodule Dexcord.Channel do
|
||||||
def from_map(map) when is_map(map), do: Dexcord.UnknownChannel.from_map(map)
|
def from_map(map) when is_map(map), do: Dexcord.UnknownChannel.from_map(map)
|
||||||
def from_map(_), do: nil
|
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
|
@doc false
|
||||||
def __type_map__, do: @type_map
|
def __type_map__, do: @type_map
|
||||||
end
|
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
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,3 @@
|
||||||
defmodule Dexcord.Embed do
|
|
||||||
@moduledoc "A message embed. https://docs.discord.com/developers/resources/message"
|
|
||||||
use Dexcord.Struct
|
|
||||||
|
|
||||||
discord_struct do
|
|
||||||
field :title, :string
|
|
||||||
field :type, :string
|
|
||||||
field :description, :string
|
|
||||||
field :url, :string
|
|
||||||
field :timestamp, :datetime
|
|
||||||
field :color, :integer
|
|
||||||
field :footer, {:struct, Dexcord.EmbedFooter}
|
|
||||||
field :image, {:struct, Dexcord.EmbedImage}
|
|
||||||
field :thumbnail, {:struct, Dexcord.EmbedThumbnail}
|
|
||||||
field :video, {:struct, Dexcord.EmbedVideo}
|
|
||||||
field :provider, {:struct, Dexcord.EmbedProvider}
|
|
||||||
field :author, {:struct, Dexcord.EmbedAuthor}
|
|
||||||
field :fields, {:list, {:struct, Dexcord.EmbedField}}, default: []
|
|
||||||
field :flags, :integer
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defmodule Dexcord.EmbedFooter do
|
defmodule Dexcord.EmbedFooter do
|
||||||
@moduledoc false
|
@moduledoc false
|
||||||
use Dexcord.Struct
|
use Dexcord.Struct
|
||||||
|
|
@ -114,3 +92,67 @@ defmodule Dexcord.EmbedField do
|
||||||
field :inline, :boolean, default: false
|
field :inline, :boolean, default: false
|
||||||
end
|
end
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,10 @@ defmodule Dexcord.Emoji do
|
||||||
field :animated, :boolean, default: false
|
field :animated, :boolean, default: false
|
||||||
field :available, :boolean
|
field :available, :boolean
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
defmodule Dexcord.PartialEmoji do
|
defmodule Dexcord.PartialEmoji do
|
||||||
|
|
@ -22,9 +26,27 @@ defmodule Dexcord.PartialEmoji do
|
||||||
"""
|
"""
|
||||||
use Dexcord.Struct
|
use Dexcord.Struct
|
||||||
|
|
||||||
|
# `to_string/1` below shadows the auto-imported `Kernel.to_string/1`.
|
||||||
|
import Kernel, except: [to_string: 1]
|
||||||
|
|
||||||
discord_struct do
|
discord_struct do
|
||||||
field :id, :snowflake
|
field :id, :snowflake
|
||||||
field :name, :string
|
field :name, :string
|
||||||
field :animated, :boolean, default: false
|
field :animated, :boolean, default: false
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
The send-format string for this emoji.
|
||||||
|
|
||||||
|
Custom static: `<:name:id>`; animated: `<a:name:id>`; 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: "<a:#{name}:#{id}>"
|
||||||
|
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
|
end
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,136 @@ defmodule Dexcord.Guild do
|
||||||
field :guild_scheduled_events, {:list, {:struct, Dexcord.GuildScheduledEvent}}, default: []
|
field :guild_scheduled_events, {:list, {:struct, Dexcord.GuildScheduledEvent}}, default: []
|
||||||
field :soundboard_sounds, {:list, :raw}, default: []
|
field :soundboard_sounds, {:list, :raw}, default: []
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
defmodule Dexcord.UnavailableGuild do
|
defmodule Dexcord.UnavailableGuild do
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,23 @@ defmodule Dexcord.Member do
|
||||||
field :user, {:struct, Dexcord.User}
|
field :user, {:struct, Dexcord.User}
|
||||||
include_fields Dexcord.Model.MemberShared
|
include_fields Dexcord.Model.MemberShared
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
defmodule Dexcord.PartialMember do
|
defmodule Dexcord.PartialMember do
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,47 @@ defmodule Dexcord.Message do
|
||||||
field :call, {:struct, Dexcord.MessageCall}
|
field :call, {:struct, Dexcord.MessageCall}
|
||||||
field :shared_client_theme, :raw
|
field :shared_client_theme, :raw
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
defmodule Dexcord.MessageReference do
|
defmodule Dexcord.MessageReference do
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,18 @@ defmodule Dexcord.Role do
|
||||||
field :tags, {:struct, Dexcord.RoleTags}
|
field :tags, {:struct, Dexcord.RoleTags}
|
||||||
field :flags, {:flags, Dexcord.RoleFlags}
|
field :flags, {:flags, Dexcord.RoleFlags}
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
defmodule Dexcord.RoleColors do
|
defmodule Dexcord.RoleColors do
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,18 @@ defmodule Dexcord.User do
|
||||||
field :collectibles, :raw
|
field :collectibles, :raw
|
||||||
field :primary_guild, {:struct, Dexcord.PrimaryGuild}
|
field :primary_guild, {:struct, Dexcord.PrimaryGuild}
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
defmodule Dexcord.AvatarDecorationData do
|
defmodule Dexcord.AvatarDecorationData do
|
||||||
|
|
|
||||||
|
|
@ -16,4 +16,8 @@ defmodule Dexcord.Webhook do
|
||||||
field :source_channel, :raw
|
field :source_channel, :raw
|
||||||
field :url, :string
|
field :url, :string
|
||||||
end
|
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
|
end
|
||||||
|
|
|
||||||
|
|
@ -31,13 +31,13 @@ defmodule Dexcord.Slash do
|
||||||
def handle_modal("feedback_form", itx), do: Dexcord.Slash.respond(itx, "thanks!")
|
def handle_modal("feedback_form", itx), do: Dexcord.Slash.respond(itx, "thanks!")
|
||||||
end
|
end
|
||||||
|
|
||||||
`dispatch/2` routes a raw `INTERACTION_CREATE` payload on its **top-level**
|
`dispatch/2` routes a decoded `%Dexcord.Interaction{}` on its `type` atom:
|
||||||
`interaction["type"]`: type 2 (application command) → `handle_interaction/2`
|
`:application_command` → `handle_interaction/2` keyed on the data's `name`;
|
||||||
keyed on `interaction["data"]["name"]`; type 3 (message component) →
|
`:message_component` → `handle_component/2` keyed on the data's `custom_id`;
|
||||||
`handle_component/2` keyed on `interaction["data"]["custom_id"]`; type 5 (modal
|
`:modal_submit` → `handle_modal/2` keyed on the data's `custom_id`. The
|
||||||
submit) → `handle_modal/2` keyed on `interaction["data"]["custom_id"]`. The
|
|
||||||
`Dexcord.Dispatcher` calls it automatically for those types when a `slash:`
|
`Dexcord.Dispatcher` calls it automatically for those types when a `slash:`
|
||||||
module is configured (the raw event still reaches the handler).
|
module is configured (the event still reaches the handler too). Every callback
|
||||||
|
receives the full `%Dexcord.Interaction{}` as its second argument.
|
||||||
|
|
||||||
## Response helpers
|
## Response helpers
|
||||||
|
|
||||||
|
|
@ -56,13 +56,22 @@ defmodule Dexcord.Slash do
|
||||||
@callback commands() :: [map()]
|
@callback commands() :: [map()]
|
||||||
|
|
||||||
@doc "Handles a routed application-command interaction (type 2) for command `name`."
|
@doc "Handles a routed application-command interaction (type 2) for command `name`."
|
||||||
@callback handle_interaction(name :: String.t(), interaction :: map()) :: any()
|
@callback handle_interaction(
|
||||||
|
name :: String.t() | nil,
|
||||||
|
interaction :: Dexcord.Interaction.t()
|
||||||
|
) :: any()
|
||||||
|
|
||||||
@doc "Handles a routed message-component interaction (type 3) for `custom_id`."
|
@doc "Handles a routed message-component interaction (type 3) for `custom_id`."
|
||||||
@callback handle_component(custom_id :: String.t() | nil, interaction :: map()) :: any()
|
@callback handle_component(
|
||||||
|
custom_id :: String.t() | nil,
|
||||||
|
interaction :: Dexcord.Interaction.t()
|
||||||
|
) :: any()
|
||||||
|
|
||||||
@doc "Handles a routed modal-submit interaction (type 5) for `custom_id`."
|
@doc "Handles a routed modal-submit interaction (type 5) for `custom_id`."
|
||||||
@callback handle_modal(custom_id :: String.t() | nil, interaction :: map()) :: any()
|
@callback handle_modal(
|
||||||
|
custom_id :: String.t() | nil,
|
||||||
|
interaction :: Dexcord.Interaction.t()
|
||||||
|
) :: any()
|
||||||
|
|
||||||
# Only `commands/0` and `handle_interaction/2` are required; a module that never
|
# Only `commands/0` and `handle_interaction/2` are required; a module that never
|
||||||
# uses components or modals need not define those callbacks (the injected
|
# uses components or modals need not define those callbacks (the injected
|
||||||
|
|
@ -112,21 +121,42 @@ defmodule Dexcord.Slash do
|
||||||
# --- routing ------------------------------------------------------------
|
# --- routing ------------------------------------------------------------
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Routes an `INTERACTION_CREATE` payload to `mod` on its top-level `"type"`.
|
Routes a decoded `%Dexcord.Interaction{}` to `mod` on its `type` atom.
|
||||||
|
|
||||||
* type 2 (application command) → `mod.handle_interaction/2`, keyed on
|
* `:application_command` → `mod.handle_interaction/2`, keyed on the data's `name`
|
||||||
`interaction["data"]["name"]`
|
* `:message_component` → `mod.handle_component/2`, keyed on the data's `custom_id`
|
||||||
* type 3 (message component) → `mod.handle_component/2`, keyed on
|
* `:modal_submit` → `mod.handle_modal/2`, keyed on the data's `custom_id`
|
||||||
`interaction["data"]["custom_id"]`
|
|
||||||
* type 5 (modal submit) → `mod.handle_modal/2`, keyed on
|
|
||||||
`interaction["data"]["custom_id"]`
|
|
||||||
|
|
||||||
Any other (or missing) type is ignored - the `Dexcord.Dispatcher` only routes
|
Any other (or nil) type is ignored - the `Dexcord.Dispatcher` only routes those
|
||||||
types 2/3/5 here, so this is defensive.
|
three types here, so this is defensive.
|
||||||
|
|
||||||
|
A malformed `INTERACTION_CREATE` that fails to decode into a struct is routed by
|
||||||
|
the dispatcher's integer-type gate to the **degraded raw-map head** below, which
|
||||||
|
reproduces the pre-typed routing on the top-level integer `"type"` so a
|
||||||
|
not-quite-decodable interaction still reaches the handler with the raw map.
|
||||||
"""
|
"""
|
||||||
@spec dispatch(map(), module()) :: any()
|
@spec dispatch(Dexcord.Interaction.t() | map(), module()) :: any()
|
||||||
def dispatch(interaction, mod) when is_map(interaction) and is_atom(mod) do
|
def dispatch(%Dexcord.Interaction{} = interaction, mod) when is_atom(mod) do
|
||||||
case interaction["type"] do
|
case interaction.type do
|
||||||
|
:application_command ->
|
||||||
|
mod.handle_interaction(data_field(interaction, :name), interaction)
|
||||||
|
|
||||||
|
:message_component ->
|
||||||
|
mod.handle_component(data_field(interaction, :custom_id), interaction)
|
||||||
|
|
||||||
|
:modal_submit ->
|
||||||
|
mod.handle_modal(data_field(interaction, :custom_id), interaction)
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
:ignore
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Degraded raw-map head: a raw INTERACTION_CREATE the decoder could not turn into
|
||||||
|
# a %Dexcord.Interaction{}. Routes exactly as the pre-typed dispatcher did, on the
|
||||||
|
# top-level integer "type", handing the raw map to the callback.
|
||||||
|
def dispatch(%{"type" => type} = interaction, mod) when is_atom(mod) do
|
||||||
|
case type do
|
||||||
2 -> mod.handle_interaction(get_in(interaction, ["data", "name"]), interaction)
|
2 -> mod.handle_interaction(get_in(interaction, ["data", "name"]), interaction)
|
||||||
3 -> mod.handle_component(get_in(interaction, ["data", "custom_id"]), interaction)
|
3 -> mod.handle_component(get_in(interaction, ["data", "custom_id"]), interaction)
|
||||||
5 -> mod.handle_modal(get_in(interaction, ["data", "custom_id"]), interaction)
|
5 -> mod.handle_modal(get_in(interaction, ["data", "custom_id"]), interaction)
|
||||||
|
|
@ -134,6 +164,12 @@ defmodule Dexcord.Slash do
|
||||||
end
|
end
|
||||||
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 ---------------------------------------------------
|
# --- response helpers ---------------------------------------------------
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
|
|
@ -142,15 +178,16 @@ defmodule Dexcord.Slash do
|
||||||
`text_or_map` is either a binary (used as `content`) or a map supporting
|
`text_or_map` is either a binary (used as `content`) or a map supporting
|
||||||
`content`, `embeds`, `components`, and `ephemeral: true`.
|
`content`, `embeds`, `components`, and `ephemeral: true`.
|
||||||
"""
|
"""
|
||||||
@spec respond(map(), String.t() | map()) :: {:ok, map()} | {:ok, nil} | {:error, term()}
|
@spec respond(Dexcord.Interaction.t(), String.t() | map()) ::
|
||||||
|
{:ok, map()} | {:ok, nil} | {:error, term()}
|
||||||
def respond(interaction, content) when is_binary(content) do
|
def respond(interaction, content) when is_binary(content) do
|
||||||
respond(interaction, %{content: content})
|
respond(interaction, %{content: content})
|
||||||
end
|
end
|
||||||
|
|
||||||
def respond(interaction, %{} = data) do
|
def respond(%Dexcord.Interaction{} = interaction, %{} = data) do
|
||||||
Dexcord.Api.create_interaction_response(
|
Dexcord.Api.create_interaction_response(
|
||||||
interaction["id"],
|
interaction.id,
|
||||||
interaction["token"],
|
interaction.token,
|
||||||
%{"type" => 4, "data" => message_data(data)}
|
%{"type" => 4, "data" => message_data(data)}
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
@ -159,11 +196,11 @@ defmodule Dexcord.Slash do
|
||||||
Sends a deferred response (type 5) - shows a loading state while you prepare a
|
Sends a deferred response (type 5) - shows a loading state while you prepare a
|
||||||
followup or edit the original response.
|
followup or edit the original response.
|
||||||
"""
|
"""
|
||||||
@spec respond_later(map()) :: {:ok, map()} | {:ok, nil} | {:error, term()}
|
@spec respond_later(Dexcord.Interaction.t()) :: {:ok, map()} | {:ok, nil} | {:error, term()}
|
||||||
def respond_later(interaction) do
|
def respond_later(%Dexcord.Interaction{} = interaction) do
|
||||||
Dexcord.Api.create_interaction_response(
|
Dexcord.Api.create_interaction_response(
|
||||||
interaction["id"],
|
interaction.id,
|
||||||
interaction["token"],
|
interaction.token,
|
||||||
%{"type" => 5}
|
%{"type" => 5}
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
@ -173,15 +210,15 @@ defmodule Dexcord.Slash do
|
||||||
|
|
||||||
`text_or_map` is a binary (used as `content`) or a map as in `respond/2`.
|
`text_or_map` is a binary (used as `content`) or a map as in `respond/2`.
|
||||||
"""
|
"""
|
||||||
@spec followup(map(), String.t() | map()) :: {:ok, map()} | {:error, term()}
|
@spec followup(Dexcord.Interaction.t(), String.t() | map()) :: {:ok, map()} | {:error, term()}
|
||||||
def followup(interaction, content) when is_binary(content) do
|
def followup(interaction, content) when is_binary(content) do
|
||||||
followup(interaction, %{content: content})
|
followup(interaction, %{content: content})
|
||||||
end
|
end
|
||||||
|
|
||||||
def followup(interaction, %{} = data) do
|
def followup(%Dexcord.Interaction{} = interaction, %{} = data) do
|
||||||
Dexcord.Api.create_followup_message(
|
Dexcord.Api.create_followup_message(
|
||||||
interaction["application_id"],
|
interaction.application_id,
|
||||||
interaction["token"],
|
interaction.token,
|
||||||
message_data(data)
|
message_data(data)
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
@ -191,15 +228,16 @@ defmodule Dexcord.Slash do
|
||||||
|
|
||||||
`text_or_map` is a binary (used as `content`) or a map as in `respond/2`.
|
`text_or_map` is a binary (used as `content`) or a map as in `respond/2`.
|
||||||
"""
|
"""
|
||||||
@spec edit_response(map(), String.t() | map()) :: {:ok, map()} | {:error, term()}
|
@spec edit_response(Dexcord.Interaction.t(), String.t() | map()) ::
|
||||||
|
{:ok, map()} | {:error, term()}
|
||||||
def edit_response(interaction, content) when is_binary(content) do
|
def edit_response(interaction, content) when is_binary(content) do
|
||||||
edit_response(interaction, %{content: content})
|
edit_response(interaction, %{content: content})
|
||||||
end
|
end
|
||||||
|
|
||||||
def edit_response(interaction, %{} = data) do
|
def edit_response(%Dexcord.Interaction{} = interaction, %{} = data) do
|
||||||
Dexcord.Api.edit_original_interaction_response(
|
Dexcord.Api.edit_original_interaction_response(
|
||||||
interaction["application_id"],
|
interaction.application_id,
|
||||||
interaction["token"],
|
interaction.token,
|
||||||
message_data(data)
|
message_data(data)
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
@ -213,7 +251,7 @@ defmodule Dexcord.Slash do
|
||||||
base =
|
base =
|
||||||
Enum.reduce([:content, :embeds, :components], %{}, fn field, acc ->
|
Enum.reduce([:content, :embeds, :components], %{}, fn field, acc ->
|
||||||
case fetch_any(data, field) do
|
case fetch_any(data, field) do
|
||||||
{:ok, value} -> Map.put(acc, Atom.to_string(field), value)
|
{:ok, value} -> Map.put(acc, Atom.to_string(field), encode_values(value))
|
||||||
:error -> acc
|
:error -> acc
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
|
|
@ -224,6 +262,14 @@ defmodule Dexcord.Slash do
|
||||||
end
|
end
|
||||||
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).
|
# Combines a caller-supplied integer `flags` with the ephemeral bit (64).
|
||||||
# Returns nil when neither is present so the key is omitted entirely.
|
# Returns nil when neither is present so the key is omitted entirely.
|
||||||
defp flags(data) do
|
defp flags(data) do
|
||||||
|
|
|
||||||
|
|
@ -99,7 +99,15 @@ defmodule Dexcord.Slash.Registrar do
|
||||||
# A single end-to-end registration: resolve the app id, then overwrite commands.
|
# A single end-to-end registration: resolve the app id, then overwrite commands.
|
||||||
# Returns :ok | {:error, message} - never exits.
|
# Returns :ok | {:error, message} - never exits.
|
||||||
defp try_register(config) do
|
defp try_register(config) do
|
||||||
commands = config.slash.commands()
|
# Command defs may be plain maps or Dexcord command-builder structs; normalize
|
||||||
|
# struct defs to their wire maps before the (untouched) downstream register/3.
|
||||||
|
# `[]` normalizes to `[]`, so the empty-list global guard below still fires.
|
||||||
|
commands =
|
||||||
|
config.slash.commands()
|
||||||
|
|> Enum.map(fn
|
||||||
|
%_{} = struct -> struct.__struct__.to_map(struct)
|
||||||
|
map when is_map(map) -> map
|
||||||
|
end)
|
||||||
|
|
||||||
with {:ok, app_id} <- resolve_app_id() do
|
with {:ok, app_id} <- resolve_app_id() do
|
||||||
Dexcord.Config.put_application_id(app_id)
|
Dexcord.Config.put_application_id(app_id)
|
||||||
|
|
|
||||||
22
lib/dexcord/util.ex
Normal file
22
lib/dexcord/util.ex
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
defmodule Dexcord.Util do
|
||||||
|
@moduledoc "Small formatting helpers for the ergonomics layer."
|
||||||
|
|
||||||
|
# The nine documented Discord timestamp styles (Message Formatting reference):
|
||||||
|
# t short time, T long time, d short date, D long date, f short date/time,
|
||||||
|
# F long date/time, s short relative-ish (seconds), S, R relative.
|
||||||
|
@styles ~w(t T d D f F s S R)
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Renders a Discord timestamp markdown token `<t:unix[:style]>`.
|
||||||
|
|
||||||
|
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: "<t:#{unix}:#{style}>"
|
||||||
|
end
|
||||||
103
test/dexcord/cache_fill_test.exs
Normal file
103
test/dexcord/cache_fill_test.exs
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
defmodule Dexcord.CacheFillTest do
|
||||||
|
@moduledoc """
|
||||||
|
Unit tests for `Dexcord.Cache.fill/1` (api-surface.AC3.8): best-effort
|
||||||
|
hydration of an envelope's declared `hydrate` slots from ETS only.
|
||||||
|
|
||||||
|
These tests start ONLY `Dexcord.Cache` - no FakeRest, no Finch pool. `fill/1`
|
||||||
|
reads ETS and nothing else, so any accidental HTTP round-trip would crash on
|
||||||
|
the missing connection pool; the suite passing is the proof that `fill/1`
|
||||||
|
never touches the network.
|
||||||
|
"""
|
||||||
|
use ExUnit.Case, async: false
|
||||||
|
|
||||||
|
alias Dexcord.Cache
|
||||||
|
alias Dexcord.Events
|
||||||
|
|
||||||
|
setup do
|
||||||
|
Dexcord.EnvSandbox.sandbox_env()
|
||||||
|
start_supervised!(Dexcord.Cache)
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
# Seed the cache exactly as the dispatcher would: decode once, hand the cache
|
||||||
|
# both the decoded struct and the raw partial map.
|
||||||
|
defp feed(name, raw) do
|
||||||
|
Cache.handle_dispatch(name, Events.decode(name, raw), raw, %{cache_presences: false})
|
||||||
|
end
|
||||||
|
|
||||||
|
# The golden guild's own ids (see test/fixtures/guild_create.json).
|
||||||
|
@guild_id 900_000_000_000_000_000
|
||||||
|
# channel 100000000000000000 is a type-0 text channel.
|
||||||
|
@channel_id 100_000_000_000_000_000
|
||||||
|
# member/user 800000000000000000 is Nelly.
|
||||||
|
@user_id 800_000_000_000_000_000
|
||||||
|
|
||||||
|
defp reaction(overrides) do
|
||||||
|
raw =
|
||||||
|
Map.merge(
|
||||||
|
%{
|
||||||
|
"user_id" => to_string(@user_id),
|
||||||
|
"channel_id" => to_string(@channel_id),
|
||||||
|
"message_id" => "100000000000000099",
|
||||||
|
"guild_id" => to_string(@guild_id),
|
||||||
|
"emoji" => %{"id" => nil, "name" => "👍"}
|
||||||
|
},
|
||||||
|
overrides
|
||||||
|
)
|
||||||
|
|
||||||
|
Events.decode(:MESSAGE_REACTION_ADD, raw)
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "fill/1 hydration (AC3.8)" do
|
||||||
|
test "populates every declared slot from the cache on a full hit" do
|
||||||
|
feed(:GUILD_CREATE, Dexcord.Fixtures.load!("guild_create.json"))
|
||||||
|
|
||||||
|
event = reaction(%{})
|
||||||
|
# After decode the hydrate slots are always nil.
|
||||||
|
assert %Dexcord.Events.ReactionAdd{user: nil, channel: nil, guild: nil} = event
|
||||||
|
|
||||||
|
filled = Cache.fill(event)
|
||||||
|
|
||||||
|
assert %Dexcord.Events.ReactionAdd{
|
||||||
|
user: %Dexcord.User{id: @user_id},
|
||||||
|
channel: %Dexcord.TextChannel{id: @channel_id},
|
||||||
|
guild: %Dexcord.Guild{id: @guild_id}
|
||||||
|
} = filled
|
||||||
|
end
|
||||||
|
|
||||||
|
test "a cache miss leaves that slot nil while the others still fill" do
|
||||||
|
feed(:GUILD_CREATE, Dexcord.Fixtures.load!("guild_create.json"))
|
||||||
|
|
||||||
|
# Unknown user id: not in the cache.
|
||||||
|
event = reaction(%{"user_id" => "111111111111111111"})
|
||||||
|
|
||||||
|
filled = Cache.fill(event)
|
||||||
|
|
||||||
|
assert filled.user == nil
|
||||||
|
assert %Dexcord.TextChannel{id: @channel_id} = filled.channel
|
||||||
|
assert %Dexcord.Guild{id: @guild_id} = filled.guild
|
||||||
|
end
|
||||||
|
|
||||||
|
test "an unseeded cache leaves every slot nil (never blocks on the network)" do
|
||||||
|
# No GUILD_CREATE seeded and, deliberately, no FakeRest/Finch started: if
|
||||||
|
# fill/1 ever reached for HTTP it would crash here instead of returning nils.
|
||||||
|
filled = Cache.fill(reaction(%{}))
|
||||||
|
|
||||||
|
assert %Dexcord.Events.ReactionAdd{user: nil, channel: nil, guild: nil} = filled
|
||||||
|
end
|
||||||
|
|
||||||
|
test "fill/1 is idempotent" do
|
||||||
|
feed(:GUILD_CREATE, Dexcord.Fixtures.load!("guild_create.json"))
|
||||||
|
|
||||||
|
event = reaction(%{})
|
||||||
|
once = Cache.fill(event)
|
||||||
|
|
||||||
|
assert Cache.fill(once) == once
|
||||||
|
end
|
||||||
|
|
||||||
|
test "a struct with no hydration slots passes through unchanged" do
|
||||||
|
user = %Dexcord.User{id: @user_id, username: "nelly"}
|
||||||
|
assert Cache.fill(user) == user
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -75,4 +75,25 @@ defmodule Dexcord.ConfigValidationTest do
|
||||||
assert %{slash_guild_ids: nil} = validate([])
|
assert %{slash_guild_ids: nil} = validate([])
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|
|
||||||
131
test/dexcord/ergonomics_helpers_test.exs
Normal file
131
test/dexcord/ergonomics_helpers_test.exs
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
defmodule Dexcord.ErgonomicsHelpersTest do
|
||||||
|
@moduledoc """
|
||||||
|
Pure helper tests for the discord.py taste layer (api-surface.AC3.4): mentions,
|
||||||
|
`String.Chars` interpolation, `Dexcord.PartialEmoji.to_string/1`, `created_at/1`,
|
||||||
|
and `Dexcord.Util.format_dt/2`.
|
||||||
|
"""
|
||||||
|
use ExUnit.Case, async: true
|
||||||
|
|
||||||
|
alias Dexcord.Util
|
||||||
|
|
||||||
|
# Discord's documented example snowflake -> 2016-04-30T11:18:25.796Z.
|
||||||
|
@known_snowflake 175_928_847_299_117_063
|
||||||
|
@known_unix_ms 1_462_015_105_796
|
||||||
|
|
||||||
|
describe "mention/1" do
|
||||||
|
test "User renders <@id>" do
|
||||||
|
assert Dexcord.User.mention(%Dexcord.User{id: 1}) == "<@1>"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "Member renders <@id> from user_id" do
|
||||||
|
assert Dexcord.Member.mention(%Dexcord.Member{user_id: 7}) == "<@7>"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "Member renders <@id> from the nested user when user_id is nil" do
|
||||||
|
assert Dexcord.Member.mention(%Dexcord.Member{user: %Dexcord.User{id: 8}}) == "<@8>"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "Role renders <@&id>" do
|
||||||
|
assert Dexcord.Role.mention(%Dexcord.Role{id: 2}) == "<@&2>"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "Dexcord.Channel.mention/1 renders <#id> for any channel struct" do
|
||||||
|
assert Dexcord.Channel.mention(%Dexcord.TextChannel{id: 3}) == "<#3>"
|
||||||
|
assert Dexcord.Channel.mention(%Dexcord.VoiceChannel{id: 4}) == "<#4>"
|
||||||
|
assert Dexcord.Channel.mention(%Dexcord.Thread{id: 5}) == "<#5>"
|
||||||
|
assert Dexcord.Channel.mention(%Dexcord.DMChannel{id: 6}) == "<#6>"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "String.Chars interpolation" do
|
||||||
|
test "a user interpolates as a real ping" do
|
||||||
|
assert "hey #{%Dexcord.User{id: 1}}" == "hey <@1>"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "a member interpolates as a real ping" do
|
||||||
|
assert "#{%Dexcord.Member{user_id: 9}}" == "<@9>"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "a role interpolates as <@&id>" do
|
||||||
|
assert "#{%Dexcord.Role{id: 2}}" == "<@&2>"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "a text channel interpolates as <#id>" do
|
||||||
|
assert "#{%Dexcord.TextChannel{id: 3}}" == "<#3>"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "a thread interpolates as <#id>" do
|
||||||
|
assert "#{%Dexcord.Thread{id: 5}}" == "<#5>"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "PartialEmoji.to_string/1" do
|
||||||
|
test "a custom (static) emoji renders <:name:id>" do
|
||||||
|
emoji = %Dexcord.PartialEmoji{id: 100, name: "blob", animated: false}
|
||||||
|
assert Dexcord.PartialEmoji.to_string(emoji) == "<:blob:100>"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "an animated emoji renders <a:name:id>" do
|
||||||
|
emoji = %Dexcord.PartialEmoji{id: 200, name: "party", animated: true}
|
||||||
|
assert Dexcord.PartialEmoji.to_string(emoji) == "<a:party:200>"
|
||||||
|
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) == "<t:1700000000:#{style}>"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "defaults to the f style" do
|
||||||
|
assert Util.format_dt(1_700_000_000) == "<t:1700000000:f>"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "accepts a DateTime and converts to unix" do
|
||||||
|
dt = DateTime.from_unix!(1_700_000_000)
|
||||||
|
assert Util.format_dt(dt) == "<t:1700000000:f>"
|
||||||
|
assert Util.format_dt(dt, "R") == "<t:1700000000: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
|
||||||
|
|
@ -116,7 +116,7 @@ defmodule Dexcord.ErgonomicsIntegrationTest do
|
||||||
itx = %{"id" => "i2", "type" => 2, "token" => "tok", "data" => %{"name" => "ping"}}
|
itx = %{"id" => "i2", "type" => 2, "token" => "tok", "data" => %{"name" => "ping"}}
|
||||||
FakeGateway.push_dispatch(fake, "INTERACTION_CREATE", itx, 20)
|
FakeGateway.push_dispatch(fake, "INTERACTION_CREATE", itx, 20)
|
||||||
|
|
||||||
assert_receive {:slash, "ping", ^itx}, @timeout
|
assert_receive {:slash, "ping", %Dexcord.Interaction{type: :application_command}}, @timeout
|
||||||
|
|
||||||
assert_receive {:raw, :INTERACTION_CREATE, %Dexcord.Interaction{type: :application_command}},
|
assert_receive {:raw, :INTERACTION_CREATE, %Dexcord.Interaction{type: :application_command}},
|
||||||
@timeout
|
@timeout
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,7 @@ defmodule Dexcord.GatewayIntegrationTest do
|
||||||
|
|
||||||
# A dispatch reaches the user handler...
|
# A dispatch reaches the user handler...
|
||||||
FakeGateway.push_dispatch(fake, "MESSAGE_CREATE", %{"content" => "hi"}, 7)
|
FakeGateway.push_dispatch(fake, "MESSAGE_CREATE", %{"content" => "hi"}, 7)
|
||||||
|
|
||||||
assert_receive {:handler_event, {:MESSAGE_CREATE, %Dexcord.Message{content: "hi"}}},
|
assert_receive {:handler_event, {:MESSAGE_CREATE, %Dexcord.Message{content: "hi"}}},
|
||||||
@event_timeout
|
@event_timeout
|
||||||
|
|
||||||
|
|
@ -258,6 +259,7 @@ defmodule Dexcord.GatewayIntegrationTest do
|
||||||
|
|
||||||
# The statem survived: a following real dispatch still reaches the user handler.
|
# The statem survived: a following real dispatch still reaches the user handler.
|
||||||
FakeGateway.push_dispatch(fake, "MESSAGE_CREATE", %{"content" => "alive"}, 6)
|
FakeGateway.push_dispatch(fake, "MESSAGE_CREATE", %{"content" => "alive"}, 6)
|
||||||
|
|
||||||
assert_receive {:handler_event, {:MESSAGE_CREATE, %Dexcord.Message{content: "alive"}}},
|
assert_receive {:handler_event, {:MESSAGE_CREATE, %Dexcord.Message{content: "alive"}}},
|
||||||
@event_timeout
|
@event_timeout
|
||||||
|
|
||||||
|
|
@ -290,6 +292,7 @@ defmodule Dexcord.GatewayIntegrationTest do
|
||||||
|
|
||||||
# Advance the live session's seq.
|
# Advance the live session's seq.
|
||||||
FakeGateway.push_dispatch(fake, "MESSAGE_CREATE", %{"content" => "x"}, 42)
|
FakeGateway.push_dispatch(fake, "MESSAGE_CREATE", %{"content" => "x"}, 42)
|
||||||
|
|
||||||
assert_receive {:handler_event, {:MESSAGE_CREATE, %Dexcord.Message{content: "x"}}},
|
assert_receive {:handler_event, {:MESSAGE_CREATE, %Dexcord.Message{content: "x"}}},
|
||||||
@event_timeout
|
@event_timeout
|
||||||
|
|
||||||
|
|
@ -302,6 +305,7 @@ defmodule Dexcord.GatewayIntegrationTest do
|
||||||
|
|
||||||
# Trickle a dispatch from the abandoned session during the reidentify wait.
|
# Trickle a dispatch from the abandoned session during the reidentify wait.
|
||||||
FakeGateway.push_dispatch(fake, "MESSAGE_CREATE", %{"content" => "noise"}, 9_999)
|
FakeGateway.push_dispatch(fake, "MESSAGE_CREATE", %{"content" => "noise"}, 9_999)
|
||||||
|
|
||||||
assert_receive {:handler_event, {:MESSAGE_CREATE, %Dexcord.Message{content: "noise"}}},
|
assert_receive {:handler_event, {:MESSAGE_CREATE, %Dexcord.Message{content: "noise"}}},
|
||||||
@event_timeout
|
@event_timeout
|
||||||
|
|
||||||
|
|
@ -547,6 +551,7 @@ defmodule Dexcord.GatewayIntegrationTest do
|
||||||
assert_frame(2)
|
assert_frame(2)
|
||||||
assert_receive {:handler_event, {:READY, _}}, @event_timeout
|
assert_receive {:handler_event, {:READY, _}}, @event_timeout
|
||||||
FakeGateway.push_dispatch(fake, "MESSAGE_CREATE", %{"content" => "seed"}, 5)
|
FakeGateway.push_dispatch(fake, "MESSAGE_CREATE", %{"content" => "seed"}, 5)
|
||||||
|
|
||||||
assert_receive {:handler_event, {:MESSAGE_CREATE, %Dexcord.Message{content: "seed"}}},
|
assert_receive {:handler_event, {:MESSAGE_CREATE, %Dexcord.Message{content: "seed"}}},
|
||||||
@event_timeout
|
@event_timeout
|
||||||
|
|
||||||
|
|
@ -562,6 +567,7 @@ defmodule Dexcord.GatewayIntegrationTest do
|
||||||
|
|
||||||
assert_receive {:handler_event, {:MESSAGE_CREATE, %Dexcord.Message{content: "c"}}},
|
assert_receive {:handler_event, {:MESSAGE_CREATE, %Dexcord.Message{content: "c"}}},
|
||||||
@event_timeout
|
@event_timeout
|
||||||
|
|
||||||
assert_receive {:handler_event, {:RESUMED, _}}, @event_timeout
|
assert_receive {:handler_event, {:RESUMED, _}}, @event_timeout
|
||||||
|
|
||||||
# Seq advanced to the last replayed dispatch.
|
# Seq advanced to the last replayed dispatch.
|
||||||
|
|
|
||||||
79
test/dexcord/messageable_test.exs
Normal file
79
test/dexcord/messageable_test.exs
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
defmodule Dexcord.MessageableTest do
|
||||||
|
@moduledoc """
|
||||||
|
Pure resolve tests for the `Dexcord.Messageable` protocol (api-surface.AC3.1's
|
||||||
|
match-time layer). No HTTP: every case exercises `Dexcord.Messageable.resolve/1`
|
||||||
|
directly, including the deliberate non-implementations that must fail BEFORE any
|
||||||
|
network call.
|
||||||
|
"""
|
||||||
|
use ExUnit.Case, async: true
|
||||||
|
|
||||||
|
alias Dexcord.Messageable
|
||||||
|
|
||||||
|
describe "channel-like targets resolve to {:channel, id}" do
|
||||||
|
test "guild text/announcement/voice/stage channels" do
|
||||||
|
assert Messageable.resolve(%Dexcord.TextChannel{id: 1}) == {:channel, 1}
|
||||||
|
assert Messageable.resolve(%Dexcord.AnnouncementChannel{id: 2}) == {:channel, 2}
|
||||||
|
assert Messageable.resolve(%Dexcord.VoiceChannel{id: 3}) == {:channel, 3}
|
||||||
|
assert Messageable.resolve(%Dexcord.StageChannel{id: 4}) == {:channel, 4}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "threads and DM/group-DM channels" do
|
||||||
|
assert Messageable.resolve(%Dexcord.Thread{id: 9}) == {:channel, 9}
|
||||||
|
assert Messageable.resolve(%Dexcord.DMChannel{id: 10}) == {:channel, 10}
|
||||||
|
assert Messageable.resolve(%Dexcord.GroupDMChannel{id: 11}) == {:channel, 11}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "a message resolves to its channel" do
|
||||||
|
assert Messageable.resolve(%Dexcord.Message{id: 100, channel_id: 5}) == {:channel, 5}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "an interaction resolves to its channel" do
|
||||||
|
assert Messageable.resolve(%Dexcord.Interaction{id: 1, channel_id: 7}) == {:channel, 7}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "a bare non-negative integer passes through" do
|
||||||
|
assert Messageable.resolve(123) == {:channel, 123}
|
||||||
|
assert Messageable.resolve(0) == {:channel, 0}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "user-like targets resolve to {:dm_user, id}" do
|
||||||
|
test "a user" do
|
||||||
|
assert Messageable.resolve(%Dexcord.User{id: 42}) == {:dm_user, 42}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "a member via its nested user" do
|
||||||
|
assert Messageable.resolve(%Dexcord.Member{user: %Dexcord.User{id: 43}}) ==
|
||||||
|
{:dm_user, 43}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "a member via its user_id back-reference (cache shape, no nested user)" do
|
||||||
|
assert Messageable.resolve(%Dexcord.Member{user: nil, user_id: 44}) == {:dm_user, 44}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "deliberate non-implementations fail at resolve time" do
|
||||||
|
# These structs are built via `struct/2` rather than a literal so the
|
||||||
|
# set-theoretic type checker can't statically flag the (intentional)
|
||||||
|
# protocol violation we're asserting on at runtime.
|
||||||
|
test "category channels are not Messageable" do
|
||||||
|
target = struct(Dexcord.CategoryChannel, id: 1)
|
||||||
|
assert_raise Protocol.UndefinedError, fn -> Messageable.resolve(target) end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "forum channels are not Messageable" do
|
||||||
|
target = struct(Dexcord.ForumChannel, id: 1)
|
||||||
|
assert_raise Protocol.UndefinedError, fn -> Messageable.resolve(target) end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "media channels are not Messageable" do
|
||||||
|
target = struct(Dexcord.MediaChannel, id: 1)
|
||||||
|
assert_raise Protocol.UndefinedError, fn -> Messageable.resolve(target) end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "directory channels are not Messageable" do
|
||||||
|
target = struct(Dexcord.DirectoryChannel, id: 1)
|
||||||
|
assert_raise Protocol.UndefinedError, fn -> Messageable.resolve(target) end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
193
test/dexcord/migration_guide_samples_test.exs
Normal file
193
test/dexcord/migration_guide_samples_test.exs
Normal file
|
|
@ -0,0 +1,193 @@
|
||||||
|
defmodule AlamedyaDiscord.Handler do
|
||||||
|
# KEEP IN SYNC: mirrored verbatim in
|
||||||
|
# test/dexcord/migration_guide_samples_test.exs (§3 of docs/alamedya-migration-v2.md).
|
||||||
|
use Dexcord.Handler
|
||||||
|
|
||||||
|
@self_id 1_135_637_126_222_987_365
|
||||||
|
|
||||||
|
# Webhook messages carry a `webhook_id` and a synthetic author — skip them
|
||||||
|
# first, before touching `author.bot`.
|
||||||
|
def handle_event({:MESSAGE_CREATE, %Dexcord.Message{webhook_id: id}}) when not is_nil(id),
|
||||||
|
do: :ignore
|
||||||
|
|
||||||
|
# Any bot (including ourselves) — never react.
|
||||||
|
def handle_event({:MESSAGE_CREATE, %Dexcord.Message{author: %Dexcord.User{bot: true}}}),
|
||||||
|
do: :ignore
|
||||||
|
|
||||||
|
def handle_event({:MESSAGE_CREATE, %Dexcord.Message{author: %Dexcord.User{id: @self_id}}}),
|
||||||
|
do: :ignore
|
||||||
|
|
||||||
|
def handle_event({:MESSAGE_CREATE, %Dexcord.Message{} = msg}) do
|
||||||
|
cond do
|
||||||
|
mentions_self?(msg) -> Dexcord.Message.reply(msg, "you rang?")
|
||||||
|
msg.content == "ping!" -> Dexcord.Message.reply(msg, "helo")
|
||||||
|
true -> :ignore
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp mentions_self?(%Dexcord.Message{mentions: mentions}),
|
||||||
|
do: Enum.any?(mentions, fn %Dexcord.User{id: id} -> id == @self_id end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defmodule Dexcord.MigrationGuideSamplesTest do
|
||||||
|
@moduledoc """
|
||||||
|
Mechanically pins `docs/alamedya-migration-v2.md` to the final API (AC5.1's
|
||||||
|
"code samples are valid against the final API").
|
||||||
|
|
||||||
|
The §3 handler module above is embedded VERBATIM from the guide (the keep-in-sync
|
||||||
|
comment appears in both files). We decode real MESSAGE_CREATE fixtures and drive
|
||||||
|
it through `Dexcord.FakeRest`, asserting it routes: bot/webhook/self authors send
|
||||||
|
nothing; a `"ping!"` or self-mention replies to the source channel. The second
|
||||||
|
block asserts every API the guide's snippets call still exists at the right
|
||||||
|
arity / with the right struct fields, so a rename in the library breaks this test
|
||||||
|
rather than silently rotting the guide.
|
||||||
|
"""
|
||||||
|
use ExUnit.Case, async: false
|
||||||
|
|
||||||
|
alias Dexcord.Events
|
||||||
|
alias Dexcord.FakeRest
|
||||||
|
alias Dexcord.Api.Ratelimit
|
||||||
|
|
||||||
|
@token "test.token.value"
|
||||||
|
@self_id 1_135_637_126_222_987_365
|
||||||
|
|
||||||
|
setup do
|
||||||
|
Dexcord.EnvSandbox.sandbox_env()
|
||||||
|
Dexcord.Config.put(%{token: @token, handler: AlamedyaDiscord.Handler, intents: 0})
|
||||||
|
|
||||||
|
start_supervised!({Finch, name: Dexcord.Finch})
|
||||||
|
start_supervised!(Ratelimit)
|
||||||
|
start_supervised!(Dexcord.Cache)
|
||||||
|
start_supervised!(FakeRest)
|
||||||
|
|
||||||
|
Application.put_env(:dexcord, :api_base_url, FakeRest.base_url())
|
||||||
|
FakeRest.subscribe(self())
|
||||||
|
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
defp message(raw), do: Events.decode(:MESSAGE_CREATE, raw)
|
||||||
|
|
||||||
|
describe "§3 MESSAGE_CREATE handler routes on typed struct fields" do
|
||||||
|
test "a webhook message is skipped before any send" do
|
||||||
|
msg =
|
||||||
|
message(%{
|
||||||
|
"webhook_id" => "999",
|
||||||
|
"channel_id" => "100",
|
||||||
|
"content" => "ping!",
|
||||||
|
"author" => %{"id" => "555", "username" => "hook"}
|
||||||
|
})
|
||||||
|
|
||||||
|
assert :ignore = AlamedyaDiscord.Handler.handle_event({:MESSAGE_CREATE, msg})
|
||||||
|
refute_receive {:rest_hit, _}, 50
|
||||||
|
end
|
||||||
|
|
||||||
|
test "a bot author is skipped" do
|
||||||
|
msg =
|
||||||
|
message(%{
|
||||||
|
"channel_id" => "100",
|
||||||
|
"content" => "ping!",
|
||||||
|
"author" => %{"id" => "7", "bot" => true}
|
||||||
|
})
|
||||||
|
|
||||||
|
assert :ignore = AlamedyaDiscord.Handler.handle_event({:MESSAGE_CREATE, msg})
|
||||||
|
refute_receive {:rest_hit, _}, 50
|
||||||
|
end
|
||||||
|
|
||||||
|
test "the bot's own message (@self_id) is skipped" do
|
||||||
|
msg =
|
||||||
|
message(%{
|
||||||
|
"channel_id" => "100",
|
||||||
|
"content" => "ping!",
|
||||||
|
"author" => %{"id" => Integer.to_string(@self_id)}
|
||||||
|
})
|
||||||
|
|
||||||
|
assert :ignore = AlamedyaDiscord.Handler.handle_event({:MESSAGE_CREATE, msg})
|
||||||
|
refute_receive {:rest_hit, _}, 50
|
||||||
|
end
|
||||||
|
|
||||||
|
test "a plain \"ping!\" replies \"helo\" to the source channel with a message_reference" do
|
||||||
|
FakeRest.stub(:post, "/channels/100/messages", FakeRest.resp(200, body: ~s({"id":"1"})))
|
||||||
|
|
||||||
|
msg =
|
||||||
|
message(%{
|
||||||
|
"id" => "500",
|
||||||
|
"channel_id" => "100",
|
||||||
|
"content" => "ping!",
|
||||||
|
"author" => %{"id" => "7", "bot" => false}
|
||||||
|
})
|
||||||
|
|
||||||
|
assert {:ok, %Dexcord.Message{}} =
|
||||||
|
AlamedyaDiscord.Handler.handle_event({:MESSAGE_CREATE, msg})
|
||||||
|
|
||||||
|
assert_receive {:rest_hit, %{method: "POST", path: "/channels/100/messages", body: body}}
|
||||||
|
decoded = JSON.decode!(body)
|
||||||
|
assert decoded["content"] == "helo"
|
||||||
|
assert decoded["message_reference"] == %{"message_id" => "500"}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "a self-mention replies \"you rang?\"" do
|
||||||
|
FakeRest.stub(:post, "/channels/101/messages", FakeRest.resp(200, body: ~s({"id":"2"})))
|
||||||
|
|
||||||
|
msg =
|
||||||
|
message(%{
|
||||||
|
"id" => "501",
|
||||||
|
"channel_id" => "101",
|
||||||
|
"content" => "hey bot",
|
||||||
|
"author" => %{"id" => "8", "bot" => false},
|
||||||
|
"mentions" => [%{"id" => Integer.to_string(@self_id)}]
|
||||||
|
})
|
||||||
|
|
||||||
|
assert {:ok, %Dexcord.Message{}} =
|
||||||
|
AlamedyaDiscord.Handler.handle_event({:MESSAGE_CREATE, msg})
|
||||||
|
|
||||||
|
assert_receive {:rest_hit, %{path: "/channels/101/messages", body: body}}
|
||||||
|
assert JSON.decode!(body)["content"] == "you rang?"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "every API the guide's snippets use exists at the documented shape" do
|
||||||
|
test "functions the guide calls are exported at the right arity" do
|
||||||
|
for {mod, fun, arity} <- [
|
||||||
|
# §3 — reply/3 (reply(msg, body, opts \\ []))
|
||||||
|
{Dexcord.Message, :reply, 3},
|
||||||
|
# §4 — thread cache reads
|
||||||
|
{Dexcord.Cache, :threads, 1},
|
||||||
|
{Dexcord.Cache, :channel, 1},
|
||||||
|
# §6 — slash respond
|
||||||
|
{Dexcord.Slash, :respond, 2},
|
||||||
|
# §7 — the send funnel + history stream
|
||||||
|
{Dexcord.Api, :send, 2},
|
||||||
|
{Dexcord.Api, :message_history, 2},
|
||||||
|
# §8 — hydration
|
||||||
|
{Dexcord.Cache, :fill, 1},
|
||||||
|
# §5 — snowflake boundary cast
|
||||||
|
{Dexcord.Snowflake, :cast, 1},
|
||||||
|
{Dexcord.Snowflake, :cast!, 1}
|
||||||
|
] do
|
||||||
|
Code.ensure_loaded!(mod)
|
||||||
|
|
||||||
|
assert function_exported?(mod, fun, arity),
|
||||||
|
"#{inspect(mod)}.#{fun}/#{arity} is called by the guide but is not exported"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "struct fields the guide accesses exist on their structs" do
|
||||||
|
assert_fields(%Dexcord.Message{}, [:author, :content, :channel_id, :webhook_id, :mentions])
|
||||||
|
assert_fields(%Dexcord.User{}, [:id, :bot, :username])
|
||||||
|
assert_fields(%Dexcord.Thread{}, [:id, :parent_id, :thread_metadata])
|
||||||
|
assert_fields(%Dexcord.ThreadMetadata{}, [:archived])
|
||||||
|
assert_fields(%Dexcord.Interaction{}, [:id, :token, :data])
|
||||||
|
assert_fields(%Dexcord.Events.Ready{}, [:user, :guilds])
|
||||||
|
assert_fields(%Dexcord.UnavailableGuild{}, [:id, :unavailable])
|
||||||
|
assert_fields(%Dexcord.Events.ReactionAdd{}, [:user_id, :user, :channel, :guild])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp assert_fields(struct, fields) do
|
||||||
|
for field <- fields do
|
||||||
|
assert Map.has_key?(struct, field),
|
||||||
|
"#{inspect(struct.__struct__)} is missing the `#{field}` field the guide accesses"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -139,4 +139,100 @@ defmodule Dexcord.ModelMessagePartsTest do
|
||||||
assert am.replied_user == true
|
assert am.replied_user == true
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|
|
||||||
177
test/dexcord/pagination_test.exs
Normal file
177
test/dexcord/pagination_test.exs
Normal file
|
|
@ -0,0 +1,177 @@
|
||||||
|
defmodule Dexcord.PaginationTest do
|
||||||
|
@moduledoc """
|
||||||
|
Wire tests for the lazy pagination streams on `Dexcord.Api`
|
||||||
|
(api-surface.AC3.7), driven through `Dexcord.FakeRest`.
|
||||||
|
|
||||||
|
The point of these streams is laziness: a page is fetched only when the
|
||||||
|
consumer walks that far, so `Stream.take/2` on a fresh stream makes exactly
|
||||||
|
one wire hit. These tests assert that directly (the second hit is refuted).
|
||||||
|
"""
|
||||||
|
use ExUnit.Case, async: false
|
||||||
|
|
||||||
|
alias Dexcord.Api
|
||||||
|
alias Dexcord.Api.Paginate
|
||||||
|
alias Dexcord.Api.Ratelimit
|
||||||
|
alias Dexcord.FakeRest
|
||||||
|
|
||||||
|
@token "test.token.value"
|
||||||
|
|
||||||
|
setup do
|
||||||
|
Dexcord.EnvSandbox.sandbox_env()
|
||||||
|
Dexcord.Config.put(%{token: @token, handler: nil, intents: 0})
|
||||||
|
|
||||||
|
start_supervised!({Finch, name: Dexcord.Finch})
|
||||||
|
start_supervised!(Ratelimit)
|
||||||
|
start_supervised!(Dexcord.Cache)
|
||||||
|
start_supervised!(FakeRest)
|
||||||
|
|
||||||
|
Application.put_env(:dexcord, :api_base_url, FakeRest.base_url())
|
||||||
|
FakeRest.subscribe(self())
|
||||||
|
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
# Build a JSON array body of message objects with the given ids (as strings).
|
||||||
|
defp messages_json(ids),
|
||||||
|
do: "[" <> Enum.map_join(ids, ",", fn id -> ~s({"id":"#{id}"}) end) <> "]"
|
||||||
|
|
||||||
|
# Build a JSON array of member objects (nested user id) with the given ids.
|
||||||
|
defp members_json(ids),
|
||||||
|
do: "[" <> Enum.map_join(ids, ",", fn id -> ~s({"user":{"id":"#{id}"}}) end) <> "]"
|
||||||
|
|
||||||
|
describe "AC3.7: message_history pages lazily across multiple hits" do
|
||||||
|
test "two sequential pages yield every message in order, before= cursor on hit 2" do
|
||||||
|
page1_ids = Enum.to_list(200..101//-1)
|
||||||
|
page2_ids = [100, 99, 98]
|
||||||
|
assert length(page1_ids) == 100
|
||||||
|
|
||||||
|
FakeRest.stub(
|
||||||
|
:get,
|
||||||
|
"/channels/1/messages",
|
||||||
|
FakeRest.resp(200, body: messages_json(page1_ids))
|
||||||
|
)
|
||||||
|
|
||||||
|
FakeRest.stub(
|
||||||
|
:get,
|
||||||
|
"/channels/1/messages",
|
||||||
|
FakeRest.resp(200, body: messages_json(page2_ids))
|
||||||
|
)
|
||||||
|
|
||||||
|
result = Api.message_history(1) |> Enum.to_list()
|
||||||
|
|
||||||
|
assert Enum.map(result, & &1.id) == page1_ids ++ page2_ids
|
||||||
|
|
||||||
|
# Exactly two hits; the second carries before=<last id of page 1>.
|
||||||
|
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
|
||||||
153
test/dexcord/permissions_compute_test.exs
Normal file
153
test/dexcord/permissions_compute_test.exs
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
defmodule Dexcord.PermissionsComputeTest do
|
||||||
|
@moduledoc """
|
||||||
|
Pure tests for `Dexcord.Guild.member_permissions/2,3` — the documented
|
||||||
|
overwrite-resolution algorithm (owner → base roles → ADMINISTRATOR short-circuit
|
||||||
|
→ @everyone/role/member overwrites → timeout rule), verified verbatim.
|
||||||
|
"""
|
||||||
|
use ExUnit.Case, async: true
|
||||||
|
|
||||||
|
import Bitwise
|
||||||
|
|
||||||
|
alias Dexcord.Guild
|
||||||
|
alias Dexcord.Member
|
||||||
|
alias Dexcord.Overwrite
|
||||||
|
alias Dexcord.Permissions
|
||||||
|
alias Dexcord.Role
|
||||||
|
|
||||||
|
@send Permissions.from_list([:send_messages])
|
||||||
|
@view Permissions.from_list([:view_channel])
|
||||||
|
@rmh Permissions.from_list([:read_message_history])
|
||||||
|
@admin Permissions.from_list([:administrator])
|
||||||
|
@ban Permissions.from_list([:ban_members])
|
||||||
|
@all Permissions.all() |> Map.values() |> Enum.reduce(0, &bor/2)
|
||||||
|
@timeout_allowed bor(@view, @rmh)
|
||||||
|
|
||||||
|
# Guild id 1; @everyone role has id == guild id (== 1).
|
||||||
|
defp guild(roles), do: %Guild{id: 1, owner_id: 100, roles: roles}
|
||||||
|
defp everyone(perms), do: %Role{id: 1, permissions: perms}
|
||||||
|
defp role(id, perms), do: %Role{id: id, permissions: perms}
|
||||||
|
defp member(user_id, role_ids), do: %Member{user_id: user_id, roles: role_ids}
|
||||||
|
|
||||||
|
test "the guild owner gets all permissions" do
|
||||||
|
g = guild([everyone(0)])
|
||||||
|
m = member(100, [])
|
||||||
|
assert Guild.member_permissions(g, m) == @all
|
||||||
|
end
|
||||||
|
|
||||||
|
test "an ADMINISTRATOR member gets all permissions, even with a deny-everything channel overwrite" do
|
||||||
|
g = guild([everyone(0), role(20, @admin)])
|
||||||
|
m = member(200, [20])
|
||||||
|
|
||||||
|
channel = %Dexcord.TextChannel{
|
||||||
|
id: 5,
|
||||||
|
permission_overwrites: [%Overwrite{id: 1, type: :role, allow: 0, deny: @all}]
|
||||||
|
}
|
||||||
|
|
||||||
|
assert Guild.member_permissions(g, m, channel) == @all
|
||||||
|
end
|
||||||
|
|
||||||
|
test "base permissions are the OR of @everyone and every member role" do
|
||||||
|
g = guild([everyone(@view), role(10, @send), role(11, @ban)])
|
||||||
|
m = member(200, [10, 11])
|
||||||
|
|
||||||
|
assert Guild.member_permissions(g, m) == (@view ||| @send ||| @ban)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "unknown member role ids are skipped" do
|
||||||
|
g = guild([everyone(@view), role(10, @send)])
|
||||||
|
m = member(200, [10, 999])
|
||||||
|
|
||||||
|
assert Guild.member_permissions(g, m) == (@view ||| @send)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "an @everyone channel overwrite that denies SEND_MESSAGES removes it" do
|
||||||
|
g = guild([everyone(@view), role(10, @send)])
|
||||||
|
m = member(200, [10])
|
||||||
|
|
||||||
|
channel = %Dexcord.TextChannel{
|
||||||
|
id: 5,
|
||||||
|
permission_overwrites: [%Overwrite{id: 1, type: :role, allow: 0, deny: @send}]
|
||||||
|
}
|
||||||
|
|
||||||
|
result = Guild.member_permissions(g, m, channel)
|
||||||
|
refute Permissions.has?(result, :send_messages)
|
||||||
|
assert Permissions.has?(result, :view_channel)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "a role overwrite allow restores a bit denied by @everyone (deny then allow order)" do
|
||||||
|
g = guild([everyone(@view), role(10, @send)])
|
||||||
|
m = member(200, [10])
|
||||||
|
|
||||||
|
channel = %Dexcord.TextChannel{
|
||||||
|
id: 5,
|
||||||
|
permission_overwrites: [
|
||||||
|
%Overwrite{id: 1, type: :role, allow: 0, deny: @send},
|
||||||
|
%Overwrite{id: 10, type: :role, allow: @send, deny: 0}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
result = Guild.member_permissions(g, m, channel)
|
||||||
|
assert Permissions.has?(result, :send_messages)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "a member overwrite deny wins over a role overwrite allow (member applied last)" do
|
||||||
|
g = guild([everyone(@view), role(10, 0)])
|
||||||
|
m = member(200, [10])
|
||||||
|
|
||||||
|
channel = %Dexcord.TextChannel{
|
||||||
|
id: 5,
|
||||||
|
permission_overwrites: [
|
||||||
|
%Overwrite{id: 10, type: :role, allow: @send, deny: 0},
|
||||||
|
%Overwrite{id: 200, type: :member, allow: 0, deny: @send}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
result = Guild.member_permissions(g, m, channel)
|
||||||
|
refute Permissions.has?(result, :send_messages)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "within the role tier, an allow OR-aggregates over a deny of the same bit (allow wins)" do
|
||||||
|
g = guild([everyone(@view), role(10, 0), role(11, 0)])
|
||||||
|
m = member(200, [10, 11])
|
||||||
|
|
||||||
|
channel = %Dexcord.TextChannel{
|
||||||
|
id: 5,
|
||||||
|
permission_overwrites: [
|
||||||
|
%Overwrite{id: 10, type: :role, allow: 0, deny: @send},
|
||||||
|
%Overwrite{id: 11, type: :role, allow: @send, deny: 0}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
result = Guild.member_permissions(g, m, channel)
|
||||||
|
assert Permissions.has?(result, :send_messages)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "a timed-out member keeps exactly VIEW_CHANNEL | READ_MESSAGE_HISTORY" do
|
||||||
|
future = DateTime.add(DateTime.utc_now(), 3600, :second)
|
||||||
|
g = guild([everyone(@view ||| @send ||| @rmh)])
|
||||||
|
|
||||||
|
m = %Member{user_id: 200, roles: [], communication_disabled_until: future}
|
||||||
|
|
||||||
|
assert Guild.member_permissions(g, m) == @timeout_allowed
|
||||||
|
end
|
||||||
|
|
||||||
|
test "a timed-out administrator is unaffected (owner/admin skip the timeout rule)" do
|
||||||
|
future = DateTime.add(DateTime.utc_now(), 3600, :second)
|
||||||
|
g = guild([everyone(0), role(20, @admin)])
|
||||||
|
|
||||||
|
m = %Member{user_id: 200, roles: [20], communication_disabled_until: future}
|
||||||
|
|
||||||
|
assert Guild.member_permissions(g, m) == @all
|
||||||
|
end
|
||||||
|
|
||||||
|
test "a PAST communication_disabled_until is NOT a timeout" do
|
||||||
|
past = DateTime.add(DateTime.utc_now(), -3600, :second)
|
||||||
|
g = guild([everyone(@view ||| @send)])
|
||||||
|
|
||||||
|
m = %Member{user_id: 200, roles: [], communication_disabled_until: past}
|
||||||
|
|
||||||
|
result = Guild.member_permissions(g, m)
|
||||||
|
assert Permissions.has?(result, :send_messages)
|
||||||
|
assert Permissions.has?(result, :view_channel)
|
||||||
|
end
|
||||||
|
end
|
||||||
219
test/dexcord/send_test.exs
Normal file
219
test/dexcord/send_test.exs
Normal file
|
|
@ -0,0 +1,219 @@
|
||||||
|
defmodule Dexcord.SendTest do
|
||||||
|
@moduledoc """
|
||||||
|
Wire tests for the `Dexcord.Api.send/2,3` funnel and its lazy DM dance
|
||||||
|
(api-surface.AC3.1, api-surface.AC3.2), driven through `Dexcord.FakeRest`.
|
||||||
|
"""
|
||||||
|
use ExUnit.Case, async: false
|
||||||
|
|
||||||
|
alias Dexcord.Api
|
||||||
|
alias Dexcord.Api.Ratelimit
|
||||||
|
alias Dexcord.FakeRest
|
||||||
|
|
||||||
|
@token "test.token.value"
|
||||||
|
|
||||||
|
setup do
|
||||||
|
Dexcord.EnvSandbox.sandbox_env()
|
||||||
|
Dexcord.Config.put(%{token: @token, handler: nil, intents: 0})
|
||||||
|
|
||||||
|
start_supervised!({Finch, name: Dexcord.Finch})
|
||||||
|
start_supervised!(Ratelimit)
|
||||||
|
start_supervised!(Dexcord.Cache)
|
||||||
|
start_supervised!(FakeRest)
|
||||||
|
|
||||||
|
Application.put_env(:dexcord, :api_base_url, FakeRest.base_url())
|
||||||
|
FakeRest.subscribe(self())
|
||||||
|
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "AC3.1: send/2 resolves channels, threads, messages, and bare ids to the right route" do
|
||||||
|
test "a text channel struct" do
|
||||||
|
FakeRest.stub(:post, "/channels/1/messages", FakeRest.resp(200, body: ~s({"id":"1"})))
|
||||||
|
|
||||||
|
assert {:ok, %Dexcord.Message{}} = Api.send(%Dexcord.TextChannel{id: 1}, "hi")
|
||||||
|
|
||||||
|
assert_receive {:rest_hit, %{method: "POST", path: "/channels/1/messages"}}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "a thread struct" do
|
||||||
|
FakeRest.stub(:post, "/channels/9/messages", FakeRest.resp(200, body: ~s({"id":"2"})))
|
||||||
|
|
||||||
|
assert {:ok, %Dexcord.Message{}} = Api.send(%Dexcord.Thread{id: 9}, "hi")
|
||||||
|
|
||||||
|
assert_receive {:rest_hit, %{path: "/channels/9/messages"}}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "a bare snowflake" do
|
||||||
|
FakeRest.stub(:post, "/channels/123/messages", FakeRest.resp(200, body: ~s({"id":"3"})))
|
||||||
|
|
||||||
|
assert {:ok, %Dexcord.Message{}} = Api.send(123, "hi")
|
||||||
|
|
||||||
|
assert_receive {:rest_hit, %{path: "/channels/123/messages"}}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "a message resolves to its channel" do
|
||||||
|
FakeRest.stub(:post, "/channels/5/messages", FakeRest.resp(200, body: ~s({"id":"4"})))
|
||||||
|
|
||||||
|
assert {:ok, %Dexcord.Message{}} = Api.send(%Dexcord.Message{channel_id: 5}, "hi")
|
||||||
|
|
||||||
|
assert_receive {:rest_hit, %{path: "/channels/5/messages"}}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "AC3.2: send/2 to a user lazily creates the DM once, then reuses it" do
|
||||||
|
setup do
|
||||||
|
FakeRest.stub(
|
||||||
|
:post,
|
||||||
|
"/users/@me/channels",
|
||||||
|
FakeRest.resp(200, body: ~s({"id":"77","type":1}))
|
||||||
|
)
|
||||||
|
|
||||||
|
FakeRest.stub(:post, "/channels/77/messages", FakeRest.resp(200, body: ~s({"id":"9"})))
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
test "first send hits create-DM then the message; second send skips create-DM" do
|
||||||
|
# First send: create the DM channel, then post the message.
|
||||||
|
assert {:ok, %Dexcord.Message{}} = Api.send(%Dexcord.User{id: 42}, "yo")
|
||||||
|
|
||||||
|
assert_receive {:rest_hit, %{method: "POST", path: "/users/@me/channels"}}
|
||||||
|
assert_receive {:rest_hit, %{method: "POST", path: "/channels/77/messages"}}
|
||||||
|
|
||||||
|
# Second send: the DM channel id is cached, so ONLY a message hit occurs.
|
||||||
|
assert {:ok, %Dexcord.Message{}} = Api.send(%Dexcord.User{id: 42}, "again")
|
||||||
|
|
||||||
|
assert_receive {:rest_hit, %{method: "POST", path: "/channels/77/messages"}}
|
||||||
|
refute_receive {:rest_hit, %{path: "/users/@me/channels"}}, 50
|
||||||
|
end
|
||||||
|
|
||||||
|
test "a member (via nested user) reuses the same cached DM as the user" do
|
||||||
|
assert {:ok, %Dexcord.Message{}} = Api.send(%Dexcord.User{id: 42}, "first")
|
||||||
|
assert_receive {:rest_hit, %{path: "/users/@me/channels"}}
|
||||||
|
assert_receive {:rest_hit, %{path: "/channels/77/messages"}}
|
||||||
|
|
||||||
|
assert {:ok, %Dexcord.Message{}} =
|
||||||
|
Api.send(%Dexcord.Member{user: %Dexcord.User{id: 42}}, "second")
|
||||||
|
|
||||||
|
assert_receive {:rest_hit, %{path: "/channels/77/messages"}}
|
||||||
|
refute_receive {:rest_hit, %{path: "/users/@me/channels"}}, 50
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "AC3.3: Message.reply/2,3 sets message_reference and mention_author" do
|
||||||
|
setup do
|
||||||
|
FakeRest.stub(:post, "/channels/5/messages", FakeRest.resp(200, body: ~s({"id":"1"})))
|
||||||
|
%{msg: %Dexcord.Message{id: 555, channel_id: 5}}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "reply/2 sets message_reference to the source message", %{msg: msg} do
|
||||||
|
assert {:ok, %Dexcord.Message{}} = Dexcord.Message.reply(msg, "pong")
|
||||||
|
|
||||||
|
assert_receive {:rest_hit, %{path: "/channels/5/messages", body: body}}
|
||||||
|
decoded = JSON.decode!(body)
|
||||||
|
|
||||||
|
assert decoded["content"] == "pong"
|
||||||
|
assert decoded["message_reference"] == %{"message_id" => "555"}
|
||||||
|
refute Map.has_key?(decoded, "allowed_mentions")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "mention_author: false sets allowed_mentions.replied_user to false", %{msg: msg} do
|
||||||
|
assert {:ok, %Dexcord.Message{}} = Dexcord.Message.reply(msg, "pong", mention_author: false)
|
||||||
|
|
||||||
|
assert_receive {:rest_hit, %{path: "/channels/5/messages", body: body}}
|
||||||
|
decoded = JSON.decode!(body)
|
||||||
|
|
||||||
|
assert decoded["allowed_mentions"] == %{"replied_user" => false}
|
||||||
|
assert decoded["message_reference"] == %{"message_id" => "555"}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "mention_author: true preserves other allowed_mentions keys and wins over the body",
|
||||||
|
%{msg: msg} do
|
||||||
|
body = %{
|
||||||
|
"content" => "pong",
|
||||||
|
"allowed_mentions" => %{"users" => ["7"], "replied_user" => false}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert {:ok, %Dexcord.Message{}} = Dexcord.Message.reply(msg, body, mention_author: true)
|
||||||
|
|
||||||
|
assert_receive {:rest_hit, %{path: "/channels/5/messages", body: raw}}
|
||||||
|
decoded = JSON.decode!(raw)
|
||||||
|
|
||||||
|
assert decoded["allowed_mentions"] == %{"users" => ["7"], "replied_user" => true}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "AC3.5: config-level allowed_mentions default merges field-wise per send" do
|
||||||
|
setup do
|
||||||
|
FakeRest.stub(:post, "/channels/1/messages", FakeRest.resp(200, body: ~s({"id":"1"})))
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
defp put_config_with(allowed_mentions) do
|
||||||
|
Dexcord.Config.put(%{
|
||||||
|
token: @token,
|
||||||
|
handler: nil,
|
||||||
|
intents: 0,
|
||||||
|
allowed_mentions: allowed_mentions
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
test "the configured default applies when the send carries none" do
|
||||||
|
put_config_with(%{"parse" => []})
|
||||||
|
|
||||||
|
assert {:ok, %Dexcord.Message{}} = Api.send(1, "hi")
|
||||||
|
|
||||||
|
assert_receive {:rest_hit, %{path: "/channels/1/messages", body: body}}
|
||||||
|
decoded = JSON.decode!(body)
|
||||||
|
|
||||||
|
assert decoded["allowed_mentions"] == %{"parse" => []}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "a per-send value merges field-wise over the default (both keys survive)" do
|
||||||
|
put_config_with(%{"parse" => []})
|
||||||
|
|
||||||
|
body = %{"content" => "hi", "allowed_mentions" => %{"users" => ["5"]}}
|
||||||
|
assert {:ok, %Dexcord.Message{}} = Api.send(1, body)
|
||||||
|
|
||||||
|
assert_receive {:rest_hit, %{path: "/channels/1/messages", body: raw}}
|
||||||
|
decoded = JSON.decode!(raw)
|
||||||
|
|
||||||
|
assert decoded["allowed_mentions"] == %{"parse" => [], "users" => ["5"]}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "no config default and no per-send value leaves allowed_mentions absent" do
|
||||||
|
# Base config (no :allowed_mentions) is already in place from the top setup.
|
||||||
|
assert {:ok, %Dexcord.Message{}} = Api.send(1, "hi")
|
||||||
|
|
||||||
|
assert_receive {:rest_hit, %{path: "/channels/1/messages", body: body}}
|
||||||
|
decoded = JSON.decode!(body)
|
||||||
|
|
||||||
|
refute Map.has_key?(decoded, "allowed_mentions")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "AC3.6: an Embed built with the builder rides Api.send as valid wire JSON" do
|
||||||
|
setup do
|
||||||
|
FakeRest.stub(:post, "/channels/1/messages", FakeRest.resp(200, body: ~s({"id":"1"})))
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
test "send/2 with embeds: [embed] serializes the embed" do
|
||||||
|
embed =
|
||||||
|
Dexcord.Embed.new()
|
||||||
|
|> Dexcord.Embed.title("t")
|
||||||
|
|> Dexcord.Embed.field("a", "b", inline: true)
|
||||||
|
|
||||||
|
assert {:ok, %Dexcord.Message{}} = Api.send(1, embeds: [embed])
|
||||||
|
|
||||||
|
assert_receive {:rest_hit, %{path: "/channels/1/messages", body: body}}
|
||||||
|
decoded = JSON.decode!(body)
|
||||||
|
|
||||||
|
assert decoded["embeds"] == [
|
||||||
|
%{
|
||||||
|
"title" => "t",
|
||||||
|
"fields" => [%{"name" => "a", "value" => "b", "inline" => true}]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -5,6 +5,7 @@ defmodule Dexcord.SlashTest do
|
||||||
|
|
||||||
alias Dexcord.Api.Ratelimit
|
alias Dexcord.Api.Ratelimit
|
||||||
alias Dexcord.FakeRest
|
alias Dexcord.FakeRest
|
||||||
|
alias Dexcord.Interaction
|
||||||
alias Dexcord.Slash
|
alias Dexcord.Slash
|
||||||
|
|
||||||
@token "test.token.value"
|
@token "test.token.value"
|
||||||
|
|
@ -50,25 +51,37 @@ defmodule Dexcord.SlashTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "dispatch/2 routes a type-2 interaction to handle_interaction/2 by name" do
|
test "dispatch/2 routes a type-2 interaction to handle_interaction/2 by name" do
|
||||||
itx = %{"type" => 2, "data" => %{"name" => "ping"}, "id" => "1"}
|
itx = Interaction.from_map(%{"type" => 2, "data" => %{"name" => "ping"}, "id" => "1"})
|
||||||
assert Slash.dispatch(itx, Commands) == :ok
|
assert Slash.dispatch(itx, Commands) == :ok
|
||||||
assert_received {:handled, "ping", ^itx}
|
assert_received {:handled, "ping", %Dexcord.Interaction{type: :application_command}}
|
||||||
end
|
end
|
||||||
|
|
||||||
test "dispatch/2 routes a type-3 (component) interaction to handle_component/2 by custom_id" do
|
test "dispatch/2 routes a type-3 (component) interaction to handle_component/2 by custom_id" do
|
||||||
itx = %{"type" => 3, "data" => %{"custom_id" => "refresh"}, "id" => "2"}
|
itx =
|
||||||
|
Interaction.from_map(%{"type" => 3, "data" => %{"custom_id" => "refresh"}, "id" => "2"})
|
||||||
|
|
||||||
assert Slash.dispatch(itx, Commands) == :ok
|
assert Slash.dispatch(itx, Commands) == :ok
|
||||||
assert_received {:component, "refresh", ^itx}
|
assert_received {:component, "refresh", %Dexcord.Interaction{type: :message_component}}
|
||||||
end
|
end
|
||||||
|
|
||||||
test "dispatch/2 routes a type-5 (modal) interaction to handle_modal/2 by custom_id" do
|
test "dispatch/2 routes a type-5 (modal) interaction to handle_modal/2 by custom_id" do
|
||||||
itx = %{"type" => 5, "data" => %{"custom_id" => "feedback"}, "id" => "3"}
|
itx =
|
||||||
|
Interaction.from_map(%{"type" => 5, "data" => %{"custom_id" => "feedback"}, "id" => "3"})
|
||||||
|
|
||||||
assert Slash.dispatch(itx, Commands) == :ok
|
assert Slash.dispatch(itx, Commands) == :ok
|
||||||
assert_received {:modal, "feedback", ^itx}
|
assert_received {:modal, "feedback", %Dexcord.Interaction{type: :modal_submit}}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "the raw-map degraded path still routes on the integer type" do
|
||||||
|
# A malformed interaction the dispatcher could not decode into a struct is
|
||||||
|
# routed here as a raw map; it must still reach the callback by integer type.
|
||||||
|
raw = %{"type" => 2, "data" => %{"name" => "ping"}, "id" => "1"}
|
||||||
|
assert Slash.dispatch(raw, Commands) == :ok
|
||||||
|
assert_received {:handled, "ping", ^raw}
|
||||||
end
|
end
|
||||||
|
|
||||||
test "the injected catch-all logs a warning for an unhandled command name" do
|
test "the injected catch-all logs a warning for an unhandled command name" do
|
||||||
itx = %{"type" => 2, "data" => %{"name" => "unknown"}}
|
itx = Interaction.from_map(%{"type" => 2, "data" => %{"name" => "unknown"}})
|
||||||
|
|
||||||
log =
|
log =
|
||||||
capture_log(fn ->
|
capture_log(fn ->
|
||||||
|
|
@ -80,8 +93,8 @@ defmodule Dexcord.SlashTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "the injected component/modal catch-alls log at debug, not warning" do
|
test "the injected component/modal catch-alls log at debug, not warning" do
|
||||||
component = %{"type" => 3, "data" => %{"custom_id" => "nope"}}
|
component = Interaction.from_map(%{"type" => 3, "data" => %{"custom_id" => "nope"}})
|
||||||
modal = %{"type" => 5, "data" => %{"custom_id" => "nope"}}
|
modal = Interaction.from_map(%{"type" => 5, "data" => %{"custom_id" => "nope"}})
|
||||||
|
|
||||||
log =
|
log =
|
||||||
capture_log([level: :debug], fn ->
|
capture_log([level: :debug], fn ->
|
||||||
|
|
@ -95,12 +108,12 @@ defmodule Dexcord.SlashTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "a module defining only handle_interaction/2 still routes components/modals via defaults" do
|
test "a module defining only handle_interaction/2 still routes components/modals via defaults" do
|
||||||
itx2 = %{"type" => 2, "data" => %{"name" => "ping"}}
|
itx2 = Interaction.from_map(%{"type" => 2, "data" => %{"name" => "ping"}})
|
||||||
Slash.dispatch(itx2, LegacyCommands)
|
Slash.dispatch(itx2, LegacyCommands)
|
||||||
assert_received {:legacy, ^itx2}
|
assert_received {:legacy, %Dexcord.Interaction{type: :application_command}}
|
||||||
|
|
||||||
component = %{"type" => 3, "data" => %{"custom_id" => "x"}}
|
component = Interaction.from_map(%{"type" => 3, "data" => %{"custom_id" => "x"}})
|
||||||
modal = %{"type" => 5, "data" => %{"custom_id" => "y"}}
|
modal = Interaction.from_map(%{"type" => 5, "data" => %{"custom_id" => "y"}})
|
||||||
|
|
||||||
capture_log([level: :debug], fn ->
|
capture_log([level: :debug], fn ->
|
||||||
assert Slash.dispatch(component, LegacyCommands) == :ignore
|
assert Slash.dispatch(component, LegacyCommands) == :ignore
|
||||||
|
|
@ -125,13 +138,14 @@ defmodule Dexcord.SlashTest do
|
||||||
|
|
||||||
# Interaction/application ids must be numeric snowflakes: the typed endpoint
|
# Interaction/application ids must be numeric snowflakes: the typed endpoint
|
||||||
# surface casts `:interaction_id` / `:application_id` path params via
|
# surface casts `:interaction_id` / `:application_id` path params via
|
||||||
# `Dexcord.Snowflake.cast/1`. The interaction token is an opaque string.
|
# `Dexcord.Snowflake.cast/1`. The interaction token is an opaque string. The
|
||||||
@itx %{
|
# response helpers take a decoded `%Dexcord.Interaction{}`, so build one.
|
||||||
|
@itx Interaction.from_map(%{
|
||||||
"id" => "100",
|
"id" => "100",
|
||||||
"token" => "int-token",
|
"token" => "int-token",
|
||||||
"application_id" => "200",
|
"application_id" => "200",
|
||||||
"data" => %{"name" => "ping"}
|
"data" => %{"name" => "ping"}
|
||||||
}
|
})
|
||||||
|
|
||||||
test "respond/2 with a string sends a type-4 content response to the callback route" do
|
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))
|
FakeRest.stub(:post, "/interactions/100/int-token/callback", FakeRest.resp(204))
|
||||||
|
|
@ -158,6 +172,19 @@ defmodule Dexcord.SlashTest do
|
||||||
assert body["data"]["embeds"] == [%{"title" => "x"}]
|
assert body["data"]["embeds"] == [%{"title" => "x"}]
|
||||||
end
|
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
|
test "respond/2 accepts string-keyed data maps too" do
|
||||||
FakeRest.stub(:post, "/interactions/100/int-token/callback", FakeRest.resp(204))
|
FakeRest.stub(:post, "/interactions/100/int-token/callback", FakeRest.resp(204))
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue