api-surface #1

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

feat(model): channel structs + factory dispatch on wire type

Luna 2026-07-05 04:01:55 -03:00

View file

@ -0,0 +1,258 @@
defmodule Dexcord.Model.ChannelShared do
@moduledoc false
# Field group shared by the guild-channel structs (everything with a guild).
def __included_fields__ do
[
{:id, :snowflake, []},
{:type, {:enum, Dexcord.ChannelType}, []},
{:guild_id, :snowflake, []},
{:name, :string, []},
{:position, :integer, []},
{:permission_overwrites, {:list, {:struct, Dexcord.Overwrite}}, [default: []]},
{:parent_id, :snowflake, []},
{:nsfw, :boolean, [default: false]},
{:flags, {:flags, Dexcord.ChannelFlags}, []}
]
end
end
defmodule Dexcord.TextChannel do
@moduledoc "A guild text channel (type 0)."
use Dexcord.Struct
discord_struct do
include_fields Dexcord.Model.ChannelShared
field :topic, :string
field :last_message_id, :snowflake
field :rate_limit_per_user, :integer
field :last_pin_timestamp, :datetime
field :default_auto_archive_duration, :integer
field :default_thread_rate_limit_per_user, :integer
end
end
defmodule Dexcord.AnnouncementChannel do
@moduledoc "A guild announcement channel (type 5)."
use Dexcord.Struct
discord_struct do
include_fields Dexcord.Model.ChannelShared
field :topic, :string
field :last_message_id, :snowflake
field :rate_limit_per_user, :integer
field :last_pin_timestamp, :datetime
field :default_auto_archive_duration, :integer
field :default_thread_rate_limit_per_user, :integer
end
end
defmodule Dexcord.VoiceChannel do
@moduledoc "A guild voice channel (type 2)."
use Dexcord.Struct
discord_struct do
include_fields Dexcord.Model.ChannelShared
field :bitrate, :integer
field :user_limit, :integer
field :rtc_region, :string
field :video_quality_mode, {:enum, Dexcord.VideoQualityMode}
field :last_message_id, :snowflake
field :rate_limit_per_user, :integer
end
end
defmodule Dexcord.StageChannel do
@moduledoc "A guild stage voice channel (type 13)."
use Dexcord.Struct
discord_struct do
include_fields Dexcord.Model.ChannelShared
field :bitrate, :integer
field :user_limit, :integer
field :rtc_region, :string
field :video_quality_mode, {:enum, Dexcord.VideoQualityMode}
field :last_message_id, :snowflake
field :rate_limit_per_user, :integer
end
end
defmodule Dexcord.CategoryChannel do
@moduledoc "A guild category channel (type 4)."
use Dexcord.Struct
discord_struct do
include_fields Dexcord.Model.ChannelShared
end
end
defmodule Dexcord.DirectoryChannel do
@moduledoc "A guild directory channel (type 14)."
use Dexcord.Struct
discord_struct do
include_fields Dexcord.Model.ChannelShared
end
end
defmodule Dexcord.ForumChannel do
@moduledoc "A guild forum channel (type 15)."
use Dexcord.Struct
discord_struct do
include_fields Dexcord.Model.ChannelShared
field :topic, :string
field :last_message_id, :snowflake
field :rate_limit_per_user, :integer
field :default_auto_archive_duration, :integer
field :default_thread_rate_limit_per_user, :integer
field :available_tags, {:list, {:struct, Dexcord.ForumTag}}, default: []
field :default_reaction_emoji, {:struct, Dexcord.DefaultReaction}
field :default_sort_order, {:enum, Dexcord.SortOrderType}
field :default_forum_layout, {:enum, Dexcord.ForumLayoutType}
end
end
defmodule Dexcord.MediaChannel do
@moduledoc "A guild media channel (type 16) — like a forum, without a layout mode."
use Dexcord.Struct
discord_struct do
include_fields Dexcord.Model.ChannelShared
field :topic, :string
field :last_message_id, :snowflake
field :rate_limit_per_user, :integer
field :default_auto_archive_duration, :integer
field :default_thread_rate_limit_per_user, :integer
field :available_tags, {:list, {:struct, Dexcord.ForumTag}}, default: []
field :default_reaction_emoji, {:struct, Dexcord.DefaultReaction}
field :default_sort_order, {:enum, Dexcord.SortOrderType}
end
end
defmodule Dexcord.DMChannel do
@moduledoc "A one-to-one DM channel (type 1). No guild fields."
use Dexcord.Struct
discord_struct do
field :id, :snowflake
field :type, {:enum, Dexcord.ChannelType}
field :flags, {:flags, Dexcord.ChannelFlags}
field :last_message_id, :snowflake
field :recipients, {:list, {:struct, Dexcord.User}}, default: []
field :last_pin_timestamp, :datetime
end
end
defmodule Dexcord.GroupDMChannel do
@moduledoc "A group DM channel (type 3)."
use Dexcord.Struct
discord_struct do
field :id, :snowflake
field :type, {:enum, Dexcord.ChannelType}
field :flags, {:flags, Dexcord.ChannelFlags}
field :last_message_id, :snowflake
field :recipients, {:list, {:struct, Dexcord.User}}, default: []
field :last_pin_timestamp, :datetime
field :name, :string
field :icon, :string
field :owner_id, :snowflake
field :application_id, :snowflake
field :managed, :boolean
end
end
defmodule Dexcord.Thread do
@moduledoc "A thread channel (types 10 announcement, 11 public, 12 private)."
use Dexcord.Struct
discord_struct do
field :id, :snowflake
field :type, {:enum, Dexcord.ChannelType}
field :guild_id, :snowflake
field :name, :string
field :flags, {:flags, Dexcord.ChannelFlags}
field :parent_id, :snowflake
field :owner_id, :snowflake
field :last_message_id, :snowflake
field :rate_limit_per_user, :integer
field :message_count, :integer
field :member_count, :integer
field :total_message_sent, :integer
field :thread_metadata, {:struct, Dexcord.ThreadMetadata}
field :member, {:struct, Dexcord.ThreadMember}
field :applied_tags, {:list, :snowflake}, default: []
field :last_pin_timestamp, :datetime
end
end
defmodule Dexcord.UnknownChannel do
@moduledoc "Fallback for channel 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 channels encodable.
def to_map(%__MODULE__{raw: raw}), do: raw
end
defmodule Dexcord.Channel do
@moduledoc """
Channel factory: decodes a wire channel map into the struct for its type.
Capability is struct identity a `%Dexcord.CategoryChannel{}` is not
Messageable; sending to it fails at match time, by design.
"""
@type_map %{
0 => Dexcord.TextChannel,
1 => Dexcord.DMChannel,
2 => Dexcord.VoiceChannel,
3 => Dexcord.GroupDMChannel,
4 => Dexcord.CategoryChannel,
5 => Dexcord.AnnouncementChannel,
10 => Dexcord.Thread,
11 => Dexcord.Thread,
12 => Dexcord.Thread,
13 => Dexcord.StageChannel,
14 => Dexcord.DirectoryChannel,
15 => Dexcord.ForumChannel,
16 => Dexcord.MediaChannel
}
@type t ::
Dexcord.TextChannel.t()
| Dexcord.DMChannel.t()
| Dexcord.VoiceChannel.t()
| Dexcord.GroupDMChannel.t()
| Dexcord.CategoryChannel.t()
| Dexcord.AnnouncementChannel.t()
| Dexcord.Thread.t()
| Dexcord.StageChannel.t()
| Dexcord.DirectoryChannel.t()
| Dexcord.ForumChannel.t()
| Dexcord.MediaChannel.t()
| Dexcord.UnknownChannel.t()
@doc "Decodes a wire channel 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.UnknownChannel.from_map(map)
def from_map(_), do: nil
@doc false
def __type_map__, do: @type_map
end

View file

@ -0,0 +1,124 @@
defmodule Dexcord.ModelChannelTest do
use ExUnit.Case, async: true
# Literal expectation table — deliberately NOT reusing
# Dexcord.Channel.__type_map__/0 (that would test the code with itself).
@expected %{
0 => Dexcord.TextChannel,
1 => Dexcord.DMChannel,
2 => Dexcord.VoiceChannel,
3 => Dexcord.GroupDMChannel,
4 => Dexcord.CategoryChannel,
5 => Dexcord.AnnouncementChannel,
10 => Dexcord.Thread,
11 => Dexcord.Thread,
12 => Dexcord.Thread,
13 => Dexcord.StageChannel,
14 => Dexcord.DirectoryChannel,
15 => Dexcord.ForumChannel,
16 => Dexcord.MediaChannel
}
setup_all do
{:ok, channels: Dexcord.Fixtures.load!("channel_types.json")}
end
describe "api-surface.AC2.14: factory dispatch on wire type" do
test "every documented type decodes to exactly its expected struct", %{channels: channels} do
# The fixture covers all 13 documented types, once each.
assert Enum.map(channels, & &1["type"]) |> Enum.sort() ==
Enum.sort(Map.keys(@expected))
for obj <- channels do
type = obj["type"]
expected = Map.fetch!(@expected, type)
decoded = Dexcord.Channel.from_map(obj)
assert decoded.__struct__ == expected,
"type #{type} decoded to #{inspect(decoded.__struct__)}, expected #{inspect(expected)}"
end
end
test "threads 10/11/12 all decode to %Dexcord.Thread{}", %{channels: channels} do
threads = Enum.filter(channels, &(&1["type"] in [10, 11, 12]))
assert length(threads) == 3
for obj <- threads do
assert %Dexcord.Thread{} = Dexcord.Channel.from_map(obj)
end
end
end
describe "unknown-type fallback" do
test "a recognized-shape map with an unknown type int preserves the raw payload" do
raw = %{"type" => 99, "id" => "1"}
assert %Dexcord.UnknownChannel{type: {:unknown, 99}, raw: ^raw} =
Dexcord.Channel.from_map(raw)
end
test "a map with no type field decodes to UnknownChannel with nil type" do
assert %Dexcord.UnknownChannel{type: nil} = Dexcord.Channel.from_map(%{"id" => "1"})
end
test "a non-map decodes to nil" do
assert Dexcord.Channel.from_map("junk") == nil
end
end
describe "field-depth decoding" do
test "forum available_tags decode to ForumTag structs with moderated set", %{
channels: channels
} do
forum = channel_of_type(channels, 15)
decoded = Dexcord.Channel.from_map(forum)
assert [%Dexcord.ForumTag{} = tag] = decoded.available_tags
assert tag.moderated == true
assert tag.name == "resolved"
end
test "thread metadata and member timestamps decode to DateTime", %{channels: channels} do
thread = channel_of_type(channels, 11)
decoded = Dexcord.Channel.from_map(thread)
assert %Dexcord.ThreadMetadata{} = decoded.thread_metadata
assert decoded.thread_metadata.archived == false
assert %Dexcord.ThreadMember{} = decoded.member
assert %DateTime{} = decoded.member.join_timestamp
end
test "text channel overwrites decode allow to an integer", %{channels: channels} do
text = channel_of_type(channels, 0)
decoded = Dexcord.Channel.from_map(text)
assert [%Dexcord.Overwrite{} = ow] = decoded.permission_overwrites
assert is_integer(ow.allow)
assert ow.allow == 1024
assert ow.deny == 2048
end
test "DM channel decodes its recipients to User structs", %{channels: channels} do
dm = channel_of_type(channels, 1)
decoded = Dexcord.Channel.from_map(dm)
assert [%Dexcord.User{} = user] = decoded.recipients
assert user.username == "Nelly"
end
end
describe "type field carries the enum atom" do
test "decoded structs carry type as the enum atom", %{channels: channels} do
by_type = Map.new(channels, &{&1["type"], Dexcord.Channel.from_map(&1)})
assert by_type[0].type == :guild_text
assert by_type[1].type == :dm
assert by_type[2].type == :guild_voice
assert by_type[11].type == :public_thread
assert by_type[15].type == :guild_forum
end
end
defp channel_of_type(channels, type),
do: Enum.find(channels, &(&1["type"] == type))
end

222
test/fixtures/channel_types.json vendored Normal file
View file

@ -0,0 +1,222 @@
[
{
"id": "100000000000000000",
"type": 0,
"guild_id": "900000000000000000",
"name": "general",
"position": 0,
"flags": 0,
"topic": "Welcome to the server",
"last_message_id": "100000000000000099",
"rate_limit_per_user": 5,
"nsfw": false,
"parent_id": "104000000000000000",
"default_auto_archive_duration": 1440,
"permission_overwrites": [
{
"id": "900000000000000000",
"type": 0,
"allow": "1024",
"deny": "2048"
}
]
},
{
"id": "101000000000000000",
"type": 1,
"flags": 0,
"last_message_id": "101000000000000099",
"recipients": [
{
"id": "800000000000000000",
"username": "Nelly",
"discriminator": "1337",
"global_name": "Nelly"
}
]
},
{
"id": "102000000000000000",
"type": 2,
"guild_id": "900000000000000000",
"name": "Voice Chat",
"position": 1,
"flags": 0,
"bitrate": 64000,
"user_limit": 10,
"rtc_region": null,
"video_quality_mode": 1,
"parent_id": "104000000000000000"
},
{
"id": "103000000000000000",
"type": 3,
"flags": 0,
"name": "Study Group",
"icon": "a1b2c3d4e5f6",
"owner_id": "800000000000000000",
"last_message_id": "103000000000000099",
"recipients": [
{
"id": "800000000000000000",
"username": "Nelly",
"discriminator": "1337"
},
{
"id": "800000000000000001",
"username": "Wumpus",
"discriminator": "0001"
}
]
},
{
"id": "104000000000000000",
"type": 4,
"guild_id": "900000000000000000",
"name": "Text Channels",
"position": 0,
"flags": 0
},
{
"id": "105000000000000000",
"type": 5,
"guild_id": "900000000000000000",
"name": "announcements",
"position": 2,
"flags": 0,
"topic": "Server announcements",
"last_message_id": "105000000000000099",
"nsfw": false
},
{
"id": "110000000000000000",
"type": 10,
"guild_id": "900000000000000000",
"name": "announcement-thread",
"flags": 0,
"parent_id": "105000000000000000",
"owner_id": "800000000000000000",
"message_count": 3,
"member_count": 2,
"total_message_sent": 3,
"thread_metadata": {
"archived": false,
"auto_archive_duration": 1440,
"archive_timestamp": "2021-01-01T00:00:00.000000+00:00",
"locked": false
}
},
{
"id": "111000000000000000",
"type": 11,
"guild_id": "900000000000000000",
"name": "public-thread",
"flags": 0,
"parent_id": "100000000000000000",
"owner_id": "800000000000000000",
"message_count": 12,
"member_count": 5,
"total_message_sent": 12,
"rate_limit_per_user": 0,
"applied_tags": ["150000000000000001"],
"thread_metadata": {
"archived": false,
"auto_archive_duration": 4320,
"archive_timestamp": "2021-02-01T00:00:00.000000+00:00",
"locked": false,
"invitable": true
},
"member": {
"id": "111000000000000000",
"user_id": "800000000000000000",
"join_timestamp": "2021-02-01T12:30:00.000000+00:00",
"flags": 1
}
},
{
"id": "112000000000000000",
"type": 12,
"guild_id": "900000000000000000",
"name": "private-thread",
"flags": 0,
"parent_id": "100000000000000000",
"owner_id": "800000000000000000",
"message_count": 1,
"member_count": 1,
"total_message_sent": 1,
"thread_metadata": {
"archived": true,
"auto_archive_duration": 10080,
"archive_timestamp": "2021-03-01T00:00:00.000000+00:00",
"locked": true,
"invitable": false
}
},
{
"id": "113000000000000000",
"type": 13,
"guild_id": "900000000000000000",
"name": "Stage",
"position": 3,
"flags": 0,
"bitrate": 40000,
"user_limit": 0,
"rtc_region": "us-east",
"video_quality_mode": 1
},
{
"id": "114000000000000000",
"type": 14,
"guild_id": "900000000000000000",
"name": "Server Directory",
"position": 4,
"flags": 0
},
{
"id": "115000000000000000",
"type": 15,
"guild_id": "900000000000000000",
"name": "help-forum",
"position": 5,
"flags": 16,
"topic": "Read the pinned guidelines before posting",
"last_message_id": "115000000000000099",
"rate_limit_per_user": 10,
"default_auto_archive_duration": 4320,
"default_sort_order": 0,
"default_forum_layout": 1,
"available_tags": [
{
"id": "150000000000000001",
"name": "resolved",
"moderated": true,
"emoji_id": null,
"emoji_name": "✅"
}
],
"default_reaction_emoji": {
"emoji_id": null,
"emoji_name": "👍"
}
},
{
"id": "116000000000000000",
"type": 16,
"guild_id": "900000000000000000",
"name": "gallery",
"position": 6,
"flags": 0,
"topic": "Share your art",
"default_auto_archive_duration": 1440,
"default_sort_order": 1,
"available_tags": [
{
"id": "160000000000000001",
"name": "sketch",
"moderated": false,
"emoji_id": null,
"emoji_name": "🎨"
}
]
}
]

9
test/support/fixtures.ex Normal file
View file

@ -0,0 +1,9 @@
defmodule Dexcord.Fixtures do
@moduledoc false
@fixtures_dir Path.expand("../fixtures", __DIR__)
def load!(name) do
@fixtures_dir |> Path.join(name) |> File.read!() |> JSON.decode!()
end
end