api-surface #1

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

feat(model): component structs + factory (incl. Components V2 + modal set)

Luna 2026-07-05 04:31:56 -03:00

View file

@ -0,0 +1,383 @@
defmodule Dexcord.Component.ActionRow do
@moduledoc "An action row (type 1): a container for a set of interactive components."
use Dexcord.Struct
discord_struct do
field :type, :integer, default: 1
field :id, :integer
field :components, {:list, {:struct, Dexcord.Component}}, default: []
end
end
defmodule Dexcord.Component.Button do
@moduledoc "A button (type 2)."
use Dexcord.Struct
discord_struct do
field :type, :integer, default: 2
field :id, :integer
field :style, {:enum, Dexcord.ButtonStyle}
field :label, :string
field :emoji, {:struct, Dexcord.PartialEmoji}
field :custom_id, :string
field :sku_id, :snowflake
field :url, :string
field :disabled, :boolean, default: false
end
end
defmodule Dexcord.Component.SelectOption do
@moduledoc "An option in a string select (type 3)."
use Dexcord.Struct
discord_struct do
field :label, :string
field :value, :string
field :description, :string
field :emoji, {:struct, Dexcord.PartialEmoji}
field :default, :boolean
end
end
defmodule Dexcord.Component.StringSelect do
@moduledoc "A string select menu (type 3)."
use Dexcord.Struct
discord_struct do
field :type, :integer, default: 3
field :id, :integer
field :custom_id, :string
field :options, {:list, {:struct, Dexcord.Component.SelectOption}}, default: []
field :placeholder, :string
field :min_values, :integer
field :max_values, :integer
field :required, :boolean
field :disabled, :boolean, default: false
end
end
defmodule Dexcord.Component.TextInput do
@moduledoc "A text input (type 4), used in modals."
use Dexcord.Struct
discord_struct do
field :type, :integer, default: 4
field :id, :integer
field :custom_id, :string
field :style, {:enum, Dexcord.TextInputStyle}
field :min_length, :integer
field :max_length, :integer
field :required, :boolean
field :value, :string
field :placeholder, :string
end
end
defmodule Dexcord.Component.SelectDefaultValue do
@moduledoc "A default value for an entity select (types 58)."
use Dexcord.Struct
discord_struct do
field :id, :snowflake
field :type, :string
end
end
defmodule Dexcord.Component.EntitySelect do
@moduledoc """
An entity select menu one struct for user (5), role (6), mentionable (7)
and channel (8) selects. They differ only by `type` and channel select's
extra `channel_types`.
"""
use Dexcord.Struct
discord_struct do
field :type, :integer
field :id, :integer
field :custom_id, :string
field :channel_types, {:list, :integer}
field :placeholder, :string
field :default_values, {:list, {:struct, Dexcord.Component.SelectDefaultValue}}, default: []
field :min_values, :integer
field :max_values, :integer
field :required, :boolean
field :disabled, :boolean, default: false
end
end
defmodule Dexcord.Component.UnfurledMediaItem do
@moduledoc "An unfurled media item, referenced by Components V2 media components."
use Dexcord.Struct
discord_struct do
field :url, :string
field :proxy_url, :string
field :height, :integer
field :width, :integer
field :placeholder, :string
field :placeholder_version, :integer
field :content_type, :string
field :flags, :integer
field :attachment_id, :snowflake
end
end
defmodule Dexcord.Component.Section do
@moduledoc "A section (type 9): text components with an accessory."
use Dexcord.Struct
discord_struct do
field :type, :integer, default: 9
field :id, :integer
field :components, {:list, {:struct, Dexcord.Component}}, default: []
field :accessory, {:struct, Dexcord.Component}
end
end
defmodule Dexcord.Component.TextDisplay do
@moduledoc "A text display (type 10)."
use Dexcord.Struct
discord_struct do
field :type, :integer, default: 10
field :id, :integer
field :content, :string
end
end
defmodule Dexcord.Component.Thumbnail do
@moduledoc "A thumbnail (type 11), used as a section accessory."
use Dexcord.Struct
discord_struct do
field :type, :integer, default: 11
field :id, :integer
field :media, {:struct, Dexcord.Component.UnfurledMediaItem}
field :description, :string
field :spoiler, :boolean, default: false
end
end
defmodule Dexcord.Component.MediaGalleryItem do
@moduledoc "An item in a media gallery (type 12)."
use Dexcord.Struct
discord_struct do
field :media, {:struct, Dexcord.Component.UnfurledMediaItem}
field :description, :string
field :spoiler, :boolean, default: false
end
end
defmodule Dexcord.Component.MediaGallery do
@moduledoc "A media gallery (type 12)."
use Dexcord.Struct
discord_struct do
field :type, :integer, default: 12
field :id, :integer
field :items, {:list, {:struct, Dexcord.Component.MediaGalleryItem}}, default: []
end
end
defmodule Dexcord.Component.File do
@moduledoc "A file (type 13)."
use Dexcord.Struct
discord_struct do
field :type, :integer, default: 13
field :id, :integer
field :file, {:struct, Dexcord.Component.UnfurledMediaItem}
field :spoiler, :boolean, default: false
field :name, :string
field :size, :integer
end
end
defmodule Dexcord.Component.Separator do
@moduledoc "A separator (type 14)."
use Dexcord.Struct
discord_struct do
field :type, :integer, default: 14
field :id, :integer
field :divider, :boolean, default: true
field :spacing, :integer
end
end
defmodule Dexcord.Component.Container do
@moduledoc "A container (type 17): a Components V2 grouping with an accent color."
use Dexcord.Struct
discord_struct do
field :type, :integer, default: 17
field :id, :integer
field :components, {:list, {:struct, Dexcord.Component}}, default: []
field :accent_color, :integer
field :spoiler, :boolean, default: false
end
end
defmodule Dexcord.Component.Label do
@moduledoc "A label (type 18): wraps a component with a label and description."
use Dexcord.Struct
discord_struct do
field :type, :integer, default: 18
field :id, :integer
field :label, :string
field :description, :string
field :component, {:struct, Dexcord.Component}
end
end
defmodule Dexcord.Component.FileUpload do
@moduledoc "A file upload (type 19)."
use Dexcord.Struct
discord_struct do
field :type, :integer, default: 19
field :id, :integer
field :custom_id, :string
field :min_values, :integer
field :max_values, :integer
field :required, :boolean
end
end
defmodule Dexcord.Component.GroupOption do
@moduledoc "An option in a radio group (21) or checkbox group (22)."
use Dexcord.Struct
discord_struct do
field :value, :string
field :label, :string
field :description, :string
field :default, :boolean
end
end
defmodule Dexcord.Component.RadioGroup do
@moduledoc "A radio group (type 21)."
use Dexcord.Struct
discord_struct do
field :type, :integer, default: 21
field :id, :integer
field :custom_id, :string
field :options, {:list, {:struct, Dexcord.Component.GroupOption}}, default: []
field :required, :boolean
end
end
defmodule Dexcord.Component.CheckboxGroup do
@moduledoc "A checkbox group (type 22)."
use Dexcord.Struct
discord_struct do
field :type, :integer, default: 22
field :id, :integer
field :custom_id, :string
field :options, {:list, {:struct, Dexcord.Component.GroupOption}}, default: []
field :min_values, :integer
field :max_values, :integer
field :required, :boolean
end
end
defmodule Dexcord.Component.Checkbox do
@moduledoc "A checkbox (type 23)."
use Dexcord.Struct
discord_struct do
field :type, :integer, default: 23
field :id, :integer
field :custom_id, :string
field :default, :boolean
end
end
defmodule Dexcord.Component.Unknown do
@moduledoc "Fallback for component types Dexcord doesn't recognize. Raw payload preserved."
defstruct [:type, :raw]
@type t :: %__MODULE__{type: {:unknown, integer()} | nil, raw: map()}
@doc false
def from_map(%{"type" => n} = map) when is_integer(n),
do: %__MODULE__{type: {:unknown, n}, raw: map}
def from_map(map) when is_map(map), do: %__MODULE__{type: nil, raw: map}
def from_map(_), do: nil
@doc false
# No __fields__/0 here (hand-written struct): re-encoding yields the raw
# payload verbatim. The Codec routes struct encodes through the struct's
# own to_map/1, so this keeps nested unknown components encodable.
def to_map(%__MODULE__{raw: raw}), do: raw
end
defmodule Dexcord.Component do
@moduledoc """
Component factory: decodes a wire component map into the struct for its type.
Components dispatch on the integer `"type"` field. Types 15, 16 and 20 are
unassigned in the current docs (real gaps) and fall through to
`Dexcord.Component.Unknown` along with any other unrecognized type.
"""
@type_map %{
1 => Dexcord.Component.ActionRow,
2 => Dexcord.Component.Button,
3 => Dexcord.Component.StringSelect,
4 => Dexcord.Component.TextInput,
5 => Dexcord.Component.EntitySelect,
6 => Dexcord.Component.EntitySelect,
7 => Dexcord.Component.EntitySelect,
8 => Dexcord.Component.EntitySelect,
9 => Dexcord.Component.Section,
10 => Dexcord.Component.TextDisplay,
11 => Dexcord.Component.Thumbnail,
12 => Dexcord.Component.MediaGallery,
13 => Dexcord.Component.File,
14 => Dexcord.Component.Separator,
17 => Dexcord.Component.Container,
18 => Dexcord.Component.Label,
19 => Dexcord.Component.FileUpload,
21 => Dexcord.Component.RadioGroup,
22 => Dexcord.Component.CheckboxGroup,
23 => Dexcord.Component.Checkbox
}
@type t ::
Dexcord.Component.ActionRow.t()
| Dexcord.Component.Button.t()
| Dexcord.Component.StringSelect.t()
| Dexcord.Component.TextInput.t()
| Dexcord.Component.EntitySelect.t()
| Dexcord.Component.Section.t()
| Dexcord.Component.TextDisplay.t()
| Dexcord.Component.Thumbnail.t()
| Dexcord.Component.MediaGallery.t()
| Dexcord.Component.File.t()
| Dexcord.Component.Separator.t()
| Dexcord.Component.Container.t()
| Dexcord.Component.Label.t()
| Dexcord.Component.FileUpload.t()
| Dexcord.Component.RadioGroup.t()
| Dexcord.Component.CheckboxGroup.t()
| Dexcord.Component.Checkbox.t()
| Dexcord.Component.Unknown.t()
@doc "Decodes a wire component map into the struct for its `\"type\"`."
@spec from_map(term()) :: t() | nil
def from_map(%{"type" => type} = map) when is_map_key(@type_map, type),
do: @type_map[type].from_map(map)
def from_map(map) when is_map(map), do: Dexcord.Component.Unknown.from_map(map)
def from_map(_), do: nil
@doc false
def __type_map__, do: @type_map
end

