api-surface #1
7 changed files with 492 additions and 1 deletions
feat(model): Interaction family with polymorphic data + resolved partials
commit
93057e349a
158
lib/dexcord/model/interaction.ex
Normal file
158
lib/dexcord/model/interaction.ex
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
defmodule Dexcord.PartialChannel do
|
||||
@moduledoc "The partial channel shape in interaction resolved data (current docs: 13 fields + thread_metadata)."
|
||||
use Dexcord.Struct
|
||||
|
||||
discord_struct do
|
||||
field :id, :snowflake
|
||||
field :name, :string
|
||||
field :type, {:enum, Dexcord.ChannelType}
|
||||
field :permissions, {:flags, Dexcord.Permissions}, wire_string: true
|
||||
field :last_message_id, :snowflake
|
||||
field :last_pin_timestamp, :datetime
|
||||
field :nsfw, :boolean, default: false
|
||||
field :parent_id, :snowflake
|
||||
field :guild_id, :snowflake
|
||||
field :flags, {:flags, Dexcord.ChannelFlags}
|
||||
field :rate_limit_per_user, :integer
|
||||
field :topic, :string
|
||||
field :position, :integer
|
||||
field :thread_metadata, {:struct, Dexcord.ThreadMetadata}
|
||||
end
|
||||
end
|
||||
|
||||
defmodule Dexcord.ResolvedData do
|
||||
@moduledoc "Interaction resolved data: id-keyed maps of (partial) objects."
|
||||
use Dexcord.Struct
|
||||
|
||||
discord_struct do
|
||||
field :users, {:id_map, {:struct, Dexcord.User}}, default: %{}
|
||||
field :members, {:id_map, {:struct, Dexcord.PartialMember}}, default: %{}
|
||||
field :roles, {:id_map, {:struct, Dexcord.Role}}, default: %{}
|
||||
field :channels, {:id_map, {:struct, Dexcord.PartialChannel}}, default: %{}
|
||||
field :messages, {:id_map, {:struct, Dexcord.Message}}, default: %{}
|
||||
field :attachments, {:id_map, {:struct, Dexcord.Attachment}}, default: %{}
|
||||
end
|
||||
end
|
||||
|
||||
defmodule Dexcord.Interaction.DataOption do
|
||||
@moduledoc "A single application-command interaction data option (recursive)."
|
||||
use Dexcord.Struct
|
||||
|
||||
discord_struct do
|
||||
field :name, :string
|
||||
field :type, {:enum, Dexcord.ApplicationCommandOptionType}
|
||||
field :value, :raw
|
||||
field :options, {:list, {:struct, __MODULE__}}, default: []
|
||||
field :focused, :boolean
|
||||
end
|
||||
end
|
||||
|
||||
defmodule Dexcord.Interaction.ApplicationCommandData do
|
||||
@moduledoc "Interaction `data` for APPLICATION_COMMAND / autocomplete interactions."
|
||||
use Dexcord.Struct
|
||||
|
||||
discord_struct do
|
||||
field :id, :snowflake
|
||||
field :name, :string
|
||||
field :type, {:enum, Dexcord.ApplicationCommandType}
|
||||
field :resolved, {:struct, Dexcord.ResolvedData}
|
||||
field :options, {:list, {:struct, Dexcord.Interaction.DataOption}}, default: []
|
||||
field :guild_id, :snowflake
|
||||
field :target_id, :snowflake
|
||||
end
|
||||
end
|
||||
|
||||
defmodule Dexcord.Interaction.MessageComponentData do
|
||||
@moduledoc "Interaction `data` for MESSAGE_COMPONENT interactions."
|
||||
use Dexcord.Struct
|
||||
|
||||
discord_struct do
|
||||
field :custom_id, :string
|
||||
field :component_type, {:enum, Dexcord.ComponentType}
|
||||
field :values, {:list, :string}, default: []
|
||||
field :resolved, {:struct, Dexcord.ResolvedData}
|
||||
end
|
||||
end
|
||||
|
||||
defmodule Dexcord.Interaction.ModalSubmitData do
|
||||
@moduledoc """
|
||||
Interaction `data` for MODAL_SUBMIT interactions. `components` holds modal
|
||||
RESPONSE shapes (not component definitions) — deliberately raw.
|
||||
"""
|
||||
use Dexcord.Struct
|
||||
|
||||
discord_struct do
|
||||
field :custom_id, :string
|
||||
field :components, {:list, :raw}, default: []
|
||||
field :resolved, {:struct, Dexcord.ResolvedData}
|
||||
end
|
||||
end
|
||||
|
||||
defmodule Dexcord.MessageInteraction do
|
||||
@moduledoc """
|
||||
The deprecated `message.interaction` shape (superseded by
|
||||
`message.interaction_metadata`, still delivered on messages).
|
||||
"""
|
||||
use Dexcord.Struct
|
||||
|
||||
discord_struct do
|
||||
field :id, :snowflake
|
||||
field :type, {:enum, Dexcord.InteractionType}
|
||||
field :name, :string
|
||||
field :user, {:struct, Dexcord.User}
|
||||
field :member, {:struct, Dexcord.PartialMember}
|
||||
end
|
||||
end
|
||||
|
||||
defmodule Dexcord.Interaction do
|
||||
@moduledoc "An interaction. https://docs.discord.com/developers/interactions/receiving-and-responding"
|
||||
use Dexcord.Struct
|
||||
|
||||
discord_struct do
|
||||
field :id, :snowflake
|
||||
field :application_id, :snowflake
|
||||
field :type, {:enum, Dexcord.InteractionType}
|
||||
field :data, :raw
|
||||
field :guild, {:struct, Dexcord.Guild}
|
||||
field :guild_id, :snowflake
|
||||
field :channel, {:struct, Dexcord.PartialChannel}
|
||||
field :channel_id, :snowflake
|
||||
field :member, {:struct, Dexcord.Member}
|
||||
field :user, {:struct, Dexcord.User}
|
||||
field :token, :string
|
||||
field :version, :integer
|
||||
field :message, {:struct, Dexcord.Message}
|
||||
field :app_permissions, {:flags, Dexcord.Permissions}, wire_string: true
|
||||
field :locale, :string
|
||||
field :guild_locale, :string
|
||||
field :entitlements, :raw
|
||||
field :authorizing_integration_owners, :raw
|
||||
field :context, {:enum, Dexcord.InteractionContextType}
|
||||
field :attachment_size_limit, :integer
|
||||
end
|
||||
|
||||
@data_variants %{
|
||||
application_command: Dexcord.Interaction.ApplicationCommandData,
|
||||
application_command_autocomplete: Dexcord.Interaction.ApplicationCommandData,
|
||||
message_component: Dexcord.Interaction.MessageComponentData,
|
||||
modal_submit: Dexcord.Interaction.ModalSubmitData
|
||||
}
|
||||
|
||||
# Polymorphic `data` decode: the generated `from_map/1` (Task 1's
|
||||
# `defoverridable`) is post-processed to swap the raw `data` map for its
|
||||
# typed variant based on the interaction `type`. NOTE: match the decoded
|
||||
# struct as a plain map guarded by `is_struct/2` — `defstruct` is injected
|
||||
# by `@before_compile`, so `%__MODULE__{}` is not usable in the body.
|
||||
def from_map(map) do
|
||||
case super(map) do
|
||||
%{data: raw} = interaction when is_struct(interaction, __MODULE__) and is_map(raw) ->
|
||||
case Map.fetch(@data_variants, interaction.type) do
|
||||
{:ok, mod} -> %{interaction | data: mod.from_map(raw)}
|
||||
:error -> interaction
|
||||
end
|
||||
|
||||
other ->
|
||||
other
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -39,7 +39,7 @@ defmodule Dexcord.Message do
|
|||
field :stickers, {:list, {:struct, Dexcord.Sticker}}
|
||||
field :position, :integer
|
||||
field :role_subscription_data, :raw
|
||||
field :resolved, :raw
|
||||
field :resolved, {:struct, Dexcord.ResolvedData}
|
||||
field :poll, :raw
|
||||
field :call, {:struct, Dexcord.MessageCall}
|
||||
field :shared_client_theme, :raw
|
||||
|
|
|
|||
148
test/dexcord/model_interaction_test.exs
Normal file
148
test/dexcord/model_interaction_test.exs
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
defmodule Dexcord.ModelInteractionTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias Dexcord.Fixtures
|
||||
alias Dexcord.Interaction
|
||||
|
||||
alias Dexcord.Interaction.{
|
||||
ApplicationCommandData,
|
||||
DataOption,
|
||||
MessageComponentData,
|
||||
ModalSubmitData
|
||||
}
|
||||
|
||||
describe "api-surface.AC2.15: INTERACTION_CREATE guild variant" do
|
||||
setup do
|
||||
%{interaction: Interaction.from_map(Fixtures.load!("interaction_slash_guild.json"))}
|
||||
end
|
||||
|
||||
test "decodes to a typed Interaction", %{interaction: interaction} do
|
||||
assert %Interaction{
|
||||
id: 1_000_000_000_000_000_001,
|
||||
type: :application_command,
|
||||
guild_id: 800_000_000_000_000_001,
|
||||
context: :guild
|
||||
} = interaction
|
||||
end
|
||||
|
||||
test "member is a full Member with a nested User and integer permissions", %{
|
||||
interaction: interaction
|
||||
} do
|
||||
assert %Dexcord.Member{
|
||||
user: %Dexcord.User{id: 600_000_000_000_000_001, username: "invoker"},
|
||||
permissions: 2_147_483_647,
|
||||
nick: "The Invoker"
|
||||
} = interaction.member
|
||||
end
|
||||
|
||||
test "the guild variant carries member, not top-level user", %{interaction: interaction} do
|
||||
assert interaction.user == nil
|
||||
end
|
||||
|
||||
test "app_permissions decodes to an integer bitfield", %{interaction: interaction} do
|
||||
assert interaction.app_permissions == 8
|
||||
end
|
||||
|
||||
test "data is a typed ApplicationCommandData with typed options", %{interaction: interaction} do
|
||||
assert %ApplicationCommandData{
|
||||
name: "whois",
|
||||
type: :chat_input,
|
||||
options: [%DataOption{name: "target", type: :user, value: "600000000000000002"}]
|
||||
} = interaction.data
|
||||
end
|
||||
end
|
||||
|
||||
describe "api-surface.AC2.15: INTERACTION_CREATE DM variant" do
|
||||
setup do
|
||||
%{interaction: Interaction.from_map(Fixtures.load!("interaction_slash_dm.json"))}
|
||||
end
|
||||
|
||||
test "user is present at top level, member absent", %{interaction: interaction} do
|
||||
assert %Dexcord.User{id: 600_000_000_000_000_003, username: "dmuser"} = interaction.user
|
||||
assert interaction.member == nil
|
||||
assert interaction.guild_id == nil
|
||||
end
|
||||
|
||||
test "context decodes to :bot_dm", %{interaction: interaction} do
|
||||
assert interaction.context == :bot_dm
|
||||
end
|
||||
|
||||
test "data is still a typed ApplicationCommandData", %{interaction: interaction} do
|
||||
assert %ApplicationCommandData{name: "ping", type: :chat_input} = interaction.data
|
||||
end
|
||||
end
|
||||
|
||||
describe "api-surface.AC2.16: resolved interaction data decodes to partials" do
|
||||
setup do
|
||||
interaction = Interaction.from_map(Fixtures.load!("interaction_slash_guild.json"))
|
||||
%{resolved: interaction.data.resolved}
|
||||
end
|
||||
|
||||
test "resolved is a typed ResolvedData struct", %{resolved: resolved} do
|
||||
assert %Dexcord.ResolvedData{} = resolved
|
||||
end
|
||||
|
||||
test "users are full Users keyed by INTEGER snowflakes", %{resolved: resolved} do
|
||||
assert %{600_000_000_000_000_002 => %Dexcord.User{username: "target"}} = resolved.users
|
||||
end
|
||||
|
||||
test "members are PartialMembers with no :user key on the struct", %{resolved: resolved} do
|
||||
member = resolved.members[600_000_000_000_000_002]
|
||||
|
||||
assert %Dexcord.PartialMember{nick: "Targeted", permissions: 104_324_673} = member
|
||||
refute Map.has_key?(member, :user)
|
||||
end
|
||||
|
||||
test "channels are PartialChannels with the classic four fields set", %{resolved: resolved} do
|
||||
channel = resolved.channels[700_000_000_000_000_001]
|
||||
|
||||
assert %Dexcord.PartialChannel{
|
||||
id: 700_000_000_000_000_001,
|
||||
name: "general",
|
||||
type: :guild_text,
|
||||
permissions: 2_147_483_647
|
||||
} = channel
|
||||
end
|
||||
|
||||
test "the thread channel variant carries thread_metadata", %{resolved: resolved} do
|
||||
thread = resolved.channels[720_000_000_000_000_001]
|
||||
|
||||
assert %Dexcord.PartialChannel{
|
||||
name: "help-thread",
|
||||
thread_metadata: %Dexcord.ThreadMetadata{auto_archive_duration: 1440}
|
||||
} = thread
|
||||
end
|
||||
end
|
||||
|
||||
describe "component and modal data variants" do
|
||||
test "component fixture decodes to MessageComponentData" do
|
||||
interaction = Interaction.from_map(Fixtures.load!("interaction_component.json"))
|
||||
|
||||
assert %Interaction{type: :message_component} = interaction
|
||||
|
||||
assert %MessageComponentData{custom_id: "confirm_button", component_type: :button} =
|
||||
interaction.data
|
||||
|
||||
assert %Dexcord.Message{content: "Are you sure?"} = interaction.message
|
||||
end
|
||||
|
||||
test "modal fixture decodes to ModalSubmitData with a raw components list" do
|
||||
interaction = Interaction.from_map(Fixtures.load!("interaction_modal.json"))
|
||||
|
||||
assert %Interaction{type: :modal_submit} = interaction
|
||||
|
||||
assert %ModalSubmitData{custom_id: "feedback_modal", components: components} =
|
||||
interaction.data
|
||||
|
||||
assert [%{"type" => 18}] = components
|
||||
end
|
||||
end
|
||||
|
||||
describe "PING interactions carry no data variant" do
|
||||
test "a type-1 interaction with no data decodes with data == nil" do
|
||||
interaction = Interaction.from_map(%{"id" => "1", "type" => 1, "token" => "t"})
|
||||
|
||||
assert %Interaction{type: :ping, data: nil} = interaction
|
||||
end
|
||||
end
|
||||
end
|
||||
32
test/fixtures/interaction_component.json
vendored
Normal file
32
test/fixtures/interaction_component.json
vendored
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
{
|
||||
"id": "1000000000000000003",
|
||||
"application_id": "900000000000000001",
|
||||
"type": 3,
|
||||
"token": "aW50ZXJhY3Rpb24tdG9rZW4tY29tcA",
|
||||
"version": 1,
|
||||
"guild_id": "800000000000000001",
|
||||
"channel_id": "700000000000000001",
|
||||
"app_permissions": "2147483647",
|
||||
"context": 0,
|
||||
"member": {
|
||||
"user": {
|
||||
"id": "600000000000000001",
|
||||
"username": "clicker",
|
||||
"discriminator": "0",
|
||||
"global_name": "Clicker"
|
||||
},
|
||||
"roles": [],
|
||||
"joined_at": "2021-01-01T00:00:00.000000+00:00",
|
||||
"permissions": "2147483647"
|
||||
},
|
||||
"data": {
|
||||
"custom_id": "confirm_button",
|
||||
"component_type": 2
|
||||
},
|
||||
"message": {
|
||||
"id": "500000000000000001",
|
||||
"channel_id": "700000000000000001",
|
||||
"content": "Are you sure?",
|
||||
"type": 0
|
||||
}
|
||||
}
|
||||
36
test/fixtures/interaction_modal.json
vendored
Normal file
36
test/fixtures/interaction_modal.json
vendored
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
{
|
||||
"id": "1000000000000000004",
|
||||
"application_id": "900000000000000001",
|
||||
"type": 5,
|
||||
"token": "aW50ZXJhY3Rpb24tdG9rZW4tbW9kYWw",
|
||||
"version": 1,
|
||||
"guild_id": "800000000000000001",
|
||||
"channel_id": "700000000000000001",
|
||||
"app_permissions": "2147483647",
|
||||
"context": 0,
|
||||
"member": {
|
||||
"user": {
|
||||
"id": "600000000000000001",
|
||||
"username": "submitter",
|
||||
"discriminator": "0",
|
||||
"global_name": "Submitter"
|
||||
},
|
||||
"roles": [],
|
||||
"joined_at": "2021-01-01T00:00:00.000000+00:00",
|
||||
"permissions": "2147483647"
|
||||
},
|
||||
"data": {
|
||||
"custom_id": "feedback_modal",
|
||||
"components": [
|
||||
{
|
||||
"type": 18,
|
||||
"label": "Your feedback",
|
||||
"component": {
|
||||
"type": 4,
|
||||
"custom_id": "feedback_input",
|
||||
"value": "This is great!"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
23
test/fixtures/interaction_slash_dm.json
vendored
Normal file
23
test/fixtures/interaction_slash_dm.json
vendored
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"id": "1000000000000000002",
|
||||
"application_id": "900000000000000001",
|
||||
"type": 2,
|
||||
"token": "aW50ZXJhY3Rpb24tdG9rZW4tZG0",
|
||||
"version": 1,
|
||||
"channel_id": "710000000000000009",
|
||||
"app_permissions": "0",
|
||||
"locale": "en-US",
|
||||
"context": 1,
|
||||
"attachment_size_limit": 26214400,
|
||||
"user": {
|
||||
"id": "600000000000000003",
|
||||
"username": "dmuser",
|
||||
"discriminator": "0",
|
||||
"global_name": "DM User"
|
||||
},
|
||||
"data": {
|
||||
"id": "950000000000000002",
|
||||
"name": "ping",
|
||||
"type": 1
|
||||
}
|
||||
}
|
||||
94
test/fixtures/interaction_slash_guild.json
vendored
Normal file
94
test/fixtures/interaction_slash_guild.json
vendored
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
{
|
||||
"id": "1000000000000000001",
|
||||
"application_id": "900000000000000001",
|
||||
"type": 2,
|
||||
"token": "aW50ZXJhY3Rpb24tdG9rZW4",
|
||||
"version": 1,
|
||||
"guild_id": "800000000000000001",
|
||||
"channel_id": "700000000000000001",
|
||||
"app_permissions": "8",
|
||||
"locale": "en-US",
|
||||
"guild_locale": "en-US",
|
||||
"context": 0,
|
||||
"attachment_size_limit": 26214400,
|
||||
"channel": {
|
||||
"id": "700000000000000001",
|
||||
"name": "general",
|
||||
"type": 0,
|
||||
"permissions": "2147483647",
|
||||
"last_message_id": "710000000000000001",
|
||||
"nsfw": false,
|
||||
"parent_id": "690000000000000001",
|
||||
"guild_id": "800000000000000001",
|
||||
"flags": 0,
|
||||
"rate_limit_per_user": 0,
|
||||
"topic": "welcome",
|
||||
"position": 3
|
||||
},
|
||||
"member": {
|
||||
"user": {
|
||||
"id": "600000000000000001",
|
||||
"username": "invoker",
|
||||
"discriminator": "0",
|
||||
"global_name": "Invoker"
|
||||
},
|
||||
"nick": "The Invoker",
|
||||
"roles": ["810000000000000001"],
|
||||
"joined_at": "2021-01-01T00:00:00.000000+00:00",
|
||||
"deaf": false,
|
||||
"mute": false,
|
||||
"permissions": "2147483647"
|
||||
},
|
||||
"data": {
|
||||
"id": "950000000000000001",
|
||||
"name": "whois",
|
||||
"type": 1,
|
||||
"options": [
|
||||
{
|
||||
"name": "target",
|
||||
"type": 6,
|
||||
"value": "600000000000000002"
|
||||
}
|
||||
],
|
||||
"resolved": {
|
||||
"users": {
|
||||
"600000000000000002": {
|
||||
"id": "600000000000000002",
|
||||
"username": "target",
|
||||
"discriminator": "0",
|
||||
"global_name": "Target User",
|
||||
"avatar": "abc123"
|
||||
}
|
||||
},
|
||||
"members": {
|
||||
"600000000000000002": {
|
||||
"nick": "Targeted",
|
||||
"roles": ["810000000000000002"],
|
||||
"joined_at": "2021-06-01T00:00:00.000000+00:00",
|
||||
"permissions": "104324673"
|
||||
}
|
||||
},
|
||||
"channels": {
|
||||
"700000000000000001": {
|
||||
"id": "700000000000000001",
|
||||
"name": "general",
|
||||
"type": 0,
|
||||
"permissions": "2147483647"
|
||||
},
|
||||
"720000000000000001": {
|
||||
"id": "720000000000000001",
|
||||
"name": "help-thread",
|
||||
"type": 11,
|
||||
"permissions": "1024",
|
||||
"parent_id": "700000000000000001",
|
||||
"thread_metadata": {
|
||||
"archived": false,
|
||||
"auto_archive_duration": 1440,
|
||||
"archive_timestamp": "2022-01-01T00:00:00.000000+00:00",
|
||||
"locked": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue