From 19253f6b7bbd72b5c9850d9a18a8ab98933c306e Mon Sep 17 00:00:00 2001 From: Qjuh <76154676+Qjuh@users.noreply.github.com> Date: Fri, 28 Nov 2025 17:52:42 +0100 Subject: [PATCH] feat: message structures (#10982) * feat: message structures * fix: docs * chore: components and more * feat: embed and more * feat: more substructures and code review suggestions * chore: tests and date conversions * chore: jsdoc strings * fix: tests * fix: tests * feat: hexColor getters * chore: remove getters for nested data * chore: apply suggestions from code review * fix: burst_colors in toJSON * docs: rephrase SectionBuilder remark * chore: add LabelComponent * fix: add name and size to file component * chore: move resolved interaction data to interactions dir * fix: code review * chore: bump discord-api-types * chore: apply code review suggestions * fix: lockfile * chore: update remark * fix: missing export * chore: code review and tests * build: fix file * fix: typo * fix: missing toJSON * fix: remove redundant patch overrides * chore: missing component suffix * chore: better name * chore: add comment explaining timestamp conversion --------- Co-authored-by: Jiralite <33201955+Jiralite@users.noreply.github.com> --- .../structures/__tests__/channels.test.ts | 2 +- packages/structures/__tests__/invite.test.ts | 17 +- packages/structures/__tests__/message.test.ts | 478 ++++++++++++++++++ .../__tests__/types/Mixin.test-d.ts | 25 +- .../__tests__/types/channels.test-d.ts | 48 +- packages/structures/package.json | 4 - .../src/bitfields/AttachmentFlagsBitField.ts | 16 + .../src/bitfields/MessageFlagsBitField.ts | 16 + packages/structures/src/bitfields/index.ts | 2 + packages/structures/src/channels/Channel.ts | 11 +- packages/structures/src/index.ts | 4 + .../interactions/ResolvedInteractionData.ts | 20 + packages/structures/src/interactions/index.ts | 1 + packages/structures/src/invites/Invite.ts | 17 +- .../ApplicationCommandInteractionMetadata.ts | 33 ++ .../structures/src/messages/Attachment.ts | 118 +++++ .../structures/src/messages/ChannelMention.ts | 54 ++ .../src/messages/InteractionMetadata.ts | 48 ++ packages/structures/src/messages/Message.ts | 178 +++++++ .../src/messages/MessageActivity.ts | 35 ++ .../structures/src/messages/MessageCall.ts | 65 +++ .../MessageComponentInteractionMetadata.ts | 32 ++ .../src/messages/MessageReference.ts | 54 ++ .../ModalSubmitInteractionMetadata.ts | 25 + packages/structures/src/messages/Reaction.ts | 80 +++ .../src/messages/ReactionCountDetails.ts | 40 ++ .../src/messages/RoleSubscriptionData.ts | 48 ++ .../messages/components/ActionRowComponent.ts | 27 + .../messages/components/ButtonComponent.ts | 41 ++ .../components/ChannelSelectMenuComponent.ts | 33 ++ .../src/messages/components/Component.ts | 36 ++ .../src/messages/components/ComponentEmoji.ts | 42 ++ .../messages/components/ContainerComponent.ts | 50 ++ .../src/messages/components/FileComponent.ts | 48 ++ .../components/FileUploadComponent.ts | 54 ++ .../components/InteractiveButtonComponent.ts | 34 ++ .../src/messages/components/LabelComponent.ts | 42 ++ .../components/LabeledButtonComponent.ts | 30 ++ .../components/LinkButtonComponent.ts | 32 ++ .../components/MediaGalleryComponent.ts | 26 + .../messages/components/MediaGalleryItem.ts | 41 ++ .../MentionableSelectMenuComponent.ts | 25 + .../components/PremiumButtonComponent.ts | 32 ++ .../components/RoleSelectMenuComponent.ts | 25 + .../messages/components/SectionComponent.ts | 26 + .../components/SelectMenuComponent.ts | 58 +++ .../components/SelectMenuDefaultValue.ts | 29 ++ .../messages/components/SeparatorComponent.ts | 40 ++ .../components/StringSelectMenuComponent.ts | 25 + .../components/StringSelectMenuOption.ts | 55 ++ .../components/TextDisplayComponent.ts | 33 ++ .../messages/components/TextInputComponent.ts | 82 +++ .../messages/components/ThumbnailComponent.ts | 41 ++ .../messages/components/UnfurledMediaItem.ts | 70 +++ .../components/UserSelectMenuComponent.ts | 25 + .../src/messages/components/index.ts | 28 + .../structures/src/messages/embeds/Embed.ts | 104 ++++ .../src/messages/embeds/EmbedAuthor.ts | 46 ++ .../src/messages/embeds/EmbedField.ts | 39 ++ .../src/messages/embeds/EmbedFooter.ts | 39 ++ .../src/messages/embeds/EmbedImage.ts | 46 ++ .../src/messages/embeds/EmbedProvider.ts | 35 ++ .../src/messages/embeds/EmbedThumbnail.ts | 49 ++ .../src/messages/embeds/EmbedVideo.ts | 46 ++ .../structures/src/messages/embeds/index.ts | 8 + packages/structures/src/messages/index.ts | 16 + packages/structures/src/polls/Poll.ts | 87 ++++ packages/structures/src/polls/PollAnswer.ts | 31 ++ .../structures/src/polls/PollAnswerCount.ts | 47 ++ packages/structures/src/polls/PollMedia.ts | 31 ++ packages/structures/src/polls/PollResults.ts | 31 ++ packages/structures/src/polls/index.ts | 5 + packages/structures/src/stickers/Sticker.ts | 72 +++ packages/structures/src/stickers/index.ts | 1 + packages/structures/src/users/Connection.ts | 11 +- packages/structures/src/users/User.ts | 11 +- packages/structures/src/utils/optimization.ts | 12 + packages/structures/src/utils/symbols.ts | 3 + pnpm-lock.yaml | 3 - 79 files changed, 3281 insertions(+), 93 deletions(-) create mode 100644 packages/structures/__tests__/message.test.ts create mode 100644 packages/structures/src/bitfields/AttachmentFlagsBitField.ts create mode 100644 packages/structures/src/bitfields/MessageFlagsBitField.ts create mode 100644 packages/structures/src/interactions/ResolvedInteractionData.ts create mode 100644 packages/structures/src/interactions/index.ts create mode 100644 packages/structures/src/messages/ApplicationCommandInteractionMetadata.ts create mode 100644 packages/structures/src/messages/Attachment.ts create mode 100644 packages/structures/src/messages/ChannelMention.ts create mode 100644 packages/structures/src/messages/InteractionMetadata.ts create mode 100644 packages/structures/src/messages/Message.ts create mode 100644 packages/structures/src/messages/MessageActivity.ts create mode 100644 packages/structures/src/messages/MessageCall.ts create mode 100644 packages/structures/src/messages/MessageComponentInteractionMetadata.ts create mode 100644 packages/structures/src/messages/MessageReference.ts create mode 100644 packages/structures/src/messages/ModalSubmitInteractionMetadata.ts create mode 100644 packages/structures/src/messages/Reaction.ts create mode 100644 packages/structures/src/messages/ReactionCountDetails.ts create mode 100644 packages/structures/src/messages/RoleSubscriptionData.ts create mode 100644 packages/structures/src/messages/components/ActionRowComponent.ts create mode 100644 packages/structures/src/messages/components/ButtonComponent.ts create mode 100644 packages/structures/src/messages/components/ChannelSelectMenuComponent.ts create mode 100644 packages/structures/src/messages/components/Component.ts create mode 100644 packages/structures/src/messages/components/ComponentEmoji.ts create mode 100644 packages/structures/src/messages/components/ContainerComponent.ts create mode 100644 packages/structures/src/messages/components/FileComponent.ts create mode 100644 packages/structures/src/messages/components/FileUploadComponent.ts create mode 100644 packages/structures/src/messages/components/InteractiveButtonComponent.ts create mode 100644 packages/structures/src/messages/components/LabelComponent.ts create mode 100644 packages/structures/src/messages/components/LabeledButtonComponent.ts create mode 100644 packages/structures/src/messages/components/LinkButtonComponent.ts create mode 100644 packages/structures/src/messages/components/MediaGalleryComponent.ts create mode 100644 packages/structures/src/messages/components/MediaGalleryItem.ts create mode 100644 packages/structures/src/messages/components/MentionableSelectMenuComponent.ts create mode 100644 packages/structures/src/messages/components/PremiumButtonComponent.ts create mode 100644 packages/structures/src/messages/components/RoleSelectMenuComponent.ts create mode 100644 packages/structures/src/messages/components/SectionComponent.ts create mode 100644 packages/structures/src/messages/components/SelectMenuComponent.ts create mode 100644 packages/structures/src/messages/components/SelectMenuDefaultValue.ts create mode 100644 packages/structures/src/messages/components/SeparatorComponent.ts create mode 100644 packages/structures/src/messages/components/StringSelectMenuComponent.ts create mode 100644 packages/structures/src/messages/components/StringSelectMenuOption.ts create mode 100644 packages/structures/src/messages/components/TextDisplayComponent.ts create mode 100644 packages/structures/src/messages/components/TextInputComponent.ts create mode 100644 packages/structures/src/messages/components/ThumbnailComponent.ts create mode 100644 packages/structures/src/messages/components/UnfurledMediaItem.ts create mode 100644 packages/structures/src/messages/components/UserSelectMenuComponent.ts create mode 100644 packages/structures/src/messages/components/index.ts create mode 100644 packages/structures/src/messages/embeds/Embed.ts create mode 100644 packages/structures/src/messages/embeds/EmbedAuthor.ts create mode 100644 packages/structures/src/messages/embeds/EmbedField.ts create mode 100644 packages/structures/src/messages/embeds/EmbedFooter.ts create mode 100644 packages/structures/src/messages/embeds/EmbedImage.ts create mode 100644 packages/structures/src/messages/embeds/EmbedProvider.ts create mode 100644 packages/structures/src/messages/embeds/EmbedThumbnail.ts create mode 100644 packages/structures/src/messages/embeds/EmbedVideo.ts create mode 100644 packages/structures/src/messages/embeds/index.ts create mode 100644 packages/structures/src/messages/index.ts create mode 100644 packages/structures/src/polls/Poll.ts create mode 100644 packages/structures/src/polls/PollAnswer.ts create mode 100644 packages/structures/src/polls/PollAnswerCount.ts create mode 100644 packages/structures/src/polls/PollMedia.ts create mode 100644 packages/structures/src/polls/PollResults.ts create mode 100644 packages/structures/src/polls/index.ts create mode 100644 packages/structures/src/stickers/Sticker.ts create mode 100644 packages/structures/src/stickers/index.ts diff --git a/packages/structures/__tests__/channels.test.ts b/packages/structures/__tests__/channels.test.ts index 647f796ea..f81474c65 100644 --- a/packages/structures/__tests__/channels.test.ts +++ b/packages/structures/__tests__/channels.test.ts @@ -38,7 +38,7 @@ import { TextChannel, ThreadMetadata, VoiceChannel, -} from '../src/index.js'; +} from '../src/channels/index.js'; import { kData } from '../src/utils/symbols.js'; describe('text channel', () => { diff --git a/packages/structures/__tests__/invite.test.ts b/packages/structures/__tests__/invite.test.ts index ec8407c05..9cf1307c7 100644 --- a/packages/structures/__tests__/invite.test.ts +++ b/packages/structures/__tests__/invite.test.ts @@ -1,7 +1,8 @@ import type { APIExtendedInvite, APIInvite } from 'discord-api-types/v10'; import { InviteTargetType, InviteType } from 'discord-api-types/v10'; import { describe, expect, test } from 'vitest'; -import { Invite } from '../src/index.js'; +import { Invite } from '../src/invites/Invite.js'; +import { dateToDiscordISOTimestamp } from '../src/utils/optimization.js'; import { kPatch } from '../src/utils/symbols.js'; describe('Invite', () => { @@ -22,7 +23,7 @@ describe('Invite', () => { const dataExtended: Omit = { ...data, - created_at: '2020-10-10T13:50:17.209Z', + created_at: '2020-10-10T13:50:17.209000+00:00', max_age: 12, max_uses: 34, temporary: false, @@ -54,7 +55,7 @@ describe('Invite', () => { const instance = new Invite(dataExtended); expect(instance.type).toBe(data.type); expect(instance.code).toBe(dataExtended.code); - expect(instance.createdAt?.toISOString()).toBe(dataExtended.created_at); + expect(dateToDiscordISOTimestamp(instance.createdAt!)).toBe(dataExtended.created_at); expect(instance.createdTimestamp).toBe(Date.parse(dataExtended.created_at)); expect(instance.maxAge).toBe(dataExtended.max_age); expect(instance.maxUses).toBe(dataExtended.max_uses); @@ -63,10 +64,10 @@ describe('Invite', () => { expect(instance.targetType).toBe(dataExtended.target_type); expect(instance.temporary).toBe(dataExtended.temporary); expect(instance.uses).toBe(dataExtended.uses); - expect(instance.expiresTimestamp).toStrictEqual(Date.parse('2020-10-10T13:50:29.209Z')); - expect(instance.expiresAt).toStrictEqual(new Date('2020-10-10T13:50:29.209Z')); + expect(instance.expiresTimestamp).toStrictEqual(Date.parse('2020-10-10T13:50:29.209000+00:00')); + expect(instance.expiresAt).toStrictEqual(new Date('2020-10-10T13:50:29.209000+00:00')); expect(instance.url).toBe('https://discord.gg/123'); - expect(instance.toJSON()).toEqual({ ...dataExtended, expires_at: '2020-10-10T13:50:29.209Z' }); + expect(instance.toJSON()).toEqual({ ...dataExtended, expires_at: '2020-10-10T13:50:29.209000+00:00' }); }); test('Invite with omitted properties', () => { @@ -79,8 +80,8 @@ describe('Invite', () => { }); test('Invite with expiration', () => { - const instance = new Invite({ ...dataExtended, expires_at: '2020-10-10T13:50:29.209Z' }); - expect(instance.toJSON()).toEqual({ ...dataExtended, expires_at: '2020-10-10T13:50:29.209Z' }); + const instance = new Invite({ ...dataExtended, expires_at: '2020-10-10T13:50:29.209000+00:00' }); + expect(instance.toJSON()).toEqual({ ...dataExtended, expires_at: '2020-10-10T13:50:29.209000+00:00' }); }); test('Patching Invite works in place', () => { diff --git a/packages/structures/__tests__/message.test.ts b/packages/structures/__tests__/message.test.ts new file mode 100644 index 000000000..5c739f7bd --- /dev/null +++ b/packages/structures/__tests__/message.test.ts @@ -0,0 +1,478 @@ +import { DiscordSnowflake } from '@sapphire/snowflake'; +import type { + APIActionRowComponent, + APIButtonComponent, + APIChannelSelectComponent, + APIContainerComponent, + APIFileComponent, + APIMediaGalleryComponent, + APIMentionableSelectComponent, + APIMessage, + APIRoleSelectComponent, + APISectionComponent, + APISeparatorComponent, + APIStringSelectComponent, + APIUser, + APIUserSelectComponent, +} from 'discord-api-types/v10'; +import { + MessageReferenceType, + MessageType, + MessageFlags, + ComponentType, + ButtonStyle, + SeparatorSpacingSize, + ChannelType, + SelectMenuDefaultValueType, +} from 'discord-api-types/v10'; +import { describe, expect, test } from 'vitest'; +import { Attachment } from '../src/messages/Attachment.js'; +import { Message } from '../src/messages/Message.js'; +import { ContainerComponent } from '../src/messages/components/ContainerComponent.js'; +import { Embed } from '../src/messages/embeds/Embed.js'; +import { User } from '../src/users/User.js'; +import { dateToDiscordISOTimestamp } from '../src/utils/optimization.js'; + +const user: APIUser = { + username: 'user', + avatar: 'abcd123', + global_name: 'User', + discriminator: '0', + id: '3', +}; + +describe('message with embeds and attachments', () => { + const timestamp = '2025-10-09T17:48:20.192000+00:00'; + const data: APIMessage = { + id: DiscordSnowflake.generate({ timestamp: Date.parse(timestamp) }).toString(), + type: MessageType.Default, + position: 10, + channel_id: '2', + author: user, + attachments: [ + { + filename: 'file.txt', + description: 'describe attachment', + id: '0', + proxy_url: 'https://media.example.com/attachment/123.txt', + size: 5, + url: 'https://example.com/attachment/123.txt', + }, + ], + content: 'something <&5> <&6>', + edited_timestamp: '2025-10-09T17:50:20.292000+00:00', + embeds: [ + { + author: { + name: 'embed author', + icon_url: 'https://discord.js.org/static/logo.svg', + }, + color: 255, + description: 'describe me', + fields: [ + { + name: 'field name', + value: 'field value', + inline: false, + }, + ], + footer: { + text: 'footer', + }, + image: { + url: 'https://discord.js.org/static/logo.svg', + }, + thumbnail: { + url: 'https://discord.js.org/static/logo.svg', + }, + title: 'Title', + timestamp: '2025-10-19T21:39:40.193000+00:00', + }, + ], + mention_everyone: false, + mention_roles: ['5', '6'], + mentions: [user], + pinned: false, + timestamp, + tts: false, + flags: MessageFlags.SuppressNotifications, + }; + + test('Message has all properties', () => { + const instance = new Message(data); + expect(instance.id).toBe(data.id); + expect(instance.channelId).toBe(data.channel_id); + expect(instance.position).toBe(data.position); + expect(instance.content).toBe(data.content); + expect(instance.createdTimestamp).toBe(Date.parse(data.timestamp)); + expect(dateToDiscordISOTimestamp(instance.createdAt!)).toBe(data.timestamp); + expect(instance.flags?.toJSON()).toBe(data.flags); + expect(instance.editedTimestamp).toBe(Date.parse(data.edited_timestamp!)); + expect(dateToDiscordISOTimestamp(instance.editedAt!)).toBe(data.edited_timestamp); + expect(instance.nonce).toBe(data.nonce); + expect(instance.pinned).toBe(data.pinned); + expect(instance.tts).toBe(data.tts); + expect(instance.webhookId).toBe(data.webhook_id); + expect(instance.type).toBe(MessageType.Default); + expect(instance.toJSON()).toEqual(data); + }); + + test('Attachment sub-structure', () => { + const instances = data.attachments?.map((attachment) => new Attachment(attachment)); + expect(instances?.map((attachment) => attachment.toJSON())).toEqual(data.attachments); + expect(instances?.[0]?.description).toBe(data.attachments?.[0]?.description); + expect(instances?.[0]?.filename).toBe(data.attachments?.[0]?.filename); + expect(instances?.[0]?.id).toBe(data.attachments?.[0]?.id); + expect(instances?.[0]?.size).toBe(data.attachments?.[0]?.size); + expect(instances?.[0]?.url).toBe(data.attachments?.[0]?.url); + expect(instances?.[0]?.proxyURL).toBe(data.attachments?.[0]?.proxy_url); + }); + + test('Embed sub-structure', () => { + const instances = data.embeds?.map((embed) => new Embed(embed)); + expect(instances?.map((embed) => embed.toJSON())).toEqual(data.embeds); + expect(instances?.[0]?.description).toBe(data.embeds?.[0]?.description); + expect(instances?.[0]?.color).toBe(data.embeds?.[0]?.color); + expect(instances?.[0]?.timestamp).toBe(Date.parse(data.embeds![0]!.timestamp!)); + expect(instances?.[0]?.title).toBe(data.embeds?.[0]?.title); + expect(instances?.[0]?.url).toBe(data.embeds?.[0]?.url); + expect(instances?.[0]?.type).toBe(data.embeds?.[0]?.type); + }); + + test('User sub-structure', () => { + const instance = new User(data.author); + const instances = data.mentions.map((user) => new User(user)); + expect(instance.toJSON()).toEqual(data.author); + expect(instances.map((user) => user.toJSON())).toEqual(data.mentions); + expect(instance.avatar).toBe(data.author.avatar); + expect(instance.discriminator).toBe(data.author.discriminator); + expect(instance.displayName).toBe(data.author.global_name); + expect(instance.globalName).toBe(data.author.global_name); + expect(instance.id).toBe(data.author.id); + expect(instance.username).toBe(data.author.username); + }); +}); + +describe('message with components', () => { + const timestamp = '2025-10-10T15:48:20.192000+00:00'; + const buttonRow: APIActionRowComponent = { + type: ComponentType.ActionRow, + id: 5, + components: [ + { + type: ComponentType.Button, + style: ButtonStyle.Danger, + custom_id: 'danger', + disabled: false, + emoji: { + animated: false, + id: '12345', + name: 'emoji', + }, + id: 6, + label: 'Danger button', + }, + { + type: ComponentType.Button, + style: ButtonStyle.Link, + url: 'https://discord.js.org/', + disabled: false, + id: 7, + label: 'DJS', + }, + { + type: ComponentType.Button, + style: ButtonStyle.Premium, + sku_id: '9876', + disabled: false, + id: 8, + }, + ], + }; + const file: APIFileComponent = { + type: ComponentType.File, + file: { + url: 'attachment://file.txt', + attachment_id: '0', + content_type: 'text/plain', + flags: 0, + }, + id: 9, + spoiler: true, + }; + const mediaGallery: APIMediaGalleryComponent = { + type: ComponentType.MediaGallery, + items: [ + { + media: { + url: 'https://discord.js.org/static/logo.svg', + content_type: 'image/svg+xml', + height: 50, + width: 50, + }, + description: 'Logo', + spoiler: false, + }, + ], + id: 10, + }; + const section: APISectionComponent = { + type: ComponentType.Section, + accessory: { + type: ComponentType.Thumbnail, + media: { + url: 'https://discord.js.org/static/logo.svg', + }, + description: 'Logo thumbnail', + id: 13, + spoiler: false, + }, + components: [ + { + type: ComponentType.TextDisplay, + content: 'Text', + id: 14, + }, + ], + id: 12, + }; + const separator: APISeparatorComponent = { + type: ComponentType.Separator, + divider: true, + id: 15, + spacing: SeparatorSpacingSize.Large, + }; + const channelRow: APIActionRowComponent = { + type: ComponentType.ActionRow, + id: 16, + components: [ + { + type: ComponentType.ChannelSelect, + custom_id: 'channel', + channel_types: [ChannelType.GuildCategory, ChannelType.GuildText], + default_values: [ + { + id: '123456789012345678', + type: SelectMenuDefaultValueType.Channel, + }, + { + id: '123456789012345679', + type: SelectMenuDefaultValueType.Channel, + }, + ], + disabled: false, + id: 17, + max_values: 2, + min_values: 0, + placeholder: '(none)', + required: false, + }, + ], + }; + const mentionRow: APIActionRowComponent = { + type: ComponentType.ActionRow, + id: 18, + components: [ + { + type: ComponentType.MentionableSelect, + custom_id: 'mention', + default_values: [ + { + id: '123456789012345678', + type: SelectMenuDefaultValueType.User, + }, + { + id: '123456789012345679', + type: SelectMenuDefaultValueType.Role, + }, + ], + disabled: false, + id: 19, + max_values: 2, + min_values: 0, + placeholder: '(none)', + required: false, + }, + ], + }; + const roleRow: APIActionRowComponent = { + type: ComponentType.ActionRow, + id: 20, + components: [ + { + type: ComponentType.RoleSelect, + custom_id: 'role', + default_values: [ + { + id: '123456789012345678', + type: SelectMenuDefaultValueType.Role, + }, + { + id: '123456789012345679', + type: SelectMenuDefaultValueType.Role, + }, + ], + disabled: false, + id: 21, + max_values: 2, + min_values: 0, + placeholder: '(none)', + required: false, + }, + ], + }; + const userRow: APIActionRowComponent = { + type: ComponentType.ActionRow, + id: 22, + components: [ + { + type: ComponentType.UserSelect, + custom_id: 'user', + default_values: [ + { + id: '123456789012345678', + type: SelectMenuDefaultValueType.User, + }, + { + id: '123456789012345679', + type: SelectMenuDefaultValueType.User, + }, + ], + disabled: false, + id: 23, + max_values: 2, + min_values: 0, + placeholder: '(none)', + required: false, + }, + ], + }; + const stringRow: APIActionRowComponent = { + type: ComponentType.ActionRow, + id: 24, + components: [ + { + type: ComponentType.StringSelect, + custom_id: 'string', + options: [ + { + label: 'one', + value: '1', + default: true, + }, + { + label: 'two', + value: '2', + default: false, + }, + { + label: 'three', + value: '3', + description: 'third', + emoji: { + id: '3333333333333333333', + name: '3', + animated: false, + }, + }, + ], + disabled: false, + id: 25, + max_values: 2, + min_values: 0, + placeholder: '(none)', + required: false, + }, + ], + }; + const container: APIContainerComponent = { + type: ComponentType.Container, + accent_color: 255, + id: 4, + components: [ + buttonRow, + file, + mediaGallery, + section, + separator, + channelRow, + mentionRow, + roleRow, + userRow, + stringRow, + ], + spoiler: true, + }; + const data: APIMessage = { + id: DiscordSnowflake.generate({ timestamp: Date.parse(timestamp) }).toString(), + type: MessageType.Reply, + position: 15, + channel_id: '2', + author: user, + attachments: [ + { + filename: 'file.txt', + description: 'describe attachment', + id: '0', + proxy_url: 'https://media.example.com/attachment/123.txt', + size: 5, + url: 'https://example.com/attachment/123.txt', + }, + ], + content: '', + edited_timestamp: '2025-10-10T15:50:20.292000+00:00', + embeds: [], + components: [container], + message_reference: { + channel_id: '505050505050505050', + message_id: '606060606060606060', + guild_id: '707070707070707070', + type: MessageReferenceType.Default, + }, + mention_everyone: false, + mention_roles: ['5', '6'], + mentions: [user], + pinned: false, + timestamp, + tts: false, + flags: MessageFlags.IsComponentsV2 | MessageFlags.Ephemeral, + }; + + test('Message has all properties', () => { + const instance = new Message(data); + expect(instance.id).toBe(data.id); + expect(instance.channelId).toBe(data.channel_id); + expect(instance.position).toBe(data.position); + expect(instance.content).toBe(data.content); + expect(instance.createdTimestamp).toBe(Date.parse(data.timestamp)); + expect(dateToDiscordISOTimestamp(instance.createdAt!)).toBe(data.timestamp); + expect(instance.flags?.toJSON()).toBe(data.flags); + expect(instance.editedTimestamp).toBe(Date.parse(data.edited_timestamp!)); + expect(dateToDiscordISOTimestamp(instance.editedAt!)).toBe(data.edited_timestamp); + expect(instance.nonce).toBe(data.nonce); + expect(instance.pinned).toBe(data.pinned); + expect(instance.tts).toBe(data.tts); + expect(instance.webhookId).toBe(data.webhook_id); + expect(instance.type).toBe(MessageType.Reply); + expect(instance.toJSON()).toEqual(data); + }); + + test('Attachment sub-structure', () => { + const instances = data.attachments?.map((attachment) => new Attachment(attachment)); + expect(instances?.map((attachment) => attachment.toJSON())).toEqual(data.attachments); + expect(instances?.[0]?.description).toBe(data.attachments?.[0]?.description); + expect(instances?.[0]?.filename).toBe(data.attachments?.[0]?.filename); + expect(instances?.[0]?.id).toBe(data.attachments?.[0]?.id); + expect(instances?.[0]?.size).toBe(data.attachments?.[0]?.size); + expect(instances?.[0]?.url).toBe(data.attachments?.[0]?.url); + expect(instances?.[0]?.proxyURL).toBe(data.attachments?.[0]?.proxy_url); + }); + + test('Component sub-structures', () => { + const containerInstance = new ContainerComponent(data.components?.[0] as APIContainerComponent); + expect(containerInstance.toJSON()).toEqual(container); + expect(containerInstance.type).toBe(container.type); + expect(containerInstance.id).toBe(container.id); + expect(containerInstance.spoiler).toBe(container.spoiler); + }); +}); diff --git a/packages/structures/__tests__/types/Mixin.test-d.ts b/packages/structures/__tests__/types/Mixin.test-d.ts index 59ef4878f..d0b0527b1 100644 --- a/packages/structures/__tests__/types/Mixin.test-d.ts +++ b/packages/structures/__tests__/types/Mixin.test-d.ts @@ -1,4 +1,3 @@ -import { expectNotType, expectType } from 'tsd'; import { expectTypeOf } from 'vitest'; import type { MixinTypes } from '../../src/MixinTypes.d.ts'; import type { kMixinConstruct } from '../../src/utils/symbols.js'; @@ -16,22 +15,26 @@ declare const extendsBothOmitBoth: Omit< keyof Base | typeof kMixinConstruct >; -expectType>(extendsNoOmit); -expectType, [MixinProperty1<'property1'>]>>(extendsOmitProperty1); -expectNotType>(extendsOmitProperty1); -expectNotType, [MixinProperty1<'property1'>]>>(extendsNoOmit); +expectTypeOf(extendsNoOmit).toEqualTypeOf>(); +expectTypeOf(extendsOmitProperty1).toEqualTypeOf, [MixinProperty1<'property1'>]>>(); +expectTypeOf(extendsOmitProperty1).not.toEqualTypeOf>(); +expectTypeOf(extendsNoOmit).not.toEqualTypeOf, [MixinProperty1<'property1'>]>>(); -expectType>(extendsBothNoOmit); +expectTypeOf(extendsBothNoOmit).toEqualTypeOf>(); // Since MixinProperty2 doesn't utilize the type of property1 in kData, this works and is ok -expectType, [MixinProperty1<'property1'>, MixinProperty2]>>(extendsBothOmitProperty1); -expectNotType>(extendsBothOmitProperty1); +expectTypeOf(extendsBothOmitProperty1).toEqualTypeOf< + MixinTypes, [MixinProperty1<'property1'>, MixinProperty2]> +>(); +expectTypeOf(extendsBothOmitProperty1).not.toEqualTypeOf>(); // Since MixinProperty2 doesn't utilize the type of property1 in kData, this works and is ok -expectNotType, [MixinProperty1<'property1'>, MixinProperty2]>>(extendsBothNoOmit); +expectTypeOf(extendsBothNoOmit).not.toEqualTypeOf< + MixinTypes, [MixinProperty1<'property1'>, MixinProperty2]> +>(); // Earlier mixins in the list must specify all properties because of the way merging works -expectType< +expectTypeOf(extendsBothOmitBoth).toEqualTypeOf< MixinTypes, [MixinProperty1<'property1' | 'property2'>, MixinProperty2<'property2'>]> ->(extendsBothOmitBoth); +>(); expectTypeOf, [MixinProperty1]>>().toBeNever(); // @ts-expect-error Shouldn't be able to assign non identical omits diff --git a/packages/structures/__tests__/types/channels.test-d.ts b/packages/structures/__tests__/types/channels.test-d.ts index bbb662ab3..c84d0f63b 100644 --- a/packages/structures/__tests__/types/channels.test-d.ts +++ b/packages/structures/__tests__/types/channels.test-d.ts @@ -1,79 +1,81 @@ import type { ChannelType, GuildChannelType, GuildTextChannelType, ThreadChannelType } from 'discord-api-types/v10'; -import { expectNever, expectType } from 'tsd'; -import type { Channel } from '../../src/index.js'; +import { expectTypeOf } from 'vitest'; +import type { Channel } from '../../src/channels/Channel.js'; declare const channel: Channel; if (channel.isGuildBased()) { - expectType(channel.guildId); - expectType(channel.type); + expectTypeOf(channel.guildId).toBeString(); + expectTypeOf(channel.type).toEqualTypeOf(); if (channel.isDMBased()) { - expectNever(channel); + expectTypeOf(channel).toBeNever(); } if (channel.isPermissionCapable()) { - expectType>(channel.type); + expectTypeOf(channel.type).toEqualTypeOf< + Exclude + >(); } if (channel.isTextBased()) { - expectType(channel.type); + expectTypeOf(channel.type).toEqualTypeOf(); } if (channel.isWebhookCapable()) { - expectType>( - channel.type, - ); + expectTypeOf(channel.type).toEqualTypeOf< + ChannelType.GuildForum | ChannelType.GuildMedia | Exclude + >(); } if (channel.isThread()) { - expectType(channel.type); + expectTypeOf(channel.type).toEqualTypeOf(); } if (channel.isThreadOnly()) { - expectType(channel.type); + expectTypeOf(channel.type).toEqualTypeOf(); } if (channel.isVoiceBased()) { - expectType(channel.type); + expectTypeOf(channel.type).toEqualTypeOf(); if (!channel.isTextBased()) { - expectNever(channel); + expectTypeOf(channel).toBeNever(); } if (!channel.isWebhookCapable()) { - expectNever(channel); + expectTypeOf(channel).toBeNever(); } } } if (channel.isDMBased()) { - expectType(channel.type); + expectTypeOf(channel.type).toEqualTypeOf(); if (channel.isGuildBased()) { - expectNever(channel); + expectTypeOf(channel).toBeNever(); } if (channel.isPermissionCapable()) { - expectNever(channel); + expectTypeOf(channel).toBeNever(); } if (channel.isWebhookCapable()) { - expectNever(channel); + expectTypeOf(channel).toBeNever(); } if (channel.isVoiceBased()) { - expectNever(channel); + expectTypeOf(channel).toBeNever(); } if (channel.isThread()) { - expectNever(channel); + expectTypeOf(channel).toBeNever(); } if (channel.isThreadOnly()) { - expectNever(channel); + expectTypeOf(channel).toBeNever(); } if (channel.isTextBased()) { - expectType(channel.type); + expectTypeOf(channel.type).toEqualTypeOf(); } } diff --git a/packages/structures/package.json b/packages/structures/package.json index 0f9c585b5..b7b39f641 100644 --- a/packages/structures/package.json +++ b/packages/structures/package.json @@ -79,7 +79,6 @@ "eslint-formatter-compact": "^9.0.1", "eslint-formatter-pretty": "^7.0.0", "prettier": "^3.6.2", - "tsd": "^0.33.0", "tsup": "^8.5.0", "turbo": "^2.5.8", "typescript": "~5.9.3", @@ -91,8 +90,5 @@ "publishConfig": { "access": "public", "provenance": true - }, - "tsd": { - "directory": "__tests__/types" } } diff --git a/packages/structures/src/bitfields/AttachmentFlagsBitField.ts b/packages/structures/src/bitfields/AttachmentFlagsBitField.ts new file mode 100644 index 000000000..a4db45687 --- /dev/null +++ b/packages/structures/src/bitfields/AttachmentFlagsBitField.ts @@ -0,0 +1,16 @@ +import { AttachmentFlags } from 'discord-api-types/v10'; +import { BitField } from './BitField.js'; + +/** + * Data structure that makes it easy to interact with a {@link Attachment#flags} bitfield. + */ +export class AttachmentFlagsBitField extends BitField { + /** + * Numeric attachment flags. + */ + public static override readonly Flags = AttachmentFlags; + + public override toJSON() { + return super.toJSON(true); + } +} diff --git a/packages/structures/src/bitfields/MessageFlagsBitField.ts b/packages/structures/src/bitfields/MessageFlagsBitField.ts new file mode 100644 index 000000000..233baf559 --- /dev/null +++ b/packages/structures/src/bitfields/MessageFlagsBitField.ts @@ -0,0 +1,16 @@ +import { MessageFlags } from 'discord-api-types/v10'; +import { BitField } from './BitField.js'; + +/** + * Data structure that makes it easy to interact with a {@link Message#flags} bitfield. + */ +export class MessageFlagsBitField extends BitField { + /** + * Numeric message flags. + */ + public static override readonly Flags = MessageFlags; + + public override toJSON() { + return super.toJSON(true); + } +} diff --git a/packages/structures/src/bitfields/index.ts b/packages/structures/src/bitfields/index.ts index 011821c8a..9598b8090 100644 --- a/packages/structures/src/bitfields/index.ts +++ b/packages/structures/src/bitfields/index.ts @@ -1,4 +1,6 @@ export * from './BitField.js'; +export * from './AttachmentFlagsBitField.js'; export * from './ChannelFlagsBitField.js'; +export * from './MessageFlagsBitField.js'; export * from './PermissionsBitField.js'; diff --git a/packages/structures/src/channels/Channel.ts b/packages/structures/src/channels/Channel.ts index 2ea00eacd..54701059c 100644 --- a/packages/structures/src/channels/Channel.ts +++ b/packages/structures/src/channels/Channel.ts @@ -2,7 +2,7 @@ import { DiscordSnowflake } from '@sapphire/snowflake'; import type { APIChannel, APIPartialChannel, ChannelType, ChannelFlags } from 'discord-api-types/v10'; import { Structure } from '../Structure.js'; import { ChannelFlagsBitField } from '../bitfields/ChannelFlagsBitField.js'; -import { kData, kPatch } from '../utils/symbols.js'; +import { kData } from '../utils/symbols.js'; import { isIdSet } from '../utils/type-guards.js'; import type { Partialize } from '../utils/types.js'; import type { ChannelPermissionMixin } from './mixins/ChannelPermissionMixin.js'; @@ -50,15 +50,6 @@ export class Channel< super(data as ChannelDataType); } - /** - * {@inheritDoc Structure.[kPatch]} - * - * @internal - */ - public override [kPatch](data: Partial>) { - return super[kPatch](data); - } - /** * The id of the channel */ diff --git a/packages/structures/src/index.ts b/packages/structures/src/index.ts index 5a2e6b407..274b2ef05 100644 --- a/packages/structures/src/index.ts +++ b/packages/structures/src/index.ts @@ -1,6 +1,10 @@ export * from './bitfields/index.js'; export * from './channels/index.js'; +export * from './interactions/index.js'; export * from './invites/index.js'; +export * from './messages/index.js'; +export * from './polls/index.js'; +export * from './stickers/index.js'; export * from './users/index.js'; export * from './Structure.js'; export * from './Mixin.js'; diff --git a/packages/structures/src/interactions/ResolvedInteractionData.ts b/packages/structures/src/interactions/ResolvedInteractionData.ts new file mode 100644 index 000000000..d6e5a33d6 --- /dev/null +++ b/packages/structures/src/interactions/ResolvedInteractionData.ts @@ -0,0 +1,20 @@ +import type { APIInteractionDataResolved } from 'discord-api-types/v10'; +import { Structure } from '../Structure.js'; +import type { Partialize } from '../utils/types.js'; + +/** + * Represents data for users, members, channels, and roles in the message's auto-populated select menus. + * + * @typeParam Omitted - Specify the properties that will not be stored in the raw data field as a union, implement via `DataTemplate` + * @remarks has substructures `User`, `Channel`, `Role`, `Message`, `GuildMember`, `Attachment`, which need to be instantiated and stored by an extending class using it + */ +export abstract class ResolvedInteractionData< + Omitted extends keyof APIInteractionDataResolved | '' = '', +> extends Structure { + /** + * @param data - The raw data received from the API for the connection + */ + public constructor(data: Partialize) { + super(data); + } +} diff --git a/packages/structures/src/interactions/index.ts b/packages/structures/src/interactions/index.ts new file mode 100644 index 000000000..f68595469 --- /dev/null +++ b/packages/structures/src/interactions/index.ts @@ -0,0 +1 @@ +export * from './ResolvedInteractionData.js'; diff --git a/packages/structures/src/invites/Invite.ts b/packages/structures/src/invites/Invite.ts index 93e8c5cc5..0b5d8a1b2 100644 --- a/packages/structures/src/invites/Invite.ts +++ b/packages/structures/src/invites/Invite.ts @@ -1,6 +1,7 @@ import { type APIInvite, type APIExtendedInvite, RouteBases } from 'discord-api-types/v10'; import { Structure } from '../Structure.js'; -import { kData, kExpiresTimestamp, kCreatedTimestamp, kPatch } from '../utils/symbols.js'; +import { dateToDiscordISOTimestamp } from '../utils/optimization.js'; +import { kData, kExpiresTimestamp, kCreatedTimestamp } from '../utils/symbols.js'; import type { Partialize } from '../utils/types.js'; export interface APIActualInvite extends APIInvite, Partial> {} @@ -47,16 +48,6 @@ export class Invite) { - super[kPatch](data); - return this; - } - /** * {@inheritDoc Structure.optimizeData} * @@ -201,11 +192,11 @@ export class Invite extends InteractionMetadata { + /** + * The template used for removing data from the raw data stored for each ApplicationCommandInteractionMetadata. + */ + public static override readonly DataTemplate: Partial = {}; + + /** + * @param data - The raw data received from the API for the connection + */ + public constructor(data: Partialize) { + super(data); + } + + /** + * The id of the message the command was run on + */ + public get targetMessageId() { + return this[kData].target_message_id; + } +} diff --git a/packages/structures/src/messages/Attachment.ts b/packages/structures/src/messages/Attachment.ts new file mode 100644 index 000000000..e62d350e7 --- /dev/null +++ b/packages/structures/src/messages/Attachment.ts @@ -0,0 +1,118 @@ +import type { APIAttachment, AttachmentFlags } from 'discord-api-types/v10'; +import { Structure } from '../Structure.js'; +import { AttachmentFlagsBitField } from '../bitfields/AttachmentFlagsBitField.js'; +import { kData } from '../utils/symbols.js'; +import type { Partialize } from '../utils/types.js'; + +export class Attachment extends Structure { + /** + * The template used for removing data from the raw data stored for each Attachment. + */ + public static override readonly DataTemplate: Partial = {}; + + /** + * @param data - The raw data received from the API for the connection + */ + public constructor(data: Partialize) { + super(data); + } + + /** + * The id of the attachment + */ + public get id() { + return this[kData].id; + } + + /** + * The name of the attached file + */ + public get filename() { + return this[kData].filename; + } + + /** + * The title of the file + */ + public get title() { + return this[kData].title; + } + + /** + * The description for the file + */ + public get description() { + return this[kData].description; + } + + /** + * The attachment's media type + */ + public get contentType() { + return this[kData].content_type; + } + + /** + * The size of the file in bytes + */ + public get size() { + return this[kData].size; + } + + /** + * The source URL of the file + */ + public get url() { + return this[kData].url; + } + + /** + * A proxied URL of the file + */ + public get proxyURL() { + return this[kData].proxy_url; + } + + /** + * The height of the file (if image) + */ + public get height() { + return this[kData].height; + } + + /** + * The width of the file (if image) + */ + public get width() { + return this[kData].width; + } + + /** + * Whether this attachment is ephemeral + */ + public get ephemeral() { + return this[kData].ephemeral; + } + + /** + * The duration of the audio file + */ + public get durationSecs() { + return this[kData].duration_secs; + } + + /** + * Base64 encoded bytearray representing a sampled waveform + */ + public get waveform() { + return this[kData].waveform; + } + + /** + * Attachment flags combined as a bitfield + */ + public get flags() { + const flags = this[kData].flags; + return flags ? new AttachmentFlagsBitField(this[kData].flags as AttachmentFlags) : null; + } +} diff --git a/packages/structures/src/messages/ChannelMention.ts b/packages/structures/src/messages/ChannelMention.ts new file mode 100644 index 000000000..9217996ab --- /dev/null +++ b/packages/structures/src/messages/ChannelMention.ts @@ -0,0 +1,54 @@ +import type { APIChannelMention } from 'discord-api-types/v10'; +import { Structure } from '../Structure.js'; +import { kData } from '../utils/symbols.js'; +import type { Partialize } from '../utils/types.js'; + +/** + * Represents the mention of a channel on a message on Discord. + * + * @typeParam Omitted - Specify the properties that will not be stored in the raw data field as a union, implement via `DataTemplate` + */ +export class ChannelMention extends Structure< + APIChannelMention, + Omitted +> { + /** + * The template used for removing data from the raw data stored for each ChannelMention. + */ + public static override DataTemplate: Partial = {}; + + /** + * @param data - The raw data received from the API for the channel mention + */ + public constructor(data: Partialize) { + super(data); + } + + /** + * The type of the mentioned channel + */ + public get type() { + return this[kData].type; + } + + /** + * The name of the mentioned channel + */ + public get name() { + return this[kData].name; + } + + /** + * The id of the mentioned channel + */ + public get id() { + return this[kData].id; + } + + /** + * The id of the guild the mentioned channel is in + */ + public get guildId() { + return this[kData].guild_id; + } +} diff --git a/packages/structures/src/messages/InteractionMetadata.ts b/packages/structures/src/messages/InteractionMetadata.ts new file mode 100644 index 000000000..40f214ee9 --- /dev/null +++ b/packages/structures/src/messages/InteractionMetadata.ts @@ -0,0 +1,48 @@ +import type { APIMessageInteractionMetadata, InteractionType } from 'discord-api-types/v10'; +import { Structure } from '../Structure.js'; +import { kData } from '../utils/symbols.js'; +import type { Partialize } from '../utils/types.js'; + +export type InteractionMetadataType = Extract< + APIMessageInteractionMetadata, + { type: Type } +>; + +/** + * Represents metadata about the interaction causing a message. + * + * @typeParam Omitted - Specify the properties that will not be stored in the raw data field as a union, implement via `DataTemplate` + * @remarks has a substructure `User` which needs to be instantiated and stored by an extending class using it + */ +export abstract class InteractionMetadata< + Type extends InteractionType, + Omitted extends keyof InteractionMetadataType | '' = '', +> extends Structure, Omitted> { + /** + * @param data - The raw data received from the API for the connection + */ + public constructor(data: Partialize, Omitted>) { + super(data as InteractionMetadataType); + } + + /** + * The id of the interaction + */ + public get id() { + return this[kData].id; + } + + /** + * The id of the original response message, present only on follow-up messages + */ + public get originalResponseMessageId() { + return this[kData].original_response_message_id; + } + + /** + * The type of interaction + */ + public get type() { + return this[kData].type; + } +} diff --git a/packages/structures/src/messages/Message.ts b/packages/structures/src/messages/Message.ts new file mode 100644 index 000000000..7fa33895d --- /dev/null +++ b/packages/structures/src/messages/Message.ts @@ -0,0 +1,178 @@ +import { DiscordSnowflake } from '@sapphire/snowflake'; +import type { APIMessage, MessageFlags } from 'discord-api-types/v10'; +import { Structure } from '../Structure.js'; +import { MessageFlagsBitField } from '../bitfields/MessageFlagsBitField.js'; +import { dateToDiscordISOTimestamp } from '../utils/optimization.js'; +import { kData, kEditedTimestamp } from '../utils/symbols.js'; +import { isIdSet } from '../utils/type-guards.js'; +import type { Partialize } from '../utils/types.js'; + +// TODO: missing substructures: application + +/** + * Represents a message on Discord. + * + * @typeParam Omitted - Specify the properties that will not be stored in the raw data field as a union, implement via `DataTemplate` + * @remarks has substructures `Message`, `Channel`, `MessageActivity`, `MessageCall`, `MessageReference`, `Attachment`, `Application`, `ChannelMention`, `Reaction`, `Poll`, `ResolvedInteractionData`, `RoleSubscriptionData`, `Sticker`, all the different `Component`s, ... which need to be instantiated and stored by an extending class using it + */ +export class Message extends Structure< + APIMessage, + Omitted +> { + /** + * The template used for removing data from the raw data stored for each Message + */ + public static override DataTemplate: Partial = { + set timestamp(_: string) {}, + set edited_timestamp(_: string) {}, + }; + + protected [kEditedTimestamp]: number | null = null; + + /** + * @param data - The raw data received from the API for the message + */ + public constructor(data: Partialize) { + super(data); + this.optimizeData(data); + } + + /** + * {@inheritDoc Structure.optimizeData} + * + * @internal + */ + protected override optimizeData(data: Partial) { + if (data.edited_timestamp) { + this[kEditedTimestamp] = Date.parse(data.edited_timestamp); + } + } + + /** + * The message's id + */ + public get id() { + return this[kData].id; + } + + /** + * The id of the interaction's application, if this message is a reply to an interaction + */ + public get applicationId() { + return this[kData].application_id; + } + + /** + * The channel's id this message was sent in + */ + public get channelId() { + return this[kData].channel_id; + } + + /** + * The timestamp this message was created at + */ + public get createdTimestamp() { + return isIdSet(this.id) ? DiscordSnowflake.timestampFrom(this.id) : null; + } + + /** + * The time the message was created at + */ + public get createdAt() { + const createdTimestamp = this.createdTimestamp; + return createdTimestamp ? new Date(createdTimestamp) : null; + } + + /** + * The content of the message + */ + public get content() { + return this[kData].content; + } + + /** + * The timestamp this message was last edited at, or `null` if it never was edited + */ + public get editedTimestamp() { + return this[kEditedTimestamp]; + } + + /** + * The time the message was last edited at, or `null` if it never was edited + */ + public get editedAt() { + const editedTimestamp = this.editedTimestamp; + return editedTimestamp ? new Date(editedTimestamp) : null; + } + + /** + * The flags of this message as a bit field + */ + public get flags() { + const flags = this[kData].flags; + return flags ? new MessageFlagsBitField(this[kData].flags as MessageFlags) : null; + } + + /** + * The nonce used when sending this message. + * + * @remarks This is only present in MESSAGE_CREATE event, if a nonce was provided when sending + */ + public get nonce() { + return this[kData].nonce; + } + + /** + * Whether this message is pinned in its channel + */ + public get pinned() { + return this[kData].pinned; + } + + /** + * A generally increasing integer (there may be gaps or duplicates) that represents the approximate position of the message in a thread + * It can be used to estimate the relative position of the message in a thread in company with `totalMessageSent` on parent thread + */ + public get position() { + return this[kData].position; + } + + /** + * Whether this message was a TTS message + */ + public get tts() { + return this[kData].tts; + } + + /** + * The type of message + */ + public get type() { + return this[kData].type; + } + + /** + * If the message is generated by a webhook, this is the webhook's id + */ + public get webhookId() { + return this[kData].webhook_id; + } + + /** + * {@inheritDoc Structure.toJSON} + */ + public override toJSON() { + const clone = super.toJSON(); + if (this[kEditedTimestamp]) { + clone.edited_timestamp = dateToDiscordISOTimestamp(new Date(this[kEditedTimestamp])); + } + + const createdAt = this.createdAt; + if (createdAt) { + clone.timestamp = dateToDiscordISOTimestamp(createdAt); + } + + return clone; + } +} diff --git a/packages/structures/src/messages/MessageActivity.ts b/packages/structures/src/messages/MessageActivity.ts new file mode 100644 index 000000000..80f8b191a --- /dev/null +++ b/packages/structures/src/messages/MessageActivity.ts @@ -0,0 +1,35 @@ +import type { APIMessageActivity } from 'discord-api-types/v10'; +import { Structure } from '../Structure.js'; +import { kData } from '../utils/symbols.js'; +import type { Partialize } from '../utils/types.js'; + +export class MessageActivity extends Structure< + APIMessageActivity, + Omitted +> { + /** + * The template used for removing data from the raw data stored for each MessageActivity. + */ + public static override readonly DataTemplate: Partial = {}; + + /** + * @param data - The raw data received from the API for the connection + */ + public constructor(data: Partialize) { + super(data); + } + + /** + * The party id from a Rich Presence event + */ + public get partyId() { + return this[kData].party_id; + } + + /** + * The type of message activity + */ + public get type() { + return this[kData].type; + } +} diff --git a/packages/structures/src/messages/MessageCall.ts b/packages/structures/src/messages/MessageCall.ts new file mode 100644 index 000000000..67c316b02 --- /dev/null +++ b/packages/structures/src/messages/MessageCall.ts @@ -0,0 +1,65 @@ +import type { APIMessageCall } from 'discord-api-types/v10'; +import { Structure } from '../Structure.js'; +import { dateToDiscordISOTimestamp } from '../utils/optimization.js'; +import { kEndedTimestamp } from '../utils/symbols.js'; +import type { Partialize } from '../utils/types.js'; + +export class MessageCall extends Structure< + APIMessageCall, + Omitted +> { + /** + * The template used for removing data from the raw data stored for each MessageCall + */ + public static override DataTemplate: Partial = { + set ended_timestamp(_: string) {}, + }; + + protected [kEndedTimestamp]: number | null = null; + + /** + * @param data - The raw data received from the API for the message call + */ + public constructor(data: Partialize) { + super(data); + this.optimizeData(data); + } + + /** + * {@inheritDoc Structure.optimizeData} + * + * @internal + */ + protected override optimizeData(data: Partial) { + if (data.ended_timestamp) { + this[kEndedTimestamp] = Date.parse(data.ended_timestamp); + } + } + + /** + * The timestamp this call ended at, or `null` if it didn't end yet + */ + public get endedTimestamp() { + return this[kEndedTimestamp]; + } + + /** + * The time the call ended at, or `null` if it didn't end yet + */ + public get endedAt() { + const endedTimestamp = this.endedTimestamp; + return endedTimestamp ? new Date(endedTimestamp) : null; + } + + /** + * {@inheritDoc Structure.toJSON} + */ + public override toJSON() { + const clone = super.toJSON(); + if (this[kEndedTimestamp]) { + clone.ended_timestamp = dateToDiscordISOTimestamp(new Date(this[kEndedTimestamp])); + } + + return clone; + } +} diff --git a/packages/structures/src/messages/MessageComponentInteractionMetadata.ts b/packages/structures/src/messages/MessageComponentInteractionMetadata.ts new file mode 100644 index 000000000..5ebd544bc --- /dev/null +++ b/packages/structures/src/messages/MessageComponentInteractionMetadata.ts @@ -0,0 +1,32 @@ +import type { APIMessageComponentInteractionMetadata, InteractionType } from 'discord-api-types/v10'; +import { kData } from '../utils/symbols.js'; +import type { Partialize } from '../utils/types.js'; +import { InteractionMetadata } from './InteractionMetadata.js'; + +/** + * Represents metadata about the message component interaction causing a message. + * + * @typeParam Omitted - Specify the properties that will not be stored in the raw data field as a union, implement via `DataTemplate` + */ +export class MessageComponentInteractionMetadata< + Omitted extends keyof APIMessageComponentInteractionMetadata | '' = '', +> extends InteractionMetadata { + /** + * The template used for removing data from the raw data stored for each MessageComponentInteractionMetadata. + */ + public static override readonly DataTemplate: Partial = {}; + + /** + * @param data - The raw data received from the API for the connection + */ + public constructor(data: Partialize) { + super(data); + } + + /** + * The id of the message that contained the interactive component + */ + public get interactedMessageId() { + return this[kData].interacted_message_id; + } +} diff --git a/packages/structures/src/messages/MessageReference.ts b/packages/structures/src/messages/MessageReference.ts new file mode 100644 index 000000000..a8cb74cef --- /dev/null +++ b/packages/structures/src/messages/MessageReference.ts @@ -0,0 +1,54 @@ +import { MessageReferenceType, type APIMessageReference } from 'discord-api-types/v10'; +import { Structure } from '../Structure.js'; +import { kData } from '../utils/symbols.js'; +import type { Partialize } from '../utils/types.js'; + +/** + * Represents the reference to another message on a message on Discord. + * + * @typeParam Omitted - Specify the properties that will not be stored in the raw data field as a union, implement via `DataTemplate` + */ +export class MessageReference extends Structure< + APIMessageReference, + Omitted +> { + /** + * The template used for removing data from the raw data stored for each MessageReference. + */ + public static override DataTemplate: Partial = {}; + + /** + * @param data - The raw data received from the API for the message reference + */ + public constructor(data: Partialize) { + super(data); + } + + /** + * The type of this reference + */ + public get type() { + return 'type' in this[kData] ? (this[kData].type as MessageReferenceType) : MessageReferenceType.Default; + } + + /** + * The id of the referenced message + */ + public get messageId() { + return this[kData].message_id; + } + + /** + * The id of the channel the referenced message was sent in + */ + public get channelId() { + return this[kData].channel_id; + } + + /** + * The id of the guild the referenced message was sent in + */ + public get guildId() { + return this[kData].guild_id; + } +} diff --git a/packages/structures/src/messages/ModalSubmitInteractionMetadata.ts b/packages/structures/src/messages/ModalSubmitInteractionMetadata.ts new file mode 100644 index 000000000..a0958a688 --- /dev/null +++ b/packages/structures/src/messages/ModalSubmitInteractionMetadata.ts @@ -0,0 +1,25 @@ +import type { APIModalSubmitInteractionMetadata, InteractionType } from 'discord-api-types/v10'; +import type { Partialize } from '../utils/types.js'; +import { InteractionMetadata } from './InteractionMetadata.js'; + +/** + * Represents metadata about the modal submit interaction causing a message. + * + * @typeParam Omitted - Specify the properties that will not be stored in the raw data field as a union, implement via `DataTemplate` + * @remarks has a substructure `InteractionMetadata` which needs to be instantiated and stored by an extending class using it + */ +export class ModalSubmitInteractionMetadata< + Omitted extends keyof APIModalSubmitInteractionMetadata | '' = '', +> extends InteractionMetadata { + /** + * The template used for removing data from the raw data stored for each ModalSubmitInteractionMetadata. + */ + public static override readonly DataTemplate: Partial = {}; + + /** + * @param data - The raw data received from the API for the connection + */ + public constructor(data: Partialize) { + super(data); + } +} diff --git a/packages/structures/src/messages/Reaction.ts b/packages/structures/src/messages/Reaction.ts new file mode 100644 index 000000000..1928e44dd --- /dev/null +++ b/packages/structures/src/messages/Reaction.ts @@ -0,0 +1,80 @@ +import type { APIReaction } from 'discord-api-types/v10'; +import { Structure } from '../Structure.js'; +import { kBurstColors, kData } from '../utils/symbols.js'; +import type { Partialize } from '../utils/types.js'; + +/** + * Represents a reaction on a message on Discord. + * + * @typeParam Omitted - Specify the properties that will not be stored in the raw data field as a union, implement via `DataTemplate` + * @remarks has substructures `Emoji`, `ReactionCountDetails` which need to be instantiated and stored by an extending class using it + */ +export class Reaction extends Structure { + /** + * The template used for removing data from the raw data stored for each Reaction. + */ + public static override DataTemplate: Partial = { + set burst_colors(_: string[]) {}, + }; + + protected [kBurstColors]: number[] | null = null; + + /** + * @param data - The raw data received from the API for the reaction + */ + public constructor(data: Partialize) { + super(data); + this.optimizeData(data); + } + + /** + * {@inheritDoc Structure.optimizeData} + * + * @internal + */ + protected override optimizeData(data: Partial) { + if (data.burst_colors) { + this[kBurstColors] = data.burst_colors.map((color) => Number.parseInt(color, 16)); + } + } + + /** + * The amount how often this emoji has been used to react (including super reacts) + */ + public get count() { + return this[kData].count; + } + + /** + * Whether the current user has reacted using this emoji + */ + public get me() { + return this[kData].me; + } + + /** + * Whether the current user has super-reacted using this emoji + */ + public get meBurst() { + return this[kData].me_burst; + } + + /** + * The colors used for super reaction + */ + public get burstColors() { + return this[kBurstColors]; + } + + /** + * {@inheritDoc Structure.toJSON} + */ + public override toJSON() { + const clone = super.toJSON(); + if (this[kBurstColors]) { + clone.burst_colors = this[kBurstColors].map((color) => `#${color.toString(16).padStart(6, '0')}`); + } + + return clone; + } +} diff --git a/packages/structures/src/messages/ReactionCountDetails.ts b/packages/structures/src/messages/ReactionCountDetails.ts new file mode 100644 index 000000000..1d65104d6 --- /dev/null +++ b/packages/structures/src/messages/ReactionCountDetails.ts @@ -0,0 +1,40 @@ +import type { APIReactionCountDetails } from 'discord-api-types/v10'; +import { Structure } from '../Structure.js'; +import { kData } from '../utils/symbols.js'; +import type { Partialize } from '../utils/types.js'; + +/** + * Represents the usage count of a reaction on a message on Discord. + * + * @typeParam Omitted - Specify the properties that will not be stored in the raw data field as a union, implement via `DataTemplate` + */ +export class ReactionCountDetails extends Structure< + APIReactionCountDetails, + Omitted +> { + /** + * The template used for removing data from the raw data stored for each ReactionCountDetails. + */ + public static override DataTemplate: Partial = {}; + + /** + * @param data - The raw data received from the API for the reaction count details + */ + public constructor(data: Partialize) { + super(data); + } + + /** + * The amount how often this emoji has been used to react (excluding super reacts) + */ + public get normal() { + return this[kData].normal; + } + + /** + * The amount how often this emoji has been used to super-react + */ + public get burst() { + return this[kData].burst; + } +} diff --git a/packages/structures/src/messages/RoleSubscriptionData.ts b/packages/structures/src/messages/RoleSubscriptionData.ts new file mode 100644 index 000000000..77314eb95 --- /dev/null +++ b/packages/structures/src/messages/RoleSubscriptionData.ts @@ -0,0 +1,48 @@ +import type { APIMessageRoleSubscriptionData } from 'discord-api-types/v10'; +import { Structure } from '../Structure.js'; +import { kData } from '../utils/symbols.js'; +import type { Partialize } from '../utils/types.js'; + +/** + * Represents metadata about the role subscription causing a message. + * + * @typeParam Omitted - Specify the properties that will not be stored in the raw data field as a union, implement via `DataTemplate` + */ +export abstract class RoleSubscriptionData< + Omitted extends keyof APIMessageRoleSubscriptionData | '' = '', +> extends Structure { + /** + * @param data - The raw data received from the API for the connection + */ + public constructor(data: Partialize) { + super(data); + } + + /** + * The id of the SKU and listing the user is subscribed to + */ + public get roleSubscriptionListingId() { + return this[kData].role_subscription_listing_id; + } + + /** + * The name of the tier the user is subscribed to + */ + public get tierName() { + return this[kData].tier_name; + } + + /** + * The number of months the user has been subscribed for + */ + public get totalMonthsSubscribed() { + return this[kData].total_months_subscribed; + } + + /** + * Whether this notification is for a renewal + */ + public get isRenewal() { + return this[kData].is_renewal; + } +} diff --git a/packages/structures/src/messages/components/ActionRowComponent.ts b/packages/structures/src/messages/components/ActionRowComponent.ts new file mode 100644 index 000000000..a05b7862f --- /dev/null +++ b/packages/structures/src/messages/components/ActionRowComponent.ts @@ -0,0 +1,27 @@ +import type { APIActionRowComponent, APIComponentInActionRow, ComponentType } from 'discord-api-types/v10'; +import type { Partialize } from '../../utils/types.js'; +import type { ComponentDataType } from './Component.js'; +import { Component } from './Component.js'; + +/** + * Represents an action row component on a message or modal. + * + * @typeParam Omitted - Specify the properties that will not be stored in the raw data field as a union, implement via `DataTemplate` + * @remarks has `Component`s as substructures which need to be instantiated and stored by an extending class using it + */ +export class ActionRowComponent< + Type extends APIComponentInActionRow, + Omitted extends keyof APIActionRowComponent | '' = '', +> extends Component, Omitted> { + /** + * The template used for removing data from the raw data stored for each ActionRowComponent. + */ + public static override readonly DataTemplate: Partial> = {}; + + /** + * @param data - The raw data received from the API for the action row + */ + public constructor(data: Partialize, Omitted>) { + super(data); + } +} diff --git a/packages/structures/src/messages/components/ButtonComponent.ts b/packages/structures/src/messages/components/ButtonComponent.ts new file mode 100644 index 000000000..468045cef --- /dev/null +++ b/packages/structures/src/messages/components/ButtonComponent.ts @@ -0,0 +1,41 @@ +import type { APIButtonComponent, APIButtonComponentWithCustomId, ButtonStyle } from 'discord-api-types/v10'; +import { kData } from '../../utils/symbols.js'; +import type { Partialize } from '../../utils/types.js'; +import { Component } from './Component.js'; + +/** + * The data stored by a {@link ButtonComponent} structure based on its {@link (ButtonComponent:class)."style"} property. + */ +export type ButtonDataType