View file

@ -34,7 +34,7 @@ defmodule Dexcord.Message do
field :interaction_metadata, :raw
field :interaction, :raw
field :thread, {:struct, Dexcord.Channel}
field :components, :raw
field :components, {:list, {:struct, Dexcord.Component}}, default: []
field :sticker_items, {:list, {:struct, Dexcord.StickerItem}}, default: []
field :stickers, {:list, {:struct, Dexcord.Sticker}}
field :position, :integer

View file

@ -0,0 +1,175 @@
defmodule Dexcord.ModelComponentTest do
use ExUnit.Case, async: true
# Literal expectation table — deliberately NOT reusing
# Dexcord.Component.__type_map__/0 (that would test the code with itself).
@expected %{
1 => Dexcord.Component.ActionRow,
2 => Dexcord.Component.Button,
3 => Dexcord.Component.StringSelect,
4 => Dexcord.Component.TextInput,
5 => Dexcord.Component.EntitySelect,
6 => Dexcord.Component.EntitySelect,
7 => Dexcord.Component.EntitySelect,
8 => Dexcord.Component.EntitySelect,
9 => Dexcord.Component.Section,
10 => Dexcord.Component.TextDisplay,
11 => Dexcord.Component.Thumbnail,
12 => Dexcord.Component.MediaGallery,
13 => Dexcord.Component.File,
14 => Dexcord.Component.Separator,
17 => Dexcord.Component.Container,
18 => Dexcord.Component.Label,
19 => Dexcord.Component.FileUpload,
21 => Dexcord.Component.RadioGroup,
22 => Dexcord.Component.CheckboxGroup,
23 => Dexcord.Component.Checkbox
}
describe "factory dispatch on wire type" do
test "every mapped type decodes to exactly its expected struct" do
assert Dexcord.Component.__type_map__() == @expected
for {type, mod} <- @expected do
decoded = Dexcord.Component.from_map(%{"type" => type})
assert decoded.__struct__ == mod,
"type #{type} decoded to #{inspect(decoded.__struct__)}, expected #{inspect(mod)}"
end
end
test "entity-select types 5/6/7/8 all decode to one struct" do
for type <- [5, 6, 7, 8] do
assert %Dexcord.Component.EntitySelect{} =
Dexcord.Component.from_map(%{"type" => type})
end
end
end
describe "recursive decode through the factory" do
test "an action row with a button + string select decodes recursively" do
wire = %{
"type" => 1,
"id" => 7,
"components" => [
%{"type" => 2, "style" => 1, "label" => "Click", "custom_id" => "btn"},
%{
"type" => 3,
"custom_id" => "sel",
"options" => [
%{"label" => "One", "value" => "1", "default" => true}
]
}
]
}
assert %Dexcord.Component.ActionRow{
id: 7,
components: [
%Dexcord.Component.Button{style: :primary, label: "Click", custom_id: "btn"},
%Dexcord.Component.StringSelect{
custom_id: "sel",
options: [
%Dexcord.Component.SelectOption{label: "One", value: "1", default: true}
]
}
]
} = Dexcord.Component.from_map(wire)
end
test "a Components-V2 container decodes its nested tree" do
wire = %{
"type" => 17,
"accent_color" => 16_711_680,
"components" => [
%{
"type" => 9,
"components" => [%{"type" => 10, "content" => "Hello"}],
"accessory" => %{
"type" => 11,
"media" => %{"url" => "https://example.test/a.png"},
"description" => "art"
}
},
%{"type" => 14, "divider" => true, "spacing" => 1}
]
}
assert %Dexcord.Component.Container{
accent_color: 16_711_680,
components: [
%Dexcord.Component.Section{
components: [%Dexcord.Component.TextDisplay{content: "Hello"}],
accessory: %Dexcord.Component.Thumbnail{
media: %Dexcord.Component.UnfurledMediaItem{
url: "https://example.test/a.png"
},
description: "art"
}
},
%Dexcord.Component.Separator{divider: true, spacing: 1}
]
} = Dexcord.Component.from_map(wire)
end
end
describe "id is a plain integer, not a snowflake" do
test "component id stays an integer" do
assert %Dexcord.Component.TextDisplay{id: 42} =
Dexcord.Component.from_map(%{"type" => 10, "id" => 42, "content" => "x"})
end
end
describe "unknown-type fallback" do
test "an unknown type int preserves the raw payload" do
raw = %{"type" => 99, "custom_id" => "x"}
assert %Dexcord.Component.Unknown{type: {:unknown, 99}, raw: ^raw} =
Dexcord.Component.from_map(raw)
end
test "an unassigned type (15) also falls back to Unknown" do
assert %Dexcord.Component.Unknown{type: {:unknown, 15}} =
Dexcord.Component.from_map(%{"type" => 15})
end
test "a map with no type field decodes to Unknown with nil type" do
assert %Dexcord.Component.Unknown{type: nil} =
Dexcord.Component.from_map(%{"custom_id" => "x"})
end
test "Unknown re-encodes to its raw payload verbatim" do
raw = %{"type" => 99, "custom_id" => "x"}
unknown = Dexcord.Component.from_map(raw)
assert Dexcord.Component.Unknown.to_map(unknown) == raw
end
end
describe "Message.components retype" do
test "a message map with components decodes typed via Message.from_map/1" do
wire = %{
"id" => "123",
"content" => "hi",
"components" => [
%{
"type" => 1,
"components" => [%{"type" => 2, "style" => 2, "custom_id" => "b"}]
}
]
}
assert %Dexcord.Message{
components: [
%Dexcord.Component.ActionRow{
components: [%Dexcord.Component.Button{style: :secondary, custom_id: "b"}]
}
]
} = Dexcord.Message.from_map(wire)
end
test "a message with no components defaults to []" do
assert %Dexcord.Message{components: []} =
Dexcord.Message.from_map(%{"id" => "1"})
end
end
end