api-surface #1

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

feat(model): Message + reference/snapshot/call satellites, golden message fixtures

Luna 2026-07-05 04:05:29 -03:00

View file

@ -0,0 +1,91 @@
defmodule Dexcord.Message do
@moduledoc "A Discord message. https://docs.discord.com/developers/resources/message"
use Dexcord.Struct
discord_struct do
field :id, :snowflake
field :channel_id, :snowflake
field :guild_id, :snowflake
field :author, {:struct, Dexcord.User}
field :member, {:struct, Dexcord.PartialMember}
field :content, :string
field :timestamp, :datetime
field :edited_timestamp, :datetime
field :tts, :boolean
field :mention_everyone, :boolean
field :mentions, {:list, {:struct, Dexcord.User}}, default: []
field :mention_roles, {:list, :snowflake}, default: []
field :mention_channels, {:list, {:struct, Dexcord.ChannelMention}}, default: []
field :attachments, {:list, {:struct, Dexcord.Attachment}}, default: []
field :embeds, {:list, {:struct, Dexcord.Embed}}, default: []
field :reactions, {:list, {:struct, Dexcord.Reaction}}, default: []
field :nonce, :raw
field :pinned, :boolean
field :webhook_id, :snowflake
field :type, {:enum, Dexcord.MessageType}
field :channel_type, :integer
field :activity, :raw
field :application, :raw
field :application_id, :snowflake
field :flags, {:flags, Dexcord.MessageFlags}
field :message_reference, {:struct, Dexcord.MessageReference}
field :message_snapshots, {:list, {:struct, Dexcord.MessageSnapshot}}
field :referenced_message, {:struct, __MODULE__}, tristate: true
field :interaction_metadata, :raw
field :interaction, :raw
field :thread, {:struct, Dexcord.Channel}
field :components, :raw
field :sticker_items, {:list, {:struct, Dexcord.StickerItem}}, default: []
field :stickers, {:list, {:struct, Dexcord.Sticker}}
field :position, :integer
field :role_subscription_data, :raw
field :resolved, :raw
field :poll, :raw
field :call, {:struct, Dexcord.MessageCall}
field :shared_client_theme, :raw
end
end
defmodule Dexcord.MessageReference do
@moduledoc false
use Dexcord.Struct
discord_struct do
field :type, {:enum, Dexcord.MessageReferenceType}
field :message_id, :snowflake
field :channel_id, :snowflake
field :guild_id, :snowflake
field :fail_if_not_exists, :boolean
end
end
defmodule Dexcord.MessageSnapshot do
@moduledoc "A forwarded-message snapshot (partial message; no author)."
use Dexcord.Struct
discord_struct do
field :message, {:struct, Dexcord.Message}
end
end
defmodule Dexcord.MessageCall do
@moduledoc false
use Dexcord.Struct
discord_struct do
field :participants, {:list, :snowflake}, default: []
field :ended_timestamp, :datetime
end
end
defmodule Dexcord.ChannelMention do
@moduledoc false
use Dexcord.Struct
discord_struct do
field :id, :snowflake
field :guild_id, :snowflake
field :type, {:enum, Dexcord.ChannelType}
field :name, :string
end
end

View file

@ -0,0 +1,168 @@
defmodule Dexcord.ModelMessageTest do
use ExUnit.Case, async: true
describe "api-surface.AC2.11: MESSAGE_CREATE member-without-user + full author" do
setup do
{:ok, msg: Dexcord.Message.from_map(Dexcord.Fixtures.load!("message_create_guild.json"))}
end
test "member decodes to a %PartialMember{} with nick/roles/joined_at", %{msg: msg} do
assert %Dexcord.PartialMember{} = msg.member
assert msg.member.nick == "The Big N"
assert msg.member.roles == [197_038_439_483_310_087, 197_038_439_483_310_088]
assert %DateTime{} = msg.member.joined_at
# PartialMember genuinely has no :user field.
refute Map.has_key?(Map.from_struct(msg.member), :user)
end
test "author decodes to a full %User{}", %{msg: msg} do
assert %Dexcord.User{} = msg.author
assert msg.author.id == 80_351_110_224_678_912
assert msg.author.username == "Nelly"
end
test "mentions decode to %User{} list, ignoring the embedded member key", %{msg: msg} do
assert [%Dexcord.User{} = mentioned] = msg.mentions
assert mentioned.id == 104_937_225_384_493_056
assert mentioned.username == "friend"
# The per-mention embedded partial member is ignored (unknown-key tolerance).
refute Map.has_key?(Map.from_struct(mentioned), :member)
end
test "mention_roles decode to snowflake integers", %{msg: msg} do
assert msg.mention_roles == [197_038_439_483_310_088]
end
test "attachments and embeds decode to typed structs", %{msg: msg} do
assert [%Dexcord.Attachment{filename: "cat.png"}] = msg.attachments
assert [%Dexcord.Embed{title: "An Embed"}] = msg.embeds
end
end
describe "api-surface.AC2.12: webhook-authored message" do
setup do
{:ok, msg: Dexcord.Message.from_map(Dexcord.Fixtures.load!("message_webhook.json"))}
end
test "author is a synthetic %User{} whose id equals webhook_id", %{msg: msg} do
assert %Dexcord.User{} = msg.author
assert msg.author.id == msg.webhook_id
assert msg.webhook_id == 223_704_706_495_545_344
end
test "there is no member", %{msg: msg} do
assert msg.member == nil
end
test "application_id decodes to a snowflake", %{msg: msg} do
assert msg.application_id == 223_704_706_495_545_344
end
test "decode does not raise", %{msg: msg} do
assert %Dexcord.Message{} = msg
end
end
describe "api-surface.AC2.13: referenced_message tristate" do
test "present-null referenced_message decodes to nil (deleted reply)" do
map = Dexcord.Fixtures.load!("message_reply_deleted.json")
msg = Dexcord.Message.from_map(map)
assert msg.referenced_message == nil
assert msg.type == :reply
assert %Dexcord.MessageReference{} = msg.message_reference
assert msg.message_reference.message_id == 1_052_425_005_447_712_818
end
test "absent referenced_message decodes to :absent" do
map =
"message_reply_deleted.json"
|> Dexcord.Fixtures.load!()
|> Map.delete("referenced_message")
msg = Dexcord.Message.from_map(map)
assert msg.referenced_message == :absent
end
test "present referenced_message decodes to a self-nested %Message{}" do
map =
"message_reply_deleted.json"
|> Dexcord.Fixtures.load!()
|> Map.put("referenced_message", %{
"id" => "1052425005447712818",
"channel_id" => "155361364909801472",
"author" => %{"id" => "80351110224678912", "username" => "Nelly"},
"content" => "the original message",
"type" => 0
})
msg = Dexcord.Message.from_map(map)
assert %Dexcord.Message{} = msg.referenced_message
assert msg.referenced_message.content == "the original message"
assert %Dexcord.User{} = msg.referenced_message.author
end
end
describe "DM message" do
setup do
{:ok, msg: Dexcord.Message.from_map(Dexcord.Fixtures.load!("message_dm.json"))}
end
test "has no guild_id and no member, channel_type 1", %{msg: msg} do
assert msg.guild_id == nil
assert msg.member == nil
assert msg.channel_type == 1
end
end
describe "scalar decoding" do
setup do
{:ok, msg: Dexcord.Message.from_map(Dexcord.Fixtures.load!("message_create_guild.json"))}
end
test "timestamp decodes to %DateTime{}", %{msg: msg} do
assert %DateTime{} = msg.timestamp
end
test "default-type message decodes type to :default atom", %{msg: msg} do
assert msg.type == :default
end
end
describe "satellites" do
test "MessageCall decodes participants and ended_timestamp" do
call =
Dexcord.MessageCall.from_map(%{
"participants" => ["80351110224678912", "104937225384493056"],
"ended_timestamp" => "2022-12-14T09:00:00.000000+00:00"
})
assert call.participants == [80_351_110_224_678_912, 104_937_225_384_493_056]
assert %DateTime{} = call.ended_timestamp
end
test "MessageSnapshot wraps a partial (author-less) message" do
snap =
Dexcord.MessageSnapshot.from_map(%{
"message" => %{"content" => "forwarded content", "type" => 0}
})
assert %Dexcord.Message{} = snap.message
assert snap.message.content == "forwarded content"
assert snap.message.author == nil
end
test "ChannelMention decodes type to the enum atom" do
cm =
Dexcord.ChannelMention.from_map(%{
"id" => "155361364909801472",
"guild_id" => "197038439483310086",
"type" => 0,
"name" => "general"
})
assert cm.type == :guild_text
assert cm.id == 155_361_364_909_801_472
end
end
end

