api-surface #1

Merged
luna merged 51 commits from api-surface into mistress 2026-07-05 14:43:13 +00:00
7 changed files with 492 additions and 1 deletions
Showing only changes of commit 93057e349a - Show all commits

feat(model): Interaction family with polymorphic data + resolved partials

Luna 2026-07-05 04:37:19 -03:00

View 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

View file

@ -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

View 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

View 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
View 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
View 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
}
}

View 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
}
}
}
}
}
}