api-surface #1

Merged
luna merged 51 commits from api-surface into mistress 2026-07-05 14:43:13 +00:00
6 changed files with 298 additions and 2 deletions
Showing only changes of commit 36a4bef4c6 - Show all commits

feat(api): webhooks + invites + full interactions rollout

Luna 2026-07-05 06:28:31 -03:00

View file

@ -403,6 +403,8 @@ defmodule Dexcord.Api do
Dexcord.Api.Guilds,
Dexcord.Api.Members,
Dexcord.Api.Roles,
Dexcord.Api.Webhooks,
Dexcord.Api.Invites,
Dexcord.Api.Users,
Dexcord.Api.Interactions,
Dexcord.Api.Commands,

View file

@ -3,8 +3,12 @@ defmodule Dexcord.Api.Interactions do
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`.
naturally, and `query: [:with_response]` lets the caller opt into the richer
callback-resource body. The followup/edit endpoints return a `Dexcord.Message`.
These routes are token-authed (`:token` is the interaction/application token);
each collapses to its own digested ratelimit bucket, so they are isolated from
the global bot rate limit (see `Dexcord.Api.Ratelimit.route_key/2`).
"""
use Dexcord.Api.Endpoint
@ -13,6 +17,7 @@ defmodule Dexcord.Api.Interactions do
"/interactions/:interaction_id/:token/callback",
body: true,
files: true,
query: [:with_response],
returns: :raw
endpoint :edit_original_interaction_response,
@ -26,4 +31,26 @@ defmodule Dexcord.Api.Interactions do
body: true,
files: true,
returns: Dexcord.Message
endpoint :get_original_interaction_response,
:get,
"/webhooks/:application_id/:token/messages/@original", returns: Dexcord.Message
endpoint :delete_original_interaction_response,
:delete,
"/webhooks/:application_id/:token/messages/@original", returns: nil
endpoint :get_followup_message, :get, "/webhooks/:application_id/:token/messages/:message_id",
returns: Dexcord.Message
endpoint :edit_followup_message,
:patch,
"/webhooks/:application_id/:token/messages/:message_id",
body: true,
files: true,
returns: Dexcord.Message
endpoint :delete_followup_message,
:delete,
"/webhooks/:application_id/:token/messages/:message_id", returns: nil
end

View file

@ -0,0 +1,17 @@
defmodule Dexcord.Api.Invites do
@moduledoc """
Invite endpoints, declared with the `Dexcord.Api.Endpoint` DSL mirrors the
Discord "Invite" resource page.
Invite codes are non-numeric path params; the ratelimit layer collapses them
to a bounded placeholder so codes never land in ETS
(see `Dexcord.Api.Ratelimit.route_key/2`).
"""
use Dexcord.Api.Endpoint
endpoint :get_invite, :get, "/invites/:invite_code",
query: [:with_counts, :guild_scheduled_event_id],
returns: Dexcord.Invite
endpoint :delete_invite, :delete, "/invites/:invite_code", reason: true, returns: Dexcord.Invite
end

View file

@ -0,0 +1,79 @@
defmodule Dexcord.Api.Webhooks do
@moduledoc """
Webhook endpoints, declared with the `Dexcord.Api.Endpoint` DSL mirrors the
Discord "Webhook" resource page.
`execute_webhook` returns a `Dexcord.Message` only when called with
`wait: true` (otherwise Discord replies 204 and the passthrough yields
`{:ok, nil}`). The Slack/GitHub-compat executes have no model (`:raw`). The
`*_with_token` variants are token-authed; their tokens are digested out of the
ratelimit bucket key (see `Dexcord.Api.Ratelimit.route_key/2`).
"""
use Dexcord.Api.Endpoint
endpoint :create_webhook, :post, "/channels/:channel_id/webhooks",
body: true,
binary_wrap: :name,
reason: true,
returns: Dexcord.Webhook
endpoint :get_channel_webhooks, :get, "/channels/:channel_id/webhooks",
returns: [Dexcord.Webhook]
endpoint :get_guild_webhooks, :get, "/guilds/:guild_id/webhooks", returns: [Dexcord.Webhook]
endpoint :get_webhook, :get, "/webhooks/:webhook_id", returns: Dexcord.Webhook
endpoint :get_webhook_with_token, :get, "/webhooks/:webhook_id/:webhook_token",
returns: Dexcord.Webhook
endpoint :edit_webhook, :patch, "/webhooks/:webhook_id",
body: true,
reason: true,
returns: Dexcord.Webhook
endpoint :edit_webhook_with_token, :patch, "/webhooks/:webhook_id/:webhook_token",
body: true,
returns: Dexcord.Webhook
endpoint :delete_webhook, :delete, "/webhooks/:webhook_id", reason: true, returns: nil
endpoint :delete_webhook_with_token, :delete, "/webhooks/:webhook_id/:webhook_token",
returns: nil
endpoint :execute_webhook, :post, "/webhooks/:webhook_id/:webhook_token",
body: true,
binary_wrap: :content,
files: true,
query: [:wait, :thread_id, :with_components],
returns: Dexcord.Message
endpoint :execute_slack_webhook, :post, "/webhooks/:webhook_id/:webhook_token/slack",
body: true,
query: [:thread_id, :wait],
returns: :raw
endpoint :execute_github_webhook, :post, "/webhooks/:webhook_id/:webhook_token/github",
body: true,
query: [:thread_id, :wait],
returns: :raw
endpoint :get_webhook_message,
:get,
"/webhooks/:webhook_id/:webhook_token/messages/:message_id",
query: [:thread_id],
returns: Dexcord.Message
endpoint :edit_webhook_message,
:patch,
"/webhooks/:webhook_id/:webhook_token/messages/:message_id",
body: true,
files: true,
query: [:thread_id, :with_components],
returns: Dexcord.Message
endpoint :delete_webhook_message,
:delete,
"/webhooks/:webhook_id/:webhook_token/messages/:message_id",
query: [:thread_id],
returns: nil
end

View file

@ -70,6 +70,8 @@ defmodule Dexcord.ApiFacadeTest do
Dexcord.Api.Guilds,
Dexcord.Api.Members,
Dexcord.Api.Roles,
Dexcord.Api.Webhooks,
Dexcord.Api.Invites,
Dexcord.Api.Users,
Dexcord.Api.Interactions,
Dexcord.Api.Commands,

View file

@ -0,0 +1,169 @@
defmodule Dexcord.ApiWebhooksTest do
@moduledoc """
Representative wire tests for the `Dexcord.Api.Webhooks` and
`Dexcord.Api.Invites` groups plus the extended `Dexcord.Api.Interactions`
group (Phase 5 Task 4): the `wait` / `thread_id` / `with_response` specials,
multipart webhook-message edit, and token bucketing that never leaks the token.
"""
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!(FakeRest)
Application.put_env(:dexcord, :api_base_url, FakeRest.base_url())
FakeRest.subscribe(self())
:ok
end
# --- Webhooks -----------------------------------------------------------
test "create_webhook wraps a binary as name, decodes a Webhook, carries a reason" do
FakeRest.stub(
:post,
"/channels/1/webhooks",
FakeRest.resp(200, body: ~s({"id":"9","name":"hook"}))
)
assert {:ok, %Dexcord.Webhook{id: 9, name: "hook"}} =
Api.create_webhook(1, "hook", reason: "notify")
assert_receive {:rest_hit, info}
assert JSON.decode!(info.body) == %{"name" => "hook"}
assert Map.new(info.headers)["x-audit-log-reason"] == "notify"
end
test "get_webhook_with_token decodes a Webhook" do
FakeRest.stub(:get, "/webhooks/9/tok", FakeRest.resp(200, body: ~s({"id":"9"})))
assert {:ok, %Dexcord.Webhook{id: 9}} = Api.get_webhook_with_token(9, "tok")
end
test "execute_webhook with no wait sends no query and passes a 204 through as nil" do
FakeRest.stub(:post, "/webhooks/9/tok", FakeRest.resp(204))
assert {:ok, nil} = Api.execute_webhook(9, "tok", "hi")
assert_receive {:rest_hit, info}
assert info.query_string == ""
assert JSON.decode!(info.body) == %{"content" => "hi"}
end
test "execute_webhook with wait+thread_id forwards the query and decodes a Message" do
FakeRest.stub(
:post,
"/webhooks/9/tok",
FakeRest.resp(200, body: ~s({"id":"5","content":"hi"}))
)
assert {:ok, %Dexcord.Message{id: 5, content: "hi"}} =
Api.execute_webhook(9, "tok", "hi", wait: true, thread_id: 123)
assert_receive {:rest_hit, info}
assert URI.decode_query(info.query_string) == %{"wait" => "true", "thread_id" => "123"}
end
test "execute_slack_webhook returns the raw compat response" do
FakeRest.stub(:post, "/webhooks/9/tok/slack", FakeRest.resp(200, body: ~s({"ok":true})))
assert {:ok, %{"ok" => true}} = Api.execute_slack_webhook(9, "tok", %{"text" => "hi"})
end
test "edit_webhook_message with files sends a multipart body and decodes a Message" do
FakeRest.stub(
:patch,
"/webhooks/9/tok/messages/5",
FakeRest.resp(200, body: ~s({"id":"5","content":"edited"}))
)
file = %{filename: "a.png", data: <<1, 2, 3>>, content_type: "image/png"}
assert {:ok, %Dexcord.Message{id: 5, content: "edited"}} =
Api.edit_webhook_message(9, "tok", 5, %{"content" => "edited"}, files: [file])
assert_receive {:rest_hit, info}
assert "multipart/form-data; boundary=" <> _ = Map.new(info.headers)["content-type"]
end
# --- Invites ------------------------------------------------------------
test "get_invite forwards with_counts and decodes an Invite" do
FakeRest.stub(
:get,
"/invites/abc",
FakeRest.resp(200, body: ~s({"code":"abc","approximate_member_count":42}))
)
assert {:ok, %Dexcord.Invite{code: "abc", approximate_member_count: 42}} =
Api.get_invite("abc", with_counts: true)
assert_receive {:rest_hit, info}
assert info.query_string == "with_counts=true"
end
test "delete_invite decodes the deleted Invite with a reason" do
FakeRest.stub(:delete, "/invites/abc", FakeRest.resp(200, body: ~s({"code":"abc"})))
assert {:ok, %Dexcord.Invite{code: "abc"}} = Api.delete_invite("abc", reason: "revoke")
assert_receive {:rest_hit, info}
assert Map.new(info.headers)["x-audit-log-reason"] == "revoke"
end
# --- Interactions -------------------------------------------------------
test "create_interaction_response forwards the with_response query" do
FakeRest.stub(
:post,
"/interactions/1/itok/callback",
FakeRest.resp(200, body: ~s({"resource":{}}))
)
assert {:ok, %{"resource" => %{}}} =
Api.create_interaction_response(1, "itok", %{"type" => 4}, with_response: true)
assert_receive {:rest_hit, info}
assert info.query_string == "with_response=true"
end
test "get_followup_message decodes a Message" do
FakeRest.stub(
:get,
"/webhooks/99/itok/messages/5",
FakeRest.resp(200, body: ~s({"id":"5","content":"fu"}))
)
assert {:ok, %Dexcord.Message{id: 5, content: "fu"}} =
Api.get_followup_message(99, "itok", 5)
end
test "delete_followup_message returns nil" do
FakeRest.stub(:delete, "/webhooks/99/itok/messages/5", FakeRest.resp(204))
assert {:ok, nil} = Api.delete_followup_message(99, "itok", 5)
end
# --- Token bucketing ----------------------------------------------------
test "the webhook token never appears in the ratelimit route key" do
secret = "super-secret-webhook-token"
key = Ratelimit.route_key(:post, "/webhooks/9/#{secret}/messages/5")
refute key =~ secret
assert key =~ "webhooks/9/"
# The trailing message id collapses to a bounded placeholder.
assert String.ends_with?(key, "/messages/:id [message-delete]") or
String.ends_with?(key, "/messages/:id")
end
end