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