70
test/fixtures/message_create_guild.json vendored Normal file
View file

@ -0,0 +1,70 @@
{
"id": "1052425005447712818",
"channel_id": "155361364909801472",
"guild_id": "197038439483310086",
"author": {
"id": "80351110224678912",
"username": "Nelly",
"discriminator": "1337",
"global_name": "Nelly",
"avatar": "8342729096ea3675442027381ff50dfe",
"bot": false,
"public_flags": 131072
},
"member": {
"nick": "The Big N",
"roles": ["197038439483310087", "197038439483310088"],
"joined_at": "2021-04-11T15:00:00.000000+00:00",
"deaf": false,
"mute": false,
"flags": 0,
"pending": false
},
"content": "Hey <@104937225384493056> check this out",
"timestamp": "2022-12-14T05:06:00.000000+00:00",
"edited_timestamp": null,
"tts": false,
"mention_everyone": false,
"mentions": [
{
"id": "104937225384493056",
"username": "friend",
"discriminator": "0001",
"global_name": "Friend",
"avatar": null,
"member": {
"nick": "buddy",
"roles": ["197038439483310087"],
"joined_at": "2021-05-01T00:00:00.000000+00:00"
}
}
],
"mention_roles": ["197038439483310088"],
"attachments": [
{
"id": "1052425005447712819",
"filename": "cat.png",
"size": 45678,
"url": "https://cdn.discordapp.com/attachments/1/2/cat.png",
"proxy_url": "https://media.discordapp.net/attachments/1/2/cat.png",
"height": 512,
"width": 512,
"content_type": "image/png"
}
],
"embeds": [
{
"title": "An Embed",
"type": "rich",
"description": "A described embed",
"color": 5814783,
"timestamp": "2022-12-14T05:00:00.000000+00:00"
}
],
"reactions": [],
"nonce": "1052425005447712818",
"pinned": false,
"type": 0,
"channel_type": 0,
"flags": 0
}

24
test/fixtures/message_dm.json vendored Normal file
View file

@ -0,0 +1,24 @@
{
"id": "1052425005447713100",
"channel_id": "319674150115823165",
"author": {
"id": "80351110224678912",
"username": "Nelly",
"discriminator": "1337",
"global_name": "Nelly",
"avatar": "8342729096ea3675442027381ff50dfe"
},
"content": "a private message",
"timestamp": "2022-12-14T08:00:00.000000+00:00",
"edited_timestamp": null,
"tts": false,
"mention_everyone": false,
"mentions": [],
"mention_roles": [],
"attachments": [],
"embeds": [],
"pinned": false,
"type": 0,
"channel_type": 1,
"flags": 0
}

View file

@ -0,0 +1,31 @@
{
"id": "1052425005447713000",
"channel_id": "155361364909801472",
"guild_id": "197038439483310086",
"author": {
"id": "80351110224678912",
"username": "Nelly",
"discriminator": "1337",
"global_name": "Nelly",
"avatar": "8342729096ea3675442027381ff50dfe"
},
"content": "replying to a message that got deleted",
"timestamp": "2022-12-14T07:00:00.000000+00:00",
"edited_timestamp": null,
"tts": false,
"mention_everyone": false,
"mentions": [],
"mention_roles": [],
"attachments": [],
"embeds": [],
"pinned": false,
"type": 19,
"flags": 0,
"message_reference": {
"type": 0,
"message_id": "1052425005447712818",
"channel_id": "155361364909801472",
"guild_id": "197038439483310086"
},
"referenced_message": null
}

27
test/fixtures/message_webhook.json vendored Normal file
View file

@ -0,0 +1,27 @@
{
"id": "1052425005447712900",
"channel_id": "155361364909801472",
"guild_id": "197038439483310086",
"webhook_id": "223704706495545344",
"author": {
"id": "223704706495545344",
"username": "GitHub",
"discriminator": "0000",
"avatar": null,
"bot": true
},
"content": "A new commit was pushed",
"timestamp": "2022-12-14T06:00:00.000000+00:00",
"edited_timestamp": null,
"tts": false,
"mention_everyone": false,
"mentions": [],
"mention_roles": [],
"attachments": [],
"embeds": [],
"reactions": [],
"pinned": false,
"type": 0,
"application_id": "223704706495545344",
"flags": 0
}