api-surface #1
6 changed files with 298 additions and 2 deletions
feat(api): webhooks + invites + full interactions rollout
commit
36a4bef4c6
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
17
lib/dexcord/api/invites.ex
Normal file
17
lib/dexcord/api/invites.ex
Normal 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
|
||||
79
lib/dexcord/api/webhooks.ex
Normal file
79
lib/dexcord/api/webhooks.ex
Normal 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
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
169
test/dexcord/api_webhooks_test.exs
Normal file
169
test/dexcord/api_webhooks_test.exs
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue