api-surface #1

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

feat(model): Guild family, VoiceState, Presence, golden GUILD_CREATE fixture

Luna 2026-07-05 04:10:47 -03:00

View file

@ -0,0 +1,87 @@
defmodule Dexcord.Guild do
@moduledoc "A guild. https://docs.discord.com/developers/resources/guild"
use Dexcord.Struct
discord_struct do
field :id, :snowflake
field :name, :string
field :icon, :string
field :splash, :string
field :discovery_splash, :string
field :owner_id, :snowflake
field :afk_channel_id, :snowflake
field :afk_timeout, :integer
field :widget_enabled, :boolean
field :widget_channel_id, :snowflake
field :verification_level, {:enum, Dexcord.VerificationLevel}
field :default_message_notifications, {:enum, Dexcord.DefaultMessageNotificationLevel}
field :explicit_content_filter, {:enum, Dexcord.ExplicitContentFilterLevel}
field :roles, {:list, {:struct, Dexcord.Role}}, default: []
field :emojis, {:list, {:struct, Dexcord.Emoji}}, default: []
field :features, {:list, :string}, default: []
field :mfa_level, {:enum, Dexcord.MfaLevel}
field :application_id, :snowflake
field :system_channel_id, :snowflake
field :system_channel_flags, {:flags, Dexcord.SystemChannelFlags}
field :rules_channel_id, :snowflake
field :max_presences, :integer
field :max_members, :integer
field :vanity_url_code, :string
field :description, :string
field :banner, :string
field :premium_tier, {:enum, Dexcord.PremiumTier}
field :premium_subscription_count, :integer
field :preferred_locale, :string
field :public_updates_channel_id, :snowflake
field :max_video_channel_users, :integer
field :max_stage_video_channel_users, :integer
field :approximate_member_count, :integer
field :approximate_presence_count, :integer
field :welcome_screen, :raw
field :nsfw_level, {:enum, Dexcord.GuildNsfwLevel}
field :stickers, {:list, {:struct, Dexcord.Sticker}}, default: []
field :premium_progress_bar_enabled, :boolean
field :safety_alerts_channel_id, :snowflake
field :incidents_data, :raw
# --- GUILD_CREATE gateway extension fields ---
field :joined_at, :datetime
field :large, :boolean
field :unavailable, :boolean, default: false
field :member_count, :integer
field :voice_states, {:list, {:struct, Dexcord.VoiceState}}, default: []
field :members, {:list, {:struct, Dexcord.Member}}, default: []
field :channels, {:list, {:struct, Dexcord.Channel}}, default: []
field :threads, {:list, {:struct, Dexcord.Channel}}, default: []
field :presences, {:list, {:struct, Dexcord.Presence}}, default: []
field :stage_instances, {:list, :raw}, default: []
field :guild_scheduled_events, {:list, :raw}, default: []
field :soundboard_sounds, {:list, :raw}, default: []
end
end
defmodule Dexcord.UnavailableGuild do
@moduledoc "READY / outage guild stub. On GUILD_DELETE, `unavailable` absent means the bot was removed."
use Dexcord.Struct
discord_struct do
field :id, :snowflake
field :unavailable, :presence
end
end
defmodule Dexcord.PartialGuild do
@moduledoc "The GET /users/@me/guilds list shape."
use Dexcord.Struct
discord_struct do
field :id, :snowflake
field :name, :string
field :icon, :string
field :banner, :string
field :owner, :boolean
field :permissions, {:flags, Dexcord.Permissions}, wire_string: true
field :features, {:list, :string}, default: []
field :approximate_member_count, :integer
field :approximate_presence_count, :integer
end
end

View file

@ -0,0 +1,23 @@
defmodule Dexcord.Presence do
@moduledoc "A presence update. `user` is raw — Discord guarantees only its `id` key."
use Dexcord.Struct
discord_struct do
field :user, :raw
field :guild_id, :snowflake
field :status, :string
field :activities, {:list, :raw}, default: []
field :client_status, {:struct, Dexcord.ClientStatus}
end
end
defmodule Dexcord.ClientStatus do
@moduledoc false
use Dexcord.Struct
discord_struct do
field :desktop, :string
field :mobile, :string
field :web, :string
end
end

View file

@ -0,0 +1,20 @@
defmodule Dexcord.VoiceState do
@moduledoc "A user's voice connection state. https://docs.discord.com/developers/resources/voice"
use Dexcord.Struct
discord_struct do
field :guild_id, :snowflake
field :channel_id, :snowflake
field :user_id, :snowflake
field :member, {:struct, Dexcord.Member}
field :session_id, :string
field :deaf, :boolean
field :mute, :boolean
field :self_deaf, :boolean
field :self_mute, :boolean
field :self_stream, :boolean, default: false
field :self_video, :boolean
field :suppress, :boolean
field :request_to_speak_timestamp, :datetime
end
end

View file

@ -0,0 +1,120 @@
defmodule Dexcord.ModelGuildTest do
use ExUnit.Case, async: true
setup_all do
{:ok, guild: Dexcord.Guild.from_map(Dexcord.Fixtures.load!("guild_create.json"))}
end
describe "api-surface.AC2.10: GUILD_CREATE decodes to a fully-typed %Dexcord.Guild{}" do
test "channels decode to the per-type struct for each wire type", %{guild: guild} do
assert [
%Dexcord.TextChannel{},
%Dexcord.VoiceChannel{},
%Dexcord.CategoryChannel{},
%Dexcord.ForumChannel{}
] = guild.channels
end
test "threads decode to %Dexcord.Thread{} with typed metadata", %{guild: guild} do
assert [%Dexcord.Thread{} = thread] = guild.threads
assert %Dexcord.ThreadMetadata{} = thread.thread_metadata
assert thread.thread_metadata.invitable == true
assert %Dexcord.ThreadMember{} = thread.member
end
test "roles decode typed, with the booster role's premium_subscriber tag true", %{
guild: guild
} do
assert [%Dexcord.Role{} = everyone, %Dexcord.Role{} = booster] = guild.roles
assert %Dexcord.RoleTags{} = booster.tags
assert booster.tags.premium_subscriber == true
# @everyone has no tags object at all.
assert everyone.tags == nil
end
test "members decode typed with nested %Dexcord.User{}", %{guild: guild} do
assert [%Dexcord.Member{} = first, %Dexcord.Member{} = second] = guild.members
assert %Dexcord.User{username: "Nelly"} = first.user
assert %Dexcord.User{username: "Wumpus"} = second.user
assert second.nick == "The Wumpus"
assert %DateTime{} = second.premium_since
end
test "joined_at is a DateTime and member_count is an integer", %{guild: guild} do
assert %DateTime{} = guild.joined_at
assert guild.member_count == 3
end
test "voice_states decode typed, with guild_id nil (GUILD_CREATE omits it)", %{guild: guild} do
assert [%Dexcord.VoiceState{} = vs] = guild.voice_states
assert vs.guild_id == nil
assert vs.user_id == 800_000_000_000_000_000
assert vs.self_mute == true
end
test "presences decode typed with a raw user map and typed client_status", %{guild: guild} do
assert [%Dexcord.Presence{} = presence] = guild.presences
assert is_map(presence.user)
refute is_struct(presence.user)
assert presence.user["id"] == "800000000000000000"
assert %Dexcord.ClientStatus{web: "online"} = presence.client_status
end
test "guild core scalar/enum/flag fields decode", %{guild: guild} do
assert guild.id == 900_000_000_000_000_000
assert guild.name == "Dexcord Test Guild"
assert guild.verification_level == :medium
assert guild.premium_tier == :tier_2
assert guild.system_channel_flags == 5
assert guild.features == ["COMMUNITY", "NEWS", "WELCOME_SCREEN_ENABLED"]
assert guild.large == false
assert guild.unavailable == false
end
test "roles/emojis/stickers decode to their typed structs", %{guild: guild} do
assert [%Dexcord.Emoji{name: "wumpus"}] = guild.emojis
assert [%Dexcord.Sticker{name: "hello"}] = guild.stickers
end
end
describe "Dexcord.UnavailableGuild" do
test "unavailable true when the key is present" do
assert %Dexcord.UnavailableGuild{unavailable: true} =
Dexcord.UnavailableGuild.from_map(%{"id" => "1", "unavailable" => true})
end
test "unavailable false when the key is absent (GUILD_DELETE removed-from-guild signal)" do
guild = Dexcord.UnavailableGuild.from_map(%{"id" => "1"})
assert guild.unavailable == false
assert guild.id == 1
end
end
describe "Dexcord.PartialGuild" do
test "permissions string decodes to an integer and owner is a boolean" do
partial =
Dexcord.PartialGuild.from_map(%{
"id" => "42",
"name" => "My Guild",
"owner" => true,
"permissions" => "2147483647",
"features" => ["COMMUNITY"]
})
assert partial.owner == true
assert partial.permissions == 2_147_483_647
assert partial.features == ["COMMUNITY"]
end
end
describe "Guild.merge_map/2 (feeds Phase 6 AC2.21)" do
test "a partial update touches only the present key", %{guild: guild} do
merged = Dexcord.Guild.merge_map(guild, %{"name" => "renamed"})
assert merged.name == "renamed"
assert merged.joined_at == guild.joined_at
assert merged.channels == guild.channels
assert merged.members == guild.members
end
end
end

256
test/fixtures/guild_create.json vendored Normal file
View file

@ -0,0 +1,256 @@
{
"id": "900000000000000000",
"name": "Dexcord Test Guild",
"icon": "abc123icon",
"splash": null,
"discovery_splash": null,
"owner_id": "800000000000000000",
"afk_channel_id": null,
"afk_timeout": 300,
"widget_enabled": true,
"widget_channel_id": null,
"verification_level": 2,
"default_message_notifications": 1,
"explicit_content_filter": 2,
"features": ["COMMUNITY", "NEWS", "WELCOME_SCREEN_ENABLED"],
"mfa_level": 1,
"application_id": null,
"system_channel_id": "100000000000000000",
"system_channel_flags": 5,
"rules_channel_id": null,
"max_presences": null,
"max_members": 250000,
"vanity_url_code": null,
"description": "A guild for exercising the decoder.",
"banner": null,
"premium_tier": 2,
"premium_subscription_count": 7,
"preferred_locale": "en-US",
"public_updates_channel_id": null,
"max_video_channel_users": 25,
"max_stage_video_channel_users": 50,
"nsfw_level": 0,
"premium_progress_bar_enabled": true,
"safety_alerts_channel_id": null,
"roles": [
{
"id": "900000000000000000",
"name": "@everyone",
"color": 0,
"hoist": false,
"position": 0,
"permissions": "104324673",
"managed": false,
"mentionable": false,
"flags": 0
},
{
"id": "901000000000000001",
"name": "Server Booster",
"color": 16038978,
"hoist": true,
"position": 3,
"permissions": "1071698660928",
"managed": true,
"mentionable": false,
"flags": 0,
"tags": {
"premium_subscriber": null
}
}
],
"emojis": [
{
"id": "950000000000000001",
"name": "wumpus",
"roles": [],
"require_colons": true,
"managed": false,
"animated": false,
"available": true
}
],
"stickers": [
{
"id": "960000000000000001",
"name": "hello",
"description": "a waving sticker",
"tags": "wave",
"type": 2,
"format_type": 1,
"available": true,
"guild_id": "900000000000000000",
"sort_value": 0
}
],
"joined_at": "2021-05-01T12:00:00.000000+00:00",
"large": false,
"unavailable": false,
"member_count": 3,
"members": [
{
"user": {
"id": "800000000000000000",
"username": "Nelly",
"discriminator": "1337",
"global_name": "Nelly"
},
"nick": null,
"roles": ["901000000000000001"],
"joined_at": "2021-05-01T12:00:00.000000+00:00",
"deaf": false,
"mute": false,
"flags": 0
},
{
"user": {
"id": "800000000000000001",
"username": "Wumpus",
"discriminator": "0001",
"global_name": "Wumpus",
"bot": true
},
"nick": "The Wumpus",
"roles": [],
"joined_at": "2021-05-02T09:15:00.000000+00:00",
"premium_since": "2021-06-01T00:00:00.000000+00:00",
"deaf": false,
"mute": false,
"flags": 0
}
],
"channels": [
{
"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",
"permission_overwrites": [
{
"id": "900000000000000000",
"type": 0,
"allow": "1024",
"deny": "2048"
}
]
},
{
"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": "104000000000000000",
"type": 4,
"guild_id": "900000000000000000",
"name": "Text Channels",
"position": 0,
"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": "👍"
}
}
],
"threads": [
{
"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
}
}
],
"voice_states": [
{
"channel_id": "102000000000000000",
"user_id": "800000000000000000",
"session_id": "abc123session",
"deaf": false,
"mute": false,
"self_deaf": false,
"self_mute": true,
"self_video": false,
"suppress": false,
"request_to_speak_timestamp": null
}
],
"presences": [
{
"user": {
"id": "800000000000000000"
},
"status": "online",
"activities": [
{
"name": "Visual Studio Code",
"type": 0
}
],
"client_status": {
"web": "online"
}
}
],
"stage_instances": [],
"guild_scheduled_events": [],
"soundboard_sounds": []
}