api-surface #1
3 changed files with 559 additions and 1 deletions
feat(model): component structs + factory (incl. Components V2 + modal set)
commit
bedf200a64
383
lib/dexcord/model/component.ex
Normal file
383
lib/dexcord/model/component.ex
Normal 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 5–8)."
|
||||
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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
175
test/dexcord/model_component_test.exs
Normal file
175
test/dexcord/model_component_test.exs
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue