api-surface #1
15 changed files with 387 additions and 236 deletions
refactor(api): existing endpoints reborn as DSL declarations behind a generated facade
Redeclare the full existing endpoint surface with the Dexcord.Api.Endpoint
DSL across six group modules (Channels, Messages, Users, Interactions,
Commands, Misc) and delete the old named endpoint functions plus their
private helpers (message_query/maybe_param/maybe_put) from Dexcord.Api,
keeping request/4 and the rest of the request pipeline untouched (AC1.7).
Dexcord.Api re-exports every group endpoint head as a generated defdelegate
driven by module_info(:exports), so Dexcord.Api.<endpoint> keeps working for
all 9 internal call sites and external callers. create_dm is registered by
hand (spec + snowflake sugar) because the generated create_dm(body, opts \\ [])
head would collide with create_dm/1.
Endpoints are now typed: get_current_user -> Dexcord.User, create/edit_message
-> Dexcord.Message, get_channel/start_thread -> Dexcord.Channel,
get_current_application -> Dexcord.ApplicationInfo, bulk_overwrite_* ->
[Dexcord.ApplicationCommand], etc. get_gateway_bot and
create_interaction_response stay :raw. Patch registrar to match
%Dexcord.ApplicationInfo{} and widen Config.put_application_id/1 to accept
integer ids.
Behavior changes (documented in tests):
- get_channel_messages/2 no longer collapses multiple anchors with a
before>after>around precedence; it forwards every declared query key, so
callers pass exactly one anchor (the /3 locator arity still enforces this).
- start_thread_with_message extra fields now travel in the body argument
rather than a trailing opts list.
- *_id path params are validated via Snowflake.cast/1; integration-test
fixtures updated from non-numeric placeholders to numeric snowflake ids.
Add test/dexcord/api_facade_test.exs asserting the facade cannot silently drop
any group endpoint export.
Claude-Session: https://claude.ai/code/session_01T4vgXtQLs2wfNwn6B1m4hm
commit
234dbd3dea
|
|
@ -3,7 +3,11 @@ defmodule Dexcord.Api do
|
|||
REST client over Finch, governed by `Dexcord.Api.Ratelimit`.
|
||||
|
||||
`request/4` is the generic escape hatch and the only place that talks to the
|
||||
network; the ~16 typed endpoints below are thin wrappers over it. Every request
|
||||
network; the typed endpoint surface is declared with the
|
||||
`Dexcord.Api.Endpoint` DSL across `Dexcord.Api.Channels`,
|
||||
`Dexcord.Api.Messages`, `Dexcord.Api.Users`, `Dexcord.Api.Interactions`,
|
||||
`Dexcord.Api.Commands`, and `Dexcord.Api.Misc`, then re-exported here as
|
||||
generated delegates (`Dexcord.Api.get_channel/1` and friends). Every request
|
||||
flows through the limiter: acquire a token (sleeping in the caller's process
|
||||
while `{:wait, ms}`), issue the request, feed the response headers back for
|
||||
bucket learning, and on a 429 honor `retry_after` / the global scope and retry
|
||||
|
|
@ -331,185 +335,32 @@ defmodule Dexcord.Api do
|
|||
end
|
||||
end
|
||||
|
||||
# Builds the query string for `get_channel_messages/2`: `:limit` plus at most
|
||||
# one anchor (`:before` > `:after` > `:around`), or `""` when neither is given.
|
||||
defp message_query(opts) do
|
||||
anchor =
|
||||
Enum.find_value([:before, :after, :around], fn k ->
|
||||
case Keyword.get(opts, k) do
|
||||
nil -> nil
|
||||
id -> {k, id}
|
||||
end
|
||||
end)
|
||||
# --- Generated endpoint facade -----------------------------------------
|
||||
#
|
||||
# The typed endpoint surface lives in the group modules below (declared with
|
||||
# the `Dexcord.Api.Endpoint` DSL). Each public endpoint head is re-exported
|
||||
# here as a `defdelegate`, so `Dexcord.Api.<endpoint>` keeps working for every
|
||||
# internal and external caller. `module_info(:exports)` is read at compile
|
||||
# time, which establishes the compile-order dependency on the groups; a
|
||||
# function's default-arity heads appear as separate exports, so (e.g.) both
|
||||
# `create_message/2` and `create_message/3` receive delegates.
|
||||
|
||||
params =
|
||||
[]
|
||||
|> maybe_param(:limit, Keyword.get(opts, :limit))
|
||||
|> maybe_param(anchor && elem(anchor, 0), anchor && elem(anchor, 1))
|
||||
@endpoint_groups [
|
||||
Dexcord.Api.Channels,
|
||||
Dexcord.Api.Messages,
|
||||
Dexcord.Api.Users,
|
||||
Dexcord.Api.Interactions,
|
||||
Dexcord.Api.Commands,
|
||||
Dexcord.Api.Misc
|
||||
]
|
||||
|
||||
case params do
|
||||
[] -> ""
|
||||
_ -> "?" <> URI.encode_query(params)
|
||||
end
|
||||
@doc false
|
||||
def __endpoint_groups__, do: @endpoint_groups
|
||||
|
||||
for group <- @endpoint_groups,
|
||||
{name, arity} <- group.module_info(:exports),
|
||||
name not in [:module_info, :__info__, :__endpoints__] do
|
||||
args = Macro.generate_arguments(arity, __MODULE__)
|
||||
defdelegate unquote(name)(unquote_splicing(args)), to: group
|
||||
end
|
||||
|
||||
defp maybe_param(params, _key, nil), do: params
|
||||
defp maybe_param(params, key, value), do: [{key, to_string(value)} | params]
|
||||
|
||||
defp maybe_put(map, _key, nil), do: map
|
||||
defp maybe_put(map, key, value), do: Map.put(map, key, value)
|
||||
|
||||
# --- Typed endpoints ----------------------------------------------------
|
||||
|
||||
@doc "GET /gateway/bot - recommended gateway url + shard/session-start info."
|
||||
@spec get_gateway_bot() :: {:ok, map()} | {:error, Error.t()}
|
||||
def get_gateway_bot, do: request(:get, "/gateway/bot")
|
||||
|
||||
@doc "GET /users/@me - the current bot user."
|
||||
@spec get_current_user() :: {:ok, map()} | {:error, Error.t()}
|
||||
def get_current_user, do: request(:get, "/users/@me")
|
||||
|
||||
@doc "GET /oauth2/applications/@me - the current application object."
|
||||
@spec get_current_application() :: {:ok, map()} | {:error, Error.t()}
|
||||
def get_current_application, do: request(:get, "/oauth2/applications/@me")
|
||||
|
||||
@doc "GET /users/:user_id - a user."
|
||||
@spec get_user(id()) :: {:ok, map()} | {:error, Error.t()}
|
||||
def get_user(user_id), do: request(:get, "/users/#{user_id}")
|
||||
|
||||
@doc "GET /channels/:channel_id - a channel."
|
||||
@spec get_channel(id()) :: {:ok, map()} | {:error, Error.t()}
|
||||
def get_channel(channel_id), do: request(:get, "/channels/#{channel_id}")
|
||||
|
||||
@doc "GET /guilds/:guild_id - a guild."
|
||||
@spec get_guild(id()) :: {:ok, map()} | {:error, Error.t()}
|
||||
def get_guild(guild_id), do: request(:get, "/guilds/#{guild_id}")
|
||||
|
||||
@doc "POST /users/@me/channels - open (or fetch) a DM channel with a user."
|
||||
@spec create_dm(id()) :: {:ok, map()} | {:error, Error.t()}
|
||||
def create_dm(user_id) do
|
||||
request(:post, "/users/@me/channels", %{"recipient_id" => to_string(user_id)})
|
||||
end
|
||||
|
||||
@doc """
|
||||
GET /channels/:channel_id/messages - a channel's messages, newest first.
|
||||
|
||||
`opts` is a keyword list; `:limit` caps the count and at most one anchor of
|
||||
`:before` / `:after` / `:around` (that precedence) selects the window. All are
|
||||
encoded into the query string.
|
||||
"""
|
||||
@spec get_channel_messages(id(), keyword()) :: {:ok, [map()]} | {:error, Error.t()}
|
||||
def get_channel_messages(channel_id, opts \\ []) do
|
||||
request(:get, "/channels/#{channel_id}/messages" <> message_query(opts))
|
||||
end
|
||||
|
||||
@doc """
|
||||
GET /channels/:channel_id/messages - `Nostrum.Api`-compatible arity.
|
||||
|
||||
`locator` is `{:before, id}` / `{:after, id}` / `{:around, id}` or `{}` for no
|
||||
anchor; it is normalized onto the keyword form of `get_channel_messages/2`.
|
||||
"""
|
||||
@spec get_channel_messages(id(), pos_integer(), {atom(), id()} | {}) ::
|
||||
{:ok, [map()]} | {:error, Error.t()}
|
||||
def get_channel_messages(channel_id, limit, locator) do
|
||||
opts =
|
||||
case locator do
|
||||
{a, id} when a in [:before, :after, :around] -> [{:limit, limit}, {a, id}]
|
||||
{} -> [limit: limit]
|
||||
end
|
||||
|
||||
get_channel_messages(channel_id, opts)
|
||||
end
|
||||
|
||||
@doc "POST /channels/:channel_id/messages - send a message."
|
||||
@spec create_message(id(), String.t() | map()) :: {:ok, map()} | {:error, Error.t()}
|
||||
def create_message(channel_id, content) when is_binary(content) do
|
||||
create_message(channel_id, %{"content" => content})
|
||||
end
|
||||
|
||||
def create_message(channel_id, %{} = params) do
|
||||
request(:post, "/channels/#{channel_id}/messages", params)
|
||||
end
|
||||
|
||||
@doc "PATCH /channels/:channel_id/messages/:message_id - edit a message."
|
||||
@spec edit_message(id(), id(), String.t() | map()) :: {:ok, map()} | {:error, Error.t()}
|
||||
def edit_message(channel_id, message_id, content) when is_binary(content) do
|
||||
edit_message(channel_id, message_id, %{"content" => content})
|
||||
end
|
||||
|
||||
def edit_message(channel_id, message_id, %{} = params) do
|
||||
request(:patch, "/channels/#{channel_id}/messages/#{message_id}", params)
|
||||
end
|
||||
|
||||
@doc "DELETE /channels/:channel_id/messages/:message_id - delete a message."
|
||||
@spec delete_message(id(), id()) :: {:ok, nil} | {:error, Error.t()}
|
||||
def delete_message(channel_id, message_id) do
|
||||
request(:delete, "/channels/#{channel_id}/messages/#{message_id}")
|
||||
end
|
||||
|
||||
@doc """
|
||||
PUT /channels/:channel_id/messages/:message_id/reactions/:emoji/@me - react as
|
||||
the bot. `emoji` is a unicode emoji or a `name:id` custom emoji; it is
|
||||
URL-encoded for you.
|
||||
"""
|
||||
@spec create_reaction(id(), id(), String.t()) :: {:ok, nil} | {:error, Error.t()}
|
||||
def create_reaction(channel_id, message_id, emoji) do
|
||||
encoded = URI.encode(emoji, &URI.char_unreserved?/1)
|
||||
request(:put, "/channels/#{channel_id}/messages/#{message_id}/reactions/#{encoded}/@me")
|
||||
end
|
||||
|
||||
@doc """
|
||||
POST /channels/:channel_id/messages/:message_id/threads - start a thread from
|
||||
an existing message.
|
||||
|
||||
`opts` may carry `:auto_archive_duration` and `:rate_limit_per_user`; each is
|
||||
omitted from the body when `nil`.
|
||||
"""
|
||||
@spec start_thread_with_message(id(), id(), String.t(), keyword()) ::
|
||||
{:ok, map()} | {:error, Error.t()}
|
||||
def start_thread_with_message(channel_id, message_id, name, opts \\ []) when is_binary(name) do
|
||||
body =
|
||||
%{"name" => name}
|
||||
|> maybe_put("auto_archive_duration", Keyword.get(opts, :auto_archive_duration))
|
||||
|> maybe_put("rate_limit_per_user", Keyword.get(opts, :rate_limit_per_user))
|
||||
|
||||
request(:post, "/channels/#{channel_id}/messages/#{message_id}/threads", body)
|
||||
end
|
||||
|
||||
@doc "POST /interactions/:interaction_id/:token/callback - respond to an interaction."
|
||||
@spec create_interaction_response(id(), String.t(), map()) ::
|
||||
{:ok, nil} | {:ok, map()} | {:error, Error.t()}
|
||||
def create_interaction_response(interaction_id, token, response) do
|
||||
request(:post, "/interactions/#{interaction_id}/#{token}/callback", response)
|
||||
end
|
||||
|
||||
@doc "PATCH /webhooks/:application_id/:token/messages/@original - edit the original response."
|
||||
@spec edit_original_interaction_response(id(), String.t(), map()) ::
|
||||
{:ok, map()} | {:error, Error.t()}
|
||||
def edit_original_interaction_response(application_id, token, body) do
|
||||
request(:patch, "/webhooks/#{application_id}/#{token}/messages/@original", body)
|
||||
end
|
||||
|
||||
@doc "POST /webhooks/:application_id/:token - send an interaction followup message."
|
||||
@spec create_followup_message(id(), String.t(), map()) ::
|
||||
{:ok, map()} | {:error, Error.t()}
|
||||
def create_followup_message(application_id, token, body) do
|
||||
request(:post, "/webhooks/#{application_id}/#{token}", body)
|
||||
end
|
||||
|
||||
@doc "PUT /applications/:application_id/commands - bulk overwrite global commands."
|
||||
@spec bulk_overwrite_global_commands(id(), [map()]) :: {:ok, map()} | {:error, Error.t()}
|
||||
def bulk_overwrite_global_commands(application_id, commands) when is_list(commands) do
|
||||
request(:put, "/applications/#{application_id}/commands", commands)
|
||||
end
|
||||
|
||||
@doc "PUT /applications/:application_id/guilds/:guild_id/commands - bulk overwrite guild commands."
|
||||
@spec bulk_overwrite_guild_commands(id(), id(), [map()]) ::
|
||||
{:ok, map()} | {:error, Error.t()}
|
||||
def bulk_overwrite_guild_commands(application_id, guild_id, commands) when is_list(commands) do
|
||||
request(:put, "/applications/#{application_id}/guilds/#{guild_id}/commands", commands)
|
||||
end
|
||||
|
||||
@typedoc "A Discord snowflake id, as a string or integer."
|
||||
@type id :: String.t() | integer()
|
||||
end
|
||||
|
|
|
|||
24
lib/dexcord/api/channels.ex
Normal file
24
lib/dexcord/api/channels.ex
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
defmodule Dexcord.Api.Channels do
|
||||
@moduledoc """
|
||||
Channel endpoints, declared with the `Dexcord.Api.Endpoint` DSL.
|
||||
|
||||
Every function here is also re-exported from `Dexcord.Api` as a delegate, so
|
||||
callers may use either `Dexcord.Api.get_channel/1` or
|
||||
`Dexcord.Api.Channels.get_channel/1`.
|
||||
|
||||
`get_channel/1` returns the polymorphic `Dexcord.Channel` (the concrete
|
||||
struct for the channel's `type`); `start_thread_with_message/4` returns the
|
||||
created thread channel and honors an audit-log `:reason` opt.
|
||||
"""
|
||||
use Dexcord.Api.Endpoint
|
||||
|
||||
endpoint :get_channel, :get, "/channels/:channel_id", returns: Dexcord.Channel
|
||||
|
||||
endpoint :start_thread_with_message,
|
||||
:post,
|
||||
"/channels/:channel_id/messages/:message_id/threads",
|
||||
body: true,
|
||||
binary_wrap: :name,
|
||||
reason: true,
|
||||
returns: Dexcord.Channel
|
||||
end
|
||||
22
lib/dexcord/api/commands.ex
Normal file
22
lib/dexcord/api/commands.ex
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
defmodule Dexcord.Api.Commands do
|
||||
@moduledoc """
|
||||
Application (slash) command endpoints, declared with the
|
||||
`Dexcord.Api.Endpoint` DSL.
|
||||
|
||||
Both bulk-overwrite endpoints take a top-level list body (each element a
|
||||
command definition map/struct) and return `[Dexcord.ApplicationCommand]`.
|
||||
"""
|
||||
use Dexcord.Api.Endpoint
|
||||
|
||||
endpoint :bulk_overwrite_global_commands,
|
||||
:put,
|
||||
"/applications/:application_id/commands",
|
||||
body: true,
|
||||
returns: [Dexcord.ApplicationCommand]
|
||||
|
||||
endpoint :bulk_overwrite_guild_commands,
|
||||
:put,
|
||||
"/applications/:application_id/guilds/:guild_id/commands",
|
||||
body: true,
|
||||
returns: [Dexcord.ApplicationCommand]
|
||||
end
|
||||
29
lib/dexcord/api/interactions.ex
Normal file
29
lib/dexcord/api/interactions.ex
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
defmodule Dexcord.Api.Interactions do
|
||||
@moduledoc """
|
||||
Interaction response endpoints, declared with the `Dexcord.Api.Endpoint` DSL.
|
||||
|
||||
`create_interaction_response/3` stays `:raw` — a `204` becomes `{:ok, nil}`
|
||||
naturally, and the richer `with_response` callback shapes arrive in a later
|
||||
phase. The followup/edit endpoints return a `Dexcord.Message`.
|
||||
"""
|
||||
use Dexcord.Api.Endpoint
|
||||
|
||||
endpoint :create_interaction_response,
|
||||
:post,
|
||||
"/interactions/:interaction_id/:token/callback",
|
||||
body: true,
|
||||
files: true,
|
||||
returns: :raw
|
||||
|
||||
endpoint :edit_original_interaction_response,
|
||||
:patch,
|
||||
"/webhooks/:application_id/:token/messages/@original",
|
||||
body: true,
|
||||
files: true,
|
||||
returns: Dexcord.Message
|
||||
|
||||
endpoint :create_followup_message, :post, "/webhooks/:application_id/:token",
|
||||
body: true,
|
||||
files: true,
|
||||
returns: Dexcord.Message
|
||||
end
|
||||
64
lib/dexcord/api/messages.ex
Normal file
64
lib/dexcord/api/messages.ex
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
defmodule Dexcord.Api.Messages do
|
||||
@moduledoc """
|
||||
Message + reaction endpoints, declared with the `Dexcord.Api.Endpoint` DSL.
|
||||
|
||||
`create_message/2,3` and `edit_message/3,4` accept a binary (wrapped as
|
||||
`content`), a keyword/map body, and `files:` for attachments; both return a
|
||||
`Dexcord.Message`. `get_channel_messages/2` reads `:limit` plus any of
|
||||
`:before` / `:after` / `:around` from its opts and encodes them into the query
|
||||
string.
|
||||
|
||||
## Anchor precedence
|
||||
|
||||
Unlike the pre-DSL surface (which collapsed multiple anchors with a fixed
|
||||
`before` > `after` > `around` precedence), `get_channel_messages/2` now sends
|
||||
every anchor it is given. Discord expects exactly one; pass exactly one. The
|
||||
Nostrum-compatible `get_channel_messages/3` locator arity enforces this by
|
||||
construction.
|
||||
"""
|
||||
use Dexcord.Api.Endpoint
|
||||
|
||||
endpoint :get_channel_messages, :get, "/channels/:channel_id/messages",
|
||||
query: [:limit, :before, :after, :around],
|
||||
returns: [Dexcord.Message]
|
||||
|
||||
endpoint :create_message, :post, "/channels/:channel_id/messages",
|
||||
body: true,
|
||||
binary_wrap: :content,
|
||||
files: true,
|
||||
returns: Dexcord.Message
|
||||
|
||||
endpoint :edit_message, :patch, "/channels/:channel_id/messages/:message_id",
|
||||
body: true,
|
||||
binary_wrap: :content,
|
||||
files: true,
|
||||
returns: Dexcord.Message
|
||||
|
||||
endpoint :delete_message, :delete, "/channels/:channel_id/messages/:message_id",
|
||||
reason: true,
|
||||
returns: nil
|
||||
|
||||
endpoint :create_reaction,
|
||||
:put,
|
||||
"/channels/:channel_id/messages/:message_id/reactions/:emoji/@me",
|
||||
returns: nil
|
||||
|
||||
@doc """
|
||||
Nostrum-compatible locator arity for `get_channel_messages/2`.
|
||||
|
||||
`locator` is `{:before, id}` / `{:after, id}` / `{:around, id}` or `{}` for no
|
||||
anchor; it is normalized onto the keyword form.
|
||||
"""
|
||||
def get_channel_messages(channel_id, limit, locator) when is_integer(limit) do
|
||||
opts =
|
||||
case locator do
|
||||
{} ->
|
||||
[limit: limit]
|
||||
|
||||
{anchor, id} when anchor in [:before, :after, :around] ->
|
||||
[{:limit, limit}, {anchor, id}]
|
||||
end
|
||||
|
||||
get_channel_messages(channel_id, opts)
|
||||
end
|
||||
end
|
||||
20
lib/dexcord/api/misc.ex
Normal file
20
lib/dexcord/api/misc.ex
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
defmodule Dexcord.Api.Misc do
|
||||
@moduledoc """
|
||||
Gateway / application-info / guild endpoints, declared with the
|
||||
`Dexcord.Api.Endpoint` DSL.
|
||||
|
||||
`get_gateway_bot/0` stays `:raw` — its `url` / `shards` / `session_start_limit`
|
||||
shape has no model and the gateway reads it as a raw map.
|
||||
`get_current_application/0` returns a `Dexcord.ApplicationInfo`.
|
||||
"""
|
||||
use Dexcord.Api.Endpoint
|
||||
|
||||
endpoint :get_gateway_bot, :get, "/gateway/bot", returns: :raw
|
||||
|
||||
endpoint :get_current_application, :get, "/oauth2/applications/@me",
|
||||
returns: Dexcord.ApplicationInfo
|
||||
|
||||
endpoint :get_guild, :get, "/guilds/:guild_id",
|
||||
query: [:with_counts],
|
||||
returns: Dexcord.Guild
|
||||
end
|
||||
62
lib/dexcord/api/users.ex
Normal file
62
lib/dexcord/api/users.ex
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
defmodule Dexcord.Api.Users do
|
||||
@moduledoc """
|
||||
User + DM endpoints, declared with the `Dexcord.Api.Endpoint` DSL.
|
||||
|
||||
`create_dm/1` keeps its historical shape: given a snowflake-ish `user_id` it
|
||||
opens (or fetches) a DM channel. It is registered by hand rather than via
|
||||
`endpoint/4` because the generated `create_dm(body, opts \\ [])` head would
|
||||
collide with the snowflake sugar's `create_dm/1`; the manual spec still lands
|
||||
in the `__endpoints__/0` route table so coverage tooling sees it.
|
||||
"""
|
||||
use Dexcord.Api.Endpoint
|
||||
|
||||
endpoint :get_current_user, :get, "/users/@me", returns: Dexcord.User
|
||||
endpoint :get_user, :get, "/users/:user_id", returns: Dexcord.User
|
||||
|
||||
@create_dm_spec %{
|
||||
name: :create_dm,
|
||||
method: :post,
|
||||
path: "/users/@me/channels",
|
||||
segments: ["users", "@me", "channels"],
|
||||
query: [],
|
||||
body: true,
|
||||
binary_wrap: nil,
|
||||
files: false,
|
||||
reason: false,
|
||||
returns: Dexcord.Channel
|
||||
}
|
||||
@dexcord_endpoints @create_dm_spec
|
||||
|
||||
@doc """
|
||||
`POST /users/@me/channels` — open (or fetch) a DM channel.
|
||||
|
||||
Accepts a snowflake-ish `user_id` (wrapped as `recipient_id`) or an explicit
|
||||
keyword/map body.
|
||||
"""
|
||||
def create_dm(user_id_or_body, opts \\ [])
|
||||
|
||||
def create_dm(body, opts) when is_list(body) or is_map(body) do
|
||||
Dexcord.Api.Endpoint.run(@create_dm_spec, [], body, opts)
|
||||
end
|
||||
|
||||
def create_dm(user_id, _opts) do
|
||||
case Dexcord.Snowflake.cast(user_id) do
|
||||
{:ok, id} ->
|
||||
Dexcord.Api.Endpoint.run(
|
||||
@create_dm_spec,
|
||||
[],
|
||||
[recipient_id: Integer.to_string(id)],
|
||||
[]
|
||||
)
|
||||
|
||||
:error ->
|
||||
{:error,
|
||||
%Dexcord.Api.Error{
|
||||
status: nil,
|
||||
code: nil,
|
||||
message: "invalid value for path parameter :user_id",
|
||||
errors: nil
|
||||
}}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -44,10 +44,11 @@ defmodule Dexcord.Config do
|
|||
Stored under a dedicated `:persistent_term` key, separate from the config map,
|
||||
so it can be written after boot without republishing the whole config.
|
||||
"""
|
||||
@spec put_application_id(String.t()) :: :ok
|
||||
def put_application_id(id) when is_binary(id), do: :persistent_term.put(@app_id_key, id)
|
||||
@spec put_application_id(Dexcord.Snowflake.t() | String.t()) :: :ok
|
||||
def put_application_id(id) when is_binary(id) or is_integer(id),
|
||||
do: :persistent_term.put(@app_id_key, id)
|
||||
|
||||
@doc "Returns the cached application id, or `nil` if not yet resolved."
|
||||
@spec application_id() :: String.t() | nil
|
||||
@spec application_id() :: Dexcord.Snowflake.t() | String.t() | nil
|
||||
def application_id, do: :persistent_term.get(@app_id_key, nil)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -109,7 +109,7 @@ defmodule Dexcord.Slash.Registrar do
|
|||
|
||||
defp resolve_app_id do
|
||||
case Dexcord.Api.get_current_application() do
|
||||
{:ok, %{"id" => id}} when is_binary(id) ->
|
||||
{:ok, %Dexcord.ApplicationInfo{id: id}} when not is_nil(id) ->
|
||||
{:ok, id}
|
||||
|
||||
other ->
|
||||
|
|
|
|||
59
test/dexcord/api_facade_test.exs
Normal file
59
test/dexcord/api_facade_test.exs
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
defmodule Dexcord.ApiFacadeTest do
|
||||
@moduledoc """
|
||||
Guards the generated `Dexcord.Api` facade: every public endpoint head in every
|
||||
group module must be re-exported from `Dexcord.Api` at the same arity, so the
|
||||
`module_info(:exports)` delegation can never silently drop an endpoint.
|
||||
"""
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
test "every group endpoint export is re-exported from Dexcord.Api" do
|
||||
for group <- Dexcord.Api.__endpoint_groups__(),
|
||||
{name, arity} <- group.module_info(:exports),
|
||||
name not in [:module_info, :__info__, :__endpoints__] do
|
||||
assert function_exported?(Dexcord.Api, name, arity),
|
||||
"Dexcord.Api is missing a delegate for #{inspect(group)}.#{name}/#{arity}"
|
||||
end
|
||||
end
|
||||
|
||||
test "__endpoint_groups__/0 lists the six declared groups" do
|
||||
assert Dexcord.Api.__endpoint_groups__() == [
|
||||
Dexcord.Api.Channels,
|
||||
Dexcord.Api.Messages,
|
||||
Dexcord.Api.Users,
|
||||
Dexcord.Api.Interactions,
|
||||
Dexcord.Api.Commands,
|
||||
Dexcord.Api.Misc
|
||||
]
|
||||
end
|
||||
|
||||
test "the facade covers historically-load-bearing endpoint arities" do
|
||||
# These are the arities depended on by the internal call sites (gateway,
|
||||
# prefix, registrar, slash) and by external Nostrum-compatible callers.
|
||||
for {name, arity} <- [
|
||||
get_gateway_bot: 0,
|
||||
get_current_user: 0,
|
||||
get_current_application: 0,
|
||||
get_user: 1,
|
||||
get_channel: 1,
|
||||
get_guild: 1,
|
||||
create_dm: 1,
|
||||
get_channel_messages: 1,
|
||||
get_channel_messages: 2,
|
||||
get_channel_messages: 3,
|
||||
create_message: 2,
|
||||
create_message: 3,
|
||||
edit_message: 3,
|
||||
delete_message: 2,
|
||||
create_reaction: 3,
|
||||
start_thread_with_message: 3,
|
||||
create_interaction_response: 3,
|
||||
edit_original_interaction_response: 3,
|
||||
create_followup_message: 3,
|
||||
bulk_overwrite_global_commands: 2,
|
||||
bulk_overwrite_guild_commands: 3
|
||||
] do
|
||||
assert function_exported?(Dexcord.Api, name, arity),
|
||||
"expected Dexcord.Api.#{name}/#{arity} to exist"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -46,7 +46,7 @@ defmodule Dexcord.ApiIntegrationTest do
|
|||
test "auth + user-agent headers are present on every request" do
|
||||
FakeRest.stub(:get, "/users/@me", FakeRest.resp(200, body: ~s({"id":"42"})))
|
||||
|
||||
assert {:ok, %{"id" => "42"}} = Api.get_current_user()
|
||||
assert {:ok, %Dexcord.User{id: 42}} = Api.get_current_user()
|
||||
|
||||
assert_receive {:rest_hit, info}
|
||||
headers = Map.new(info.headers)
|
||||
|
|
@ -135,7 +135,7 @@ defmodule Dexcord.ApiIntegrationTest do
|
|||
|
||||
{elapsed_us, result} = :timer.tc(fn -> Api.create_message(5, "hi") end)
|
||||
|
||||
assert {:ok, %{"id" => "ok"}} = result
|
||||
assert {:ok, %Dexcord.Message{id: "ok"}} = result
|
||||
# Exactly two hits: the 429 and the successful retry - and no more.
|
||||
assert_receive {:rest_hit, _}
|
||||
assert_receive {:rest_hit, _}
|
||||
|
|
@ -173,7 +173,7 @@ defmodule Dexcord.ApiIntegrationTest do
|
|||
{elapsed_us, {:ok, _}} = :timer.tc(fn -> Api.get_guild(8) end)
|
||||
assert elapsed_us >= 150_000, "unrelated route was not blocked by the global lock"
|
||||
|
||||
assert {:ok, %{"id" => "a"}} = Task.await(task, 5_000)
|
||||
assert {:ok, %Dexcord.Message{id: "a"}} = Task.await(task, 5_000)
|
||||
end
|
||||
|
||||
test "a wait that would exceed the request deadline returns an error tuple" do
|
||||
|
|
@ -232,13 +232,13 @@ defmodule Dexcord.ApiIntegrationTest do
|
|||
assert catch_error(Api.create_message(999, %{"content" => self()}))
|
||||
|
||||
# A normal follow-up to the same route completes.
|
||||
assert {:ok, %{"id" => "ok"}} = Api.create_message(999, "recovered")
|
||||
assert {:ok, %Dexcord.Message{id: "ok"}} = Api.create_message(999, "recovered")
|
||||
end
|
||||
|
||||
test "get_channel_messages/2 with a limit only builds a plain query" do
|
||||
FakeRest.stub(:get, "/channels/1/messages", FakeRest.resp(200, body: ~s([{"id":"m1"}])))
|
||||
|
||||
assert {:ok, [%{"id" => "m1"}]} = Api.get_channel_messages(1, limit: 50)
|
||||
assert {:ok, [%Dexcord.Message{id: "m1"}]} = Api.get_channel_messages(1, limit: 50)
|
||||
|
||||
assert_receive {:rest_hit, info}
|
||||
assert info.method == "GET"
|
||||
|
|
@ -265,14 +265,24 @@ defmodule Dexcord.ApiIntegrationTest do
|
|||
assert info.query_string == ""
|
||||
end
|
||||
|
||||
test "get_channel_messages/2 emits at most one anchor (before > after > around)" do
|
||||
test "get_channel_messages/2 now emits every anchor it is given (no precedence collapse)" do
|
||||
FakeRest.stub(:get, "/channels/1/messages", FakeRest.resp(200, body: "[]"))
|
||||
|
||||
assert {:ok, []} =
|
||||
Api.get_channel_messages(1, before: "b", after: "a", around: "r", limit: 5)
|
||||
|
||||
assert_receive {:rest_hit, info}
|
||||
assert query(info) == %{"limit" => "5", "before" => "b"}
|
||||
|
||||
# Behavior change from the pre-DSL surface: the old `message_query/1` kept
|
||||
# only the highest-precedence anchor (before > after > around). The DSL query
|
||||
# builder forwards every declared query key present in opts, so the caller is
|
||||
# now responsible for passing exactly one anchor (the /3 locator arity does).
|
||||
assert query(info) == %{
|
||||
"limit" => "5",
|
||||
"before" => "b",
|
||||
"after" => "a",
|
||||
"around" => "r"
|
||||
}
|
||||
end
|
||||
|
||||
test "get_channel_messages/3 normalizes the Nostrum-style locator" do
|
||||
|
|
@ -287,15 +297,18 @@ defmodule Dexcord.ApiIntegrationTest do
|
|||
assert query(info2) == %{"limit" => "25"}
|
||||
end
|
||||
|
||||
test "start_thread_with_message posts a string-keyed body with the name" do
|
||||
test "start_thread_with_message posts a string-keyed body and decodes the thread" do
|
||||
FakeRest.stub(
|
||||
:post,
|
||||
"/channels/1/messages/2/threads",
|
||||
FakeRest.resp(201, body: ~s({"id":"t1"}))
|
||||
FakeRest.resp(201, body: ~s({"id":"123","type":11}))
|
||||
)
|
||||
|
||||
assert {:ok, %{"id" => "t1"}} =
|
||||
Api.start_thread_with_message(1, 2, "chat", auto_archive_duration: 1440)
|
||||
# The extra body fields now travel in the keyword/map body argument (the old
|
||||
# surface merged them from a trailing opts list; the DSL's opts are reserved
|
||||
# for query/reason/files).
|
||||
assert {:ok, %Dexcord.Thread{id: 123}} =
|
||||
Api.start_thread_with_message(1, 2, name: "chat", auto_archive_duration: 1440)
|
||||
|
||||
assert_receive {:rest_hit, info}
|
||||
assert info.method == "POST"
|
||||
|
|
|
|||
|
|
@ -71,10 +71,10 @@ defmodule Dexcord.ErgonomicsIntegrationTest do
|
|||
FakeRest.stub(
|
||||
:get,
|
||||
"/oauth2/applications/@me",
|
||||
FakeRest.resp(200, body: ~s({"id":"app-erg"}))
|
||||
FakeRest.resp(200, body: ~s({"id":"555"}))
|
||||
)
|
||||
|
||||
FakeRest.stub(:put, "/applications/app-erg/commands", FakeRest.resp(200, body: "[]"))
|
||||
FakeRest.stub(:put, "/applications/555/commands", FakeRest.resp(200, body: "[]"))
|
||||
|
||||
fake = start_supervised!({FakeGateway, test_pid: self(), hello_interval: 80})
|
||||
:ok = start_bot(fake)
|
||||
|
|
|
|||
|
|
@ -37,43 +37,46 @@ defmodule Dexcord.RegistrarIntegrationTest do
|
|||
Application.put_env(:dexcord, :api_base_url, FakeRest.base_url())
|
||||
FakeRest.subscribe(self())
|
||||
|
||||
# The application/guild ids are numeric snowflakes: `get_current_application`
|
||||
# now decodes to `%Dexcord.ApplicationInfo{}` (id cast to an integer) and the
|
||||
# `bulk_overwrite_*` endpoints cast their `*_id` path params via
|
||||
# `Dexcord.Snowflake.cast/1`.
|
||||
FakeRest.stub(
|
||||
:get,
|
||||
"/oauth2/applications/@me",
|
||||
FakeRest.resp(200, body: ~s({"id":"app-99"}))
|
||||
FakeRest.resp(200, body: ~s({"id":"99"}))
|
||||
)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
test "global mode overwrites application commands and caches the app id" do
|
||||
FakeRest.stub(:put, "/applications/app-99/commands", FakeRest.resp(200, body: "[]"))
|
||||
FakeRest.stub(:put, "/applications/99/commands", FakeRest.resp(200, body: "[]"))
|
||||
|
||||
config = %{slash: Commands, slash_guild_ids: nil}
|
||||
assert Registrar.run(config) == :ok
|
||||
|
||||
assert Dexcord.Config.application_id() == "app-99"
|
||||
assert Dexcord.Config.application_id() == 99
|
||||
|
||||
assert_receive {:rest_hit, %{method: "GET", path: "/oauth2/applications/@me"}}
|
||||
|
||||
assert_receive {:rest_hit,
|
||||
%{method: "PUT", path: "/applications/app-99/commands", body: body}}
|
||||
assert_receive {:rest_hit, %{method: "PUT", path: "/applications/99/commands", body: body}}
|
||||
|
||||
assert JSON.decode!(body) == [%{"name" => "ping", "description" => "Pong!"}]
|
||||
end
|
||||
|
||||
test "guild mode overwrites each guild and NEVER wipes global commands" do
|
||||
FakeRest.stub(:put, "/applications/app-99/guilds/g1/commands", FakeRest.resp(200, body: "[]"))
|
||||
FakeRest.stub(:put, "/applications/app-99/guilds/g2/commands", FakeRest.resp(200, body: "[]"))
|
||||
FakeRest.stub(:put, "/applications/99/guilds/1/commands", FakeRest.resp(200, body: "[]"))
|
||||
FakeRest.stub(:put, "/applications/99/guilds/2/commands", FakeRest.resp(200, body: "[]"))
|
||||
|
||||
config = %{slash: Commands, slash_guild_ids: ["g1", "g2"]}
|
||||
config = %{slash: Commands, slash_guild_ids: ["1", "2"]}
|
||||
assert Registrar.run(config) == :ok
|
||||
|
||||
assert_receive {:rest_hit, %{method: "PUT", path: "/applications/app-99/guilds/g1/commands"}}
|
||||
assert_receive {:rest_hit, %{method: "PUT", path: "/applications/app-99/guilds/g2/commands"}}
|
||||
assert_receive {:rest_hit, %{method: "PUT", path: "/applications/99/guilds/1/commands"}}
|
||||
assert_receive {:rest_hit, %{method: "PUT", path: "/applications/99/guilds/2/commands"}}
|
||||
|
||||
# The global-commands route must never be hit in guild (dev) mode.
|
||||
refute_received {:rest_hit, %{path: "/applications/app-99/commands"}}
|
||||
refute_received {:rest_hit, %{path: "/applications/99/commands"}}
|
||||
end
|
||||
|
||||
test "empty commands in global mode SKIPS the overwrite (never wipes) and logs a hint" do
|
||||
|
|
@ -86,7 +89,7 @@ defmodule Dexcord.RegistrarIntegrationTest do
|
|||
|
||||
assert_receive {:rest_hit, %{method: "GET", path: "/oauth2/applications/@me"}}
|
||||
# The global-commands overwrite must NOT be attempted for an empty list.
|
||||
refute_receive {:rest_hit, %{method: "PUT", path: "/applications/app-99/commands"}}
|
||||
refute_receive {:rest_hit, %{method: "PUT", path: "/applications/99/commands"}}
|
||||
|
||||
assert log =~ "commands/0 is empty in global mode"
|
||||
assert log =~ "bulk_overwrite_global_commands"
|
||||
|
|
@ -97,7 +100,7 @@ defmodule Dexcord.RegistrarIntegrationTest do
|
|||
Dexcord.EnvSandbox.sandbox_env()
|
||||
Application.put_env(:dexcord, :registrar_retry_delays, [0, 0])
|
||||
|
||||
FakeRest.stub(:put, "/applications/app-99/commands", FakeRest.resp(403, body: "forbidden"))
|
||||
FakeRest.stub(:put, "/applications/99/commands", FakeRest.resp(403, body: "forbidden"))
|
||||
|
||||
config = %{slash: Commands, slash_guild_ids: nil}
|
||||
|
||||
|
|
@ -108,10 +111,10 @@ defmodule Dexcord.RegistrarIntegrationTest do
|
|||
end)
|
||||
|
||||
# Exactly 3 overwrite attempts hit the fake, and not a 4th.
|
||||
assert_receive {:rest_hit, %{method: "PUT", path: "/applications/app-99/commands"}}
|
||||
assert_receive {:rest_hit, %{method: "PUT", path: "/applications/app-99/commands"}}
|
||||
assert_receive {:rest_hit, %{method: "PUT", path: "/applications/app-99/commands"}}
|
||||
refute_receive {:rest_hit, %{method: "PUT", path: "/applications/app-99/commands"}}
|
||||
assert_receive {:rest_hit, %{method: "PUT", path: "/applications/99/commands"}}
|
||||
assert_receive {:rest_hit, %{method: "PUT", path: "/applications/99/commands"}}
|
||||
assert_receive {:rest_hit, %{method: "PUT", path: "/applications/99/commands"}}
|
||||
refute_receive {:rest_hit, %{method: "PUT", path: "/applications/99/commands"}}
|
||||
|
||||
assert log =~ "giving up after 3 attempt(s)"
|
||||
end
|
||||
|
|
|
|||
|
|
@ -41,9 +41,9 @@ defmodule Dexcord.RegistrarTreeTest do
|
|||
Application.put_env(:dexcord, :api_base_url, FakeRest.base_url())
|
||||
FakeRest.subscribe(self())
|
||||
|
||||
FakeRest.stub(:get, "/oauth2/applications/@me", FakeRest.resp(200, body: ~s({"id":"app-1"})))
|
||||
FakeRest.stub(:get, "/oauth2/applications/@me", FakeRest.resp(200, body: ~s({"id":"1"})))
|
||||
# Registration fails forever with a 4xx.
|
||||
FakeRest.stub(:put, "/applications/app-1/commands", FakeRest.resp(403, body: "forbidden"))
|
||||
FakeRest.stub(:put, "/applications/1/commands", FakeRest.resp(403, body: "forbidden"))
|
||||
|
||||
:ok
|
||||
end
|
||||
|
|
@ -63,10 +63,10 @@ defmodule Dexcord.RegistrarTreeTest do
|
|||
|
||||
# Exactly 3 overwrite attempts hit the fake (2 retries), then the Registrar
|
||||
# gives up - no 4th attempt.
|
||||
assert_receive {:rest_hit, %{method: "PUT", path: "/applications/app-1/commands"}}, 2_000
|
||||
assert_receive {:rest_hit, %{method: "PUT", path: "/applications/app-1/commands"}}, 2_000
|
||||
assert_receive {:rest_hit, %{method: "PUT", path: "/applications/app-1/commands"}}, 2_000
|
||||
refute_receive {:rest_hit, %{method: "PUT", path: "/applications/app-1/commands"}}, 500
|
||||
assert_receive {:rest_hit, %{method: "PUT", path: "/applications/1/commands"}}, 2_000
|
||||
assert_receive {:rest_hit, %{method: "PUT", path: "/applications/1/commands"}}, 2_000
|
||||
assert_receive {:rest_hit, %{method: "PUT", path: "/applications/1/commands"}}, 2_000
|
||||
refute_receive {:rest_hit, %{method: "PUT", path: "/applications/1/commands"}}, 500
|
||||
|
||||
# The whole tree is unharmed: supervisor and gateway are alive.
|
||||
assert Process.alive?(sup)
|
||||
|
|
|
|||
|
|
@ -123,26 +123,29 @@ defmodule Dexcord.SlashTest do
|
|||
:ok
|
||||
end
|
||||
|
||||
# Interaction/application ids must be numeric snowflakes: the typed endpoint
|
||||
# surface casts `:interaction_id` / `:application_id` path params via
|
||||
# `Dexcord.Snowflake.cast/1`. The interaction token is an opaque string.
|
||||
@itx %{
|
||||
"id" => "int-id",
|
||||
"id" => "100",
|
||||
"token" => "int-token",
|
||||
"application_id" => "app-id",
|
||||
"application_id" => "200",
|
||||
"data" => %{"name" => "ping"}
|
||||
}
|
||||
|
||||
test "respond/2 with a string sends a type-4 content response to the callback route" do
|
||||
FakeRest.stub(:post, "/interactions/int-id/int-token/callback", FakeRest.resp(204))
|
||||
FakeRest.stub(:post, "/interactions/100/int-token/callback", FakeRest.resp(204))
|
||||
|
||||
assert {:ok, nil} = Slash.respond(@itx, "pong")
|
||||
|
||||
assert_receive {:rest_hit, info}
|
||||
assert info.method == "POST"
|
||||
assert info.path == "/interactions/int-id/int-token/callback"
|
||||
assert info.path == "/interactions/100/int-token/callback"
|
||||
assert JSON.decode!(info.body) == %{"type" => 4, "data" => %{"content" => "pong"}}
|
||||
end
|
||||
|
||||
test "respond/2 with a map maps ephemeral: true to flags 64 and passes embeds through" do
|
||||
FakeRest.stub(:post, "/interactions/int-id/int-token/callback", FakeRest.resp(204))
|
||||
FakeRest.stub(:post, "/interactions/100/int-token/callback", FakeRest.resp(204))
|
||||
|
||||
assert {:ok, nil} =
|
||||
Slash.respond(@itx, %{content: "secret", embeds: [%{title: "x"}], ephemeral: true})
|
||||
|
|
@ -156,7 +159,7 @@ defmodule Dexcord.SlashTest do
|
|||
end
|
||||
|
||||
test "respond/2 accepts string-keyed data maps too" do
|
||||
FakeRest.stub(:post, "/interactions/int-id/int-token/callback", FakeRest.resp(204))
|
||||
FakeRest.stub(:post, "/interactions/100/int-token/callback", FakeRest.resp(204))
|
||||
|
||||
assert {:ok, nil} = Slash.respond(@itx, %{"content" => "hi", "ephemeral" => true})
|
||||
|
||||
|
|
@ -166,7 +169,7 @@ defmodule Dexcord.SlashTest do
|
|||
end
|
||||
|
||||
test "respond/2 passes a caller-supplied integer flags through unchanged" do
|
||||
FakeRest.stub(:post, "/interactions/int-id/int-token/callback", FakeRest.resp(204))
|
||||
FakeRest.stub(:post, "/interactions/100/int-token/callback", FakeRest.resp(204))
|
||||
|
||||
assert {:ok, nil} = Slash.respond(@itx, %{content: "hi", flags: 4})
|
||||
|
||||
|
|
@ -176,7 +179,7 @@ defmodule Dexcord.SlashTest do
|
|||
end
|
||||
|
||||
test "respond/2 OR-s a caller flags with the ephemeral bit (4 + 64 = 68)" do
|
||||
FakeRest.stub(:post, "/interactions/int-id/int-token/callback", FakeRest.resp(204))
|
||||
FakeRest.stub(:post, "/interactions/100/int-token/callback", FakeRest.resp(204))
|
||||
|
||||
assert {:ok, nil} = Slash.respond(@itx, %{content: "hi", flags: 4, ephemeral: true})
|
||||
|
||||
|
|
@ -186,7 +189,7 @@ defmodule Dexcord.SlashTest do
|
|||
end
|
||||
|
||||
test "respond_later/1 sends a bare type-5 deferred response" do
|
||||
FakeRest.stub(:post, "/interactions/int-id/int-token/callback", FakeRest.resp(204))
|
||||
FakeRest.stub(:post, "/interactions/100/int-token/callback", FakeRest.resp(204))
|
||||
|
||||
assert {:ok, nil} = Slash.respond_later(@itx)
|
||||
|
||||
|
|
@ -195,28 +198,28 @@ defmodule Dexcord.SlashTest do
|
|||
end
|
||||
|
||||
test "followup/2 posts to the application webhook route" do
|
||||
FakeRest.stub(:post, "/webhooks/app-id/int-token", FakeRest.resp(200, body: ~s({"id":"9"})))
|
||||
FakeRest.stub(:post, "/webhooks/200/int-token", FakeRest.resp(200, body: ~s({"id":"9"})))
|
||||
|
||||
assert {:ok, %{"id" => "9"}} = Slash.followup(@itx, "more")
|
||||
assert {:ok, %Dexcord.Message{id: 9}} = Slash.followup(@itx, "more")
|
||||
|
||||
assert_receive {:rest_hit, info}
|
||||
assert info.method == "POST"
|
||||
assert info.path == "/webhooks/app-id/int-token"
|
||||
assert info.path == "/webhooks/200/int-token"
|
||||
assert JSON.decode!(info.body) == %{"content" => "more"}
|
||||
end
|
||||
|
||||
test "edit_response/2 patches the original message route" do
|
||||
FakeRest.stub(
|
||||
:patch,
|
||||
"/webhooks/app-id/int-token/messages/@original",
|
||||
"/webhooks/200/int-token/messages/@original",
|
||||
FakeRest.resp(200, body: ~s({"id":"9"}))
|
||||
)
|
||||
|
||||
assert {:ok, %{"id" => "9"}} = Slash.edit_response(@itx, %{content: "edited"})
|
||||
assert {:ok, %Dexcord.Message{id: 9}} = Slash.edit_response(@itx, %{content: "edited"})
|
||||
|
||||
assert_receive {:rest_hit, info}
|
||||
assert info.method == "PATCH"
|
||||
assert info.path == "/webhooks/app-id/int-token/messages/@original"
|
||||
assert info.path == "/webhooks/200/int-token/messages/@original"
|
||||
assert JSON.decode!(info.body) == %{"content" => "edited"}
|
||||
end
|
||||
end
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue