diff --git a/packages/structures/__tests__/channels.test.ts b/packages/structures/__tests__/channels.test.ts index f81474c6520c..5ea7b70e03cd 100644 --- a/packages/structures/__tests__/channels.test.ts +++ b/packages/structures/__tests__/channels.test.ts @@ -39,6 +39,7 @@ import { ThreadMetadata, VoiceChannel, } from '../src/channels/index.js'; +import { dateToDiscordISOTimestamp } from '../src/index.js'; import { kData } from '../src/utils/symbols.js'; describe('text channel', () => { @@ -49,7 +50,7 @@ describe('text channel', () => { position: 0, guild_id: '2', last_message_id: '3', - last_pin_timestamp: '2020-10-10T13:50:17.209Z', + last_pin_timestamp: '2020-10-10T13:50:17.209000+00:00', nsfw: true, parent_id: '4', permission_overwrites: [ @@ -77,7 +78,7 @@ describe('text channel', () => { expect(instance.guildId).toBe(data.guild_id); expect(instance.lastMessageId).toBe(data.last_message_id); expect(instance.lastPinTimestamp).toBe(Date.parse(data.last_pin_timestamp!)); - expect(instance.lastPinAt?.toISOString()).toBe(data.last_pin_timestamp); + expect(dateToDiscordISOTimestamp(instance.lastPinDate!)).toBe(data.last_pin_timestamp); expect(instance.nsfw).toBe(data.nsfw); expect(instance.parentId).toBe(data.parent_id); expect(instance[kData].permission_overwrites).toEqual(data.permission_overwrites); @@ -137,8 +138,8 @@ describe('announcement channel', () => { expect(instance.flags?.toJSON()).toBe(data.flags); expect(instance.guildId).toBe(data.guild_id); expect(instance.lastMessageId).toBe(data.last_message_id); - expect(instance.lastPinTimestamp).toBe(null); - expect(instance.lastPinAt).toBe(data.last_pin_timestamp); + expect(instance.lastPinTimestamp).toBeNull(); + expect(instance.lastPinDate).toBeNull(); expect(instance.nsfw).toBe(data.nsfw); expect(instance.parentId).toBe(data.parent_id); expect(instance[kData].permission_overwrites).toEqual(data.permission_overwrites); @@ -210,7 +211,7 @@ describe('DM channel', () => { id: '1', type: ChannelType.DM, last_message_id: '3', - last_pin_timestamp: '2020-10-10T13:50:17.209Z', + last_pin_timestamp: '2020-10-10T13:50:17.209000+00:00', name: null, }; @@ -234,7 +235,7 @@ describe('DM channel', () => { expect(instance.flags?.toJSON()).toBe(data.flags); expect(instance.lastMessageId).toBe(data.last_message_id); expect(instance.lastPinTimestamp).toBe(Date.parse(data.last_pin_timestamp!)); - expect(instance.lastPinAt?.toISOString()).toBe(data.last_pin_timestamp); + expect(dateToDiscordISOTimestamp(instance.lastPinDate!)).toBe(data.last_pin_timestamp); expect(instance[kData].recipients).toEqual(data.recipients); expect(instance.type).toBe(ChannelType.DM); expect(instance.url).toBe('https://discord.com/channels/@me/1'); @@ -614,7 +615,7 @@ describe('thread channels', () => { const dataAnnounce: APIAnnouncementThreadChannel = { ...dataPublic, thread_metadata: { - archive_timestamp: '2024-09-08T12:01:02.345Z', + archive_timestamp: '2024-09-08T12:01:02.345000+00:00', archived: false, auto_archive_duration: ThreadAutoArchiveDuration.ThreeDays, locked: true, @@ -627,7 +628,7 @@ describe('thread channels', () => { ...dataPublic, thread_metadata: { ...dataAnnounce.thread_metadata!, - create_timestamp: '2023-01-02T15:13:11.987Z', + create_timestamp: '2023-01-02T15:13:11.987000+00:00', invitable: true, }, type: ChannelType.PrivateThread, @@ -730,9 +731,9 @@ describe('thread channels', () => { const instance = new ThreadMetadata(dataPrivate.thread_metadata!); expect(instance.toJSON()).toEqual(dataPrivate.thread_metadata); expect(instance.archived).toBe(dataPrivate.thread_metadata?.archived); - expect(instance.archivedAt?.toISOString()).toBe(dataPrivate.thread_metadata?.archive_timestamp); + expect(dateToDiscordISOTimestamp(instance.archivedDate!)).toBe(dataPrivate.thread_metadata?.archive_timestamp); expect(instance.archivedTimestamp).toBe(Date.parse(dataPrivate.thread_metadata!.archive_timestamp)); - expect(instance.createdAt?.toISOString()).toBe(dataPrivate.thread_metadata?.create_timestamp); + expect(dateToDiscordISOTimestamp(instance.createdDate!)).toBe(dataPrivate.thread_metadata?.create_timestamp); expect(instance.createdTimestamp).toBe(Date.parse(dataPrivate.thread_metadata!.create_timestamp!)); expect(instance.autoArchiveDuration).toBe(dataPrivate.thread_metadata?.auto_archive_duration); expect(instance.invitable).toBe(dataPrivate.thread_metadata?.invitable); diff --git a/packages/structures/__tests__/invite.test.ts b/packages/structures/__tests__/invite.test.ts index 9cf1307c71f9..e9bb6fff37fb 100644 --- a/packages/structures/__tests__/invite.test.ts +++ b/packages/structures/__tests__/invite.test.ts @@ -34,17 +34,17 @@ describe('Invite', () => { const instance = new Invite(data); expect(instance.type).toBe(data.type); expect(instance.code).toBe(data.code); - expect(instance.createdAt).toBe(null); - expect(instance.createdTimestamp).toBe(null); - expect(instance.maxAge).toBe(undefined); - expect(instance.maxUses).toBe(undefined); + expect(instance.createdDate).toBeNull(); + expect(instance.createdTimestamp).toBeNull(); + expect(instance.maxAge).toBeUndefined(); + expect(instance.maxUses).toBeUndefined(); expect(instance.approximateMemberCount).toBe(data.approximate_member_count); expect(instance.approximatePresenceCount).toBe(data.approximate_presence_count); expect(instance.targetType).toBe(data.target_type); - expect(instance.temporary).toBe(undefined); - expect(instance.uses).toBe(undefined); - expect(instance.expiresTimestamp).toBe(null); - expect(instance.expiresAt).toBe(null); + expect(instance.temporary).toBeUndefined(); + expect(instance.uses).toBeUndefined(); + expect(instance.expiresTimestamp).toBeNull(); + expect(instance.expiresDate).toBeNull(); expect(instance.url).toBe('https://discord.gg/123'); expect(instance.toJSON()).toEqual(data); expect(`${instance}`).toBe('https://discord.gg/123'); @@ -53,9 +53,10 @@ describe('Invite', () => { test('extended Invite has all properties', () => { const instance = new Invite(dataExtended); + expect(instance.type).toBe(data.type); expect(instance.code).toBe(dataExtended.code); - expect(dateToDiscordISOTimestamp(instance.createdAt!)).toBe(dataExtended.created_at); + expect(dateToDiscordISOTimestamp(instance.createdDate!)).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); @@ -64,10 +65,14 @@ describe('Invite', () => { expect(instance.targetType).toBe(dataExtended.target_type); expect(instance.temporary).toBe(dataExtended.temporary); expect(instance.uses).toBe(dataExtended.uses); + expect(instance.createdTimestamp).toBe(Date.parse(dataExtended.created_at)); + expect(dateToDiscordISOTimestamp(instance.createdDate!)).toEqual(dataExtended.created_at); 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.expiresDate).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.209000+00:00' }); + expect(instance.toString()).toEqual('https://discord.gg/123'); + expect(instance.valueOf()).toEqual('123'); }); test('Invite with omitted properties', () => { diff --git a/packages/structures/__tests__/message.test.ts b/packages/structures/__tests__/message.test.ts index 5c739f7bd785..d552c2ae13fe 100644 --- a/packages/structures/__tests__/message.test.ts +++ b/packages/structures/__tests__/message.test.ts @@ -105,10 +105,10 @@ describe('message with embeds and attachments', () => { 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(dateToDiscordISOTimestamp(instance.createdDate!)).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(dateToDiscordISOTimestamp(instance.editedDate!)).toBe(data.edited_timestamp); expect(instance.nonce).toBe(data.nonce); expect(instance.pinned).toBe(data.pinned); expect(instance.tts).toBe(data.tts); @@ -445,10 +445,10 @@ describe('message with components', () => { 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(dateToDiscordISOTimestamp(instance.createdDate!)).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(dateToDiscordISOTimestamp(instance.editedDate!)).toBe(data.edited_timestamp); expect(instance.nonce).toBe(data.nonce); expect(instance.pinned).toBe(data.pinned); expect(instance.tts).toBe(data.tts); diff --git a/packages/structures/__tests__/presences.test.ts b/packages/structures/__tests__/presences.test.ts new file mode 100644 index 000000000000..5afe2988417f --- /dev/null +++ b/packages/structures/__tests__/presences.test.ts @@ -0,0 +1,355 @@ +import { + type APIUser, + type GatewayActivity, + type GatewayActivityTimestamps, + type GatewayActivityAssets, + type GatewayActivityButton, + type GatewayActivityParty, + type GatewayActivitySecrets, + type GatewayActivityEmoji, + type GatewayGuildMembersChunkPresence, + type GatewayPresenceClientStatus as GatewayPresenceClientStatusTypedef, + ActivityType, + StatusDisplayType, + ActivityFlags, + PresenceUpdateStatus, + ImageFormat, +} from 'discord-api-types/v10'; +import { describe, test, expect } from 'vitest'; +import { + Activity, + ActivityAssets, + ActivityButton, + ActivityParty, + ActivitySecrets, + ActivityTimestamps, + ClientStatus, + Presence, +} from '../src/index.js'; +import { kPatch } from '../src/utils/symbols.js'; + +const user: APIUser = { + username: 'username', + avatar: '54a38112404a550eab14e01fb7f77c9c', + global_name: 'User', + discriminator: '0000', + id: '3', +}; + +const gatewayPresenceActivityTimestampsData: GatewayActivityTimestamps = { + start: 1_771_670_132, +}; + +const gatewayPresenceActivitySecretsData: GatewayActivitySecrets = { + join: 'djs://join', + match: 'djs://match', +}; + +const gatewayPresenceActivityPartyData: GatewayActivityParty = { + id: '1', + size: [11, 40], +}; + +const gatewayPresenceActivityEmojiData: GatewayActivityEmoji = { + name: 'emoji_name', + animated: false, +}; + +const gatewayPresenceActivityButtonData: GatewayActivityButton = { + label: 'label', + url: 'https://github.com//discordjs/discord.js', +}; + +const gatewayPresenceActivityAssetsData: GatewayActivityAssets = { + large_image: '123456789012345678', + large_text: 'large-text', + large_url: 'https://discord.js.org', + small_image: '123456789012345678', + small_text: 'activity-asset/smallText', + invite_cover_image: '123456789012345670', +}; + +const gatewayPresenceActivityData: GatewayActivity = { + id: '1', + name: 'activity-name', + type: ActivityType.Playing, + url: 'https://github.com//discordjs/discord.js', + created_at: 1_540_381_143_572, + timestamps: gatewayPresenceActivityTimestampsData, + application_id: '121212', + status_display_type: StatusDisplayType.Details, + details: 'activity-details', + details_url: 'https://github.com//discordjs/discord.js', + state: 'activity-state', + state_url: 'https://github.com//discordjs/discord.js', + emoji: gatewayPresenceActivityEmojiData, + party: gatewayPresenceActivityPartyData, + assets: gatewayPresenceActivityAssetsData, + secrets: gatewayPresenceActivitySecretsData, + instance: true, + flags: ActivityFlags.Instance, + buttons: [gatewayPresenceActivityButtonData], +}; + +const gatewayPresenceUpdateData: GatewayGuildMembersChunkPresence = { + user, + activities: [gatewayPresenceActivityData], + status: PresenceUpdateStatus.DoNotDisturb, +}; + +const gatewayPresenceClientStatusData: GatewayPresenceClientStatusTypedef = { + desktop: PresenceUpdateStatus.DoNotDisturb, + mobile: PresenceUpdateStatus.Online, +}; + +describe('gatewayPresences structures', () => { + describe('GatewayPresenceClientStatus sub-structure', () => { + const data = gatewayPresenceClientStatusData; + const instance = new ClientStatus(data); + + test('correct value for all getters', () => { + expect(instance.desktop).toBe(data.desktop); + expect(instance.mobile).toBe(data.mobile); + + expect(instance.web).toBeUndefined(); + }); + + test('toJSON() returns expected values', () => { + expect(instance.toJSON()).toStrictEqual(data); + }); + + test('patching the structure works in-place', () => { + const patched = instance[kPatch]({ + web: PresenceUpdateStatus.DoNotDisturb, + mobile: PresenceUpdateStatus.Idle, + }); + + expect(patched.toJSON()).toStrictEqual(instance.toJSON()); + expect(patched.toJSON()).not.toEqual(data); + + expect(instance.web).toEqual(patched.web); + expect(instance.mobile).toEqual(instance.mobile); + }); + }); + + describe('GatewayPresenceUpdate sub-structure', () => { + const data = gatewayPresenceUpdateData; + const instance = new Presence(data); + + test('correct value for all getters', () => { + expect(instance.status).toBe(data.status); + }); + + test('toJSON() returns expected values', () => { + expect(instance.toJSON()).toStrictEqual(data); + }); + + test('patching the structure works in-place', () => { + const patched = instance[kPatch]({ + status: PresenceUpdateStatus.Online, + }); + + expect(patched).toBe(instance); + expect(patched.toJSON()).toStrictEqual(instance.toJSON()); + expect(patched.toJSON()).not.toEqual(data); + + expect(patched.status).toEqual(PresenceUpdateStatus.Online); + }); + }); + + describe('gatewayPresences sub-structures', () => { + describe('GatewayPresenceActivity sub-structure', () => { + const data = gatewayPresenceActivityData; + const instance = new Activity(data); + + test('correct value for all getters and helper method [createdDate]', () => { + expect(instance.name).toBe(data.name); + expect(instance.type).toBe(data.type); + expect(instance.url).toBe(data.url); + expect(instance.applicationId).toBe(data.application_id); + expect(instance.statusDisplayType).toBe(data.status_display_type); + expect(instance.details).toBe(data.details); + expect(instance.detailsURL).toBe(data.details_url); + expect(instance.state).toBe(data.state); + expect(instance.stateURL).toBe(data.state_url); + expect(instance.instance).toBe(data.instance); + expect(instance.flags!.bitField).toBe(BigInt(data.flags!)); + expect(instance.createdTimestamp).toBe(data.created_at); + expect(instance.createdDate!.valueOf()).toEqual(data.created_at); + }); + + test('toJSON() returns expected values', () => { + expect(instance.toJSON()).toStrictEqual(data); + }); + + test('patching the structure works in-place', () => { + const patched = instance[kPatch]({ + status_display_type: StatusDisplayType.Name, + state: '[PATCHED]-activity-state', + state_url: null, + type: ActivityType.Custom, + }); + + expect(patched.statusDisplayType).toEqual(StatusDisplayType.Name); + expect(patched.state).toEqual('[PATCHED]-activity-state'); + expect(patched.stateURL).toBeNull(); + expect(patched.type).toEqual(ActivityType.Custom); + + expect(patched).toBe(instance); + expect(patched.toJSON()).not.toEqual(data); + expect(patched.toJSON()).toStrictEqual(instance.toJSON()); + }); + }); + + describe('GatewayPresenceActivityAssets sub-structure', () => { + const data = gatewayPresenceActivityAssetsData; + const instance = new ActivityAssets(data); + const applicationId = '23498573429574598'; + + test('correct value for all getters and helper methods [largeImageURL, smallImageURL]', () => { + expect(instance.largeImage).toBe(data.large_image); + expect(instance.largeText).toBe(data.large_text); + expect(instance.largeURL).toBe(data.large_url); + expect(instance.smallImage).toBe(data.small_image); + expect(instance.smallText).toBe(data.small_text); + expect(instance.inviteCoverImage).toBe(data.invite_cover_image); + expect(instance.largeImageURL(applicationId, ImageFormat.JPEG)); + expect(instance.smallImageURL(applicationId, ImageFormat.JPEG)); + + expect(instance.smallURL).toBeUndefined(); + }); + + test('toJSON() returns expected values', () => { + expect(instance.toJSON()).toStrictEqual(data); + }); + + test('patching the structure works in-place', () => { + const patched = instance[kPatch]({ + large_text: 'djs://PATCHED-LARGE-TEXT', + small_url: 'https://discord.js.org/docs/packages/structures/main', + }); + + expect(patched.largeText).toEqual('djs://PATCHED-LARGE-TEXT'); + expect(patched.smallURL).toEqual('https://discord.js.org/docs/packages/structures/main'); + + expect(patched).toBe(instance); + expect(patched.toJSON()).not.toEqual(data); + expect(patched.toJSON()).toStrictEqual(instance.toJSON()); + }); + }); + + describe('GatewayPresenceActivityButton sub-structure', () => { + const data = gatewayPresenceActivityButtonData; + const instance = new ActivityButton(data); + + test('correct value for all getters', () => { + expect(instance.label).toBe(data.label); + expect(instance.url).toBe(data.url); + }); + + test('toJSON() returns expected values', () => { + expect(instance.toJSON()).toStrictEqual(data); + }); + + test('patching the structure works in-place', () => { + const patched = instance[kPatch]({ + label: '[PATCHED]-button-label', + }); + + expect(patched.label).toEqual('[PATCHED]-button-label'); + + expect(patched).toEqual(instance); + expect(patched.toJSON()).not.toEqual(data); + expect(patched.toJSON()).toStrictEqual(instance.toJSON()); + }); + }); + + describe('GatewayPresenceActivityParty sub-structure', () => { + const data = gatewayPresenceActivityPartyData; + const instance = new ActivityParty(data); + + test('correct value for all getters and helper methods [createdTimestamp, createdDate]', () => { + expect(instance.id).toBe(data.id); + expect(instance.currentSize).toBe(data.size![0]); + expect(instance.maximumSize).toEqual(data.size![1]); + }); + + test('toJSON() returns expected values', () => { + expect(instance.toJSON()).toStrictEqual(data); + }); + + test('patching the structure works in-place', () => { + const patched = instance[kPatch]({ + size: [1, 999], + }); + + expect(instance.maximumSize).toBe(999); + expect(instance.currentSize).toBe(1); + + expect(patched).toBe(instance); + expect(patched.toJSON()).not.toEqual(data); + expect(patched.toJSON()).toStrictEqual(instance.toJSON()); + }); + }); + + describe('GatewayPresenceActivitySecrets sub-structure', () => { + const data = gatewayPresenceActivitySecretsData; + const instance = new ActivitySecrets(data); + + test('correct value for all getters', () => { + expect(instance.join).toBe(data.join); + expect(instance.match).toBe(data.match); + + expect(instance.spectate).toBeUndefined(); + }); + + test('toJSON() returns expected values', () => { + expect(instance.toJSON()).toStrictEqual(data); + }); + + test('patching the structure works in-place', () => { + const patched = instance[kPatch]({ + match: 'djs://[PATCHED]-activity-party-match', + spectate: 'djs://[PATCHED-[ADD-PROPERTY]]-spectate', + }); + + expect(patched.match).toEqual('djs://[PATCHED]-activity-party-match'); + expect(patched.spectate).toEqual('djs://[PATCHED-[ADD-PROPERTY]]-spectate'); + + expect(patched).toBe(instance); + expect(patched.toJSON()).not.toEqual(data); + expect(patched.toJSON()).toStrictEqual(instance.toJSON()); + }); + }); + + describe('GatewayPresenceActivityTimestamps sub-structure', () => { + const data = gatewayPresenceActivityTimestampsData; + const instance = new ActivityTimestamps(data); + + test('correct value for all getters', () => { + expect(instance.startTimestamp).toBe(data.start); + expect(instance.startDate?.valueOf()).toStrictEqual(data.start); + + expect(instance.endTimestamp).toBeUndefined(); + expect(instance.endDate).toBeNull(); + }); + + test('toJSON() returns expected values', () => { + expect(instance.toJSON()).toStrictEqual(data); + }); + + test('patching the structure works in-place', () => { + const newTimestamp = 1_771_670_132; + const patched = instance[kPatch]({ + end: newTimestamp, + }); + + expect(patched.endDate?.valueOf()).toStrictEqual(patched.endTimestamp); + + expect(patched).toEqual(instance); + expect(patched.toJSON()).not.toEqual(data); + expect(patched.toJSON()).toStrictEqual(instance.toJSON()); + }); + }); + }); +}); diff --git a/packages/structures/src/bitfields/ActivityFlagsBitField.ts b/packages/structures/src/bitfields/ActivityFlagsBitField.ts new file mode 100644 index 000000000000..08e7bda2531d --- /dev/null +++ b/packages/structures/src/bitfields/ActivityFlagsBitField.ts @@ -0,0 +1,16 @@ +import { ActivityFlags } from 'discord-api-types/v10'; +import { BitField } from './BitField.js'; + +/** + * Data structure that makes it easy to interact with an {@link ActivityFlags} bitfield. + */ +export class ActivityFlagsBitField extends BitField { + /** + * Numeric gateway presence activity flags. + */ + public static override readonly Flags = ActivityFlags; + + public override toJSON() { + return super.toJSON(true); + } +} diff --git a/packages/structures/src/channels/Channel.ts b/packages/structures/src/channels/Channel.ts index ab03b138bc6d..ceee6591b589 100644 --- a/packages/structures/src/channels/Channel.ts +++ b/packages/structures/src/channels/Channel.ts @@ -97,7 +97,7 @@ export class Channel< /** * The time the channel was created at */ - public get createdAt() { + public get createdDate() { const createdTimestamp = this.createdTimestamp; return createdTimestamp ? new Date(createdTimestamp) : null; } diff --git a/packages/structures/src/channels/ThreadMetadata.ts b/packages/structures/src/channels/ThreadMetadata.ts index 433c90122691..1b8fe01ac95e 100644 --- a/packages/structures/src/channels/ThreadMetadata.ts +++ b/packages/structures/src/channels/ThreadMetadata.ts @@ -1,5 +1,6 @@ import type { APIThreadMetadata } from 'discord-api-types/v10'; import { Structure } from '../Structure.js'; +import { dateToDiscordISOTimestamp } from '../utils/optimization.js'; import { kArchiveTimestamp, kCreatedTimestamp, kData } from '../utils/symbols.js'; import type { Partialize } from '../utils/types.js'; @@ -89,7 +90,7 @@ export class ThreadMetadata< /** * The time the thread was archived at */ - public get archivedAt() { + public get archivedDate() { const archivedTimestamp = this.archivedTimestamp; return archivedTimestamp ? new Date(archivedTimestamp) : null; } @@ -97,7 +98,7 @@ export class ThreadMetadata< /** * The time the thread was created at */ - public get createdAt() { + public get createdDate() { const createdTimestamp = this.createdTimestamp; return createdTimestamp ? new Date(createdTimestamp) : null; } @@ -108,11 +109,11 @@ export class ThreadMetadata< public override toJSON() { const data = super.toJSON(); if (this[kArchiveTimestamp]) { - data.archive_timestamp = new Date(this[kArchiveTimestamp]).toISOString(); + data.archive_timestamp = dateToDiscordISOTimestamp(new Date(this[kArchiveTimestamp])); } if (this[kCreatedTimestamp]) { - data.create_timestamp = new Date(this[kCreatedTimestamp]).toISOString(); + data.create_timestamp = dateToDiscordISOTimestamp(new Date(this[kCreatedTimestamp])); } return data; diff --git a/packages/structures/src/channels/mixins/ChannelPinMixin.ts b/packages/structures/src/channels/mixins/ChannelPinMixin.ts index d82051c14b4d..b396e4e04d27 100644 --- a/packages/structures/src/channels/mixins/ChannelPinMixin.ts +++ b/packages/structures/src/channels/mixins/ChannelPinMixin.ts @@ -1,4 +1,5 @@ import type { ChannelType, ThreadChannelType } from 'discord-api-types/v10'; +import { dateToDiscordISOTimestamp } from '../../utils/optimization.js'; import { kLastPinTimestamp, kMixinConstruct, kMixinToJSON } from '../../utils/symbols.js'; import type { Channel, ChannelDataType } from '../Channel.js'; @@ -46,7 +47,7 @@ export class ChannelPinMixin< /** * The Date of when the last pin in the channel happened */ - public get lastPinAt() { + public get lastPinDate() { const lastPinTimestamp = this.lastPinTimestamp; return lastPinTimestamp ? new Date(lastPinTimestamp) : null; } @@ -57,6 +58,8 @@ export class ChannelPinMixin< * @param data - the result of {@link (Structure:class).toJSON} */ protected [kMixinToJSON](data: Partial>) { - data.last_pin_timestamp = this[kLastPinTimestamp] ? new Date(this[kLastPinTimestamp]).toISOString() : null; + data.last_pin_timestamp = this[kLastPinTimestamp] + ? dateToDiscordISOTimestamp(new Date(this[kLastPinTimestamp])) + : null; } } diff --git a/packages/structures/src/emojis/Emoji.ts b/packages/structures/src/emojis/Emoji.ts index 5765008eaacd..7908a7b560b8 100644 --- a/packages/structures/src/emojis/Emoji.ts +++ b/packages/structures/src/emojis/Emoji.ts @@ -81,7 +81,7 @@ export class Emoji extends Structure extends Structure extends Structure< + GatewayPresenceClientStatus, + Omitted +> { + /** + * The template used for removing data from the raw data stored for each client status + */ + public static override readonly DataTemplate: Partial = {}; + + /** + * @param data - The raw data received from the API for the client status + */ + public constructor(data: Partialize) { + super(data); + } + + /** + * The user's status set for an active desktop (Windows, Linux, Mac) application session + */ + public get desktop() { + return this[kData].desktop; + } + + /** + * The user's status set for an active mobile (iOS, Android) application session + */ + public get mobile() { + return this[kData].mobile; + } + + /** + * The user's status set for an active web (browser, bot user) application session + */ + public get web() { + return this[kData].web; + } +} diff --git a/packages/structures/src/presences/Presence.ts b/packages/structures/src/presences/Presence.ts new file mode 100644 index 000000000000..dc2848cb3ff7 --- /dev/null +++ b/packages/structures/src/presences/Presence.ts @@ -0,0 +1,34 @@ +import type { GatewayGuildMembersChunkPresence } from 'discord-api-types/v10'; +import { Structure } from '../Structure.js'; +import { kData } from '../utils/symbols.js'; +import type { Partialize } from '../utils/types.js'; + +/** + * Represents any presence update 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 `Activity`, `ClientStatus` which need to be instantiated and stored by an extending class using it + */ +export class Presence extends Structure< + GatewayGuildMembersChunkPresence, + Omitted +> { + /** + * The template used for removing data from the raw data stored for each presence update + */ + public static override readonly DataTemplate: Partial = {}; + + /** + * @param data - The raw data received from the API for the presence update + */ + public constructor(data: Partialize) { + super(data); + } + + /** + * The user's new status + */ + public get status() { + return this[kData].status; + } +} diff --git a/packages/structures/src/presences/activities/Activity.ts b/packages/structures/src/presences/activities/Activity.ts new file mode 100644 index 000000000000..873856ce5fd4 --- /dev/null +++ b/packages/structures/src/presences/activities/Activity.ts @@ -0,0 +1,127 @@ +import type { ActivityFlags, GatewayActivity, ActivityType } from 'discord-api-types/v10'; +import { Structure } from '../../Structure.js'; +import { ActivityFlagsBitField } from '../../bitfields/ActivityFlagsBitField.js'; +import { kData } from '../../utils/symbols.js'; +import { isFieldSet } from '../../utils/type-guards.js'; +import type { Partialize } from '../../utils/types.js'; + +/** + * Represents any activity 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 `ActivityTimestamps`, `ActivityParty`,`ActivityAssets`, `ActivitySecrets` which need to be instantiated + * and stored by an extending class using it. + * @remarks intentionally does not export `buttons` so that extending classes can resolve `string[]` to `ActivityButton[]` + */ +export class Activity extends Structure { + /** + * The template used for removing data from the raw data stored for each activity + */ + public static override readonly DataTemplate: Partial = {}; + + /** + * @param data - The raw data received from the API for the activity + */ + public constructor(data: Partialize) { + super(data); + } + + /** + * The name of the activity + */ + public get name() { + return this[kData].name; + } + + /** + * The type of this activity + * + * @see {@link https://discord.com/developers/docs/events/gateway-events#activity-object-activity-types} + */ + public get type() { + return this[kData].type; + } + + /** + * Stream URL, is validated when type is {@link ActivityType.Streaming} + */ + public get url() { + return this[kData].url; + } + + /** + * Time of when the activity was added to the user's session + */ + public get createdDate() { + return isFieldSet(this[kData], 'created_at', 'number') ? new Date(this[kData].created_at) : null; + } + + /** + * Unix timestamp (in milliseconds) of when the activity was added to the user's session + */ + public get createdTimestamp() { + return this[kData].created_at; + } + + /** + * Application id for the game + */ + public get applicationId() { + return this[kData].application_id; + } + + /** + * Controls which field is displayed in the user's status text in the member list + * + * @see {@link https://discord.com/developers/docs/events/gateway-events#activity-object-status-display-types} + */ + public get statusDisplayType() { + return this[kData].status_display_type; + } + + /** + * What the player is currently doing + */ + public get details() { + return this[kData].details; + } + + /** + * URL that is linked when clicking on the details text + */ + public get detailsURL() { + return this[kData].details_url; + } + + /** + * User's current party status, or text used for a custom status + */ + public get state() { + return this[kData].state; + } + + /** + * URL that is linked when clicking on the state text + */ + public get stateURL() { + return this[kData].state_url; + } + + /** + * Whether the activity is an instanced game session + */ + public get instance() { + return this[kData].instance; + } + + /** + * Activity flags `OR`d together, describes what the payload includes + * + * @see {@link https://discord.com/developers/docs/events/gateway-events#activity-object-activity-flags} + */ + public get flags() { + return isFieldSet(this[kData], 'flags', 'number') + ? new ActivityFlagsBitField(this[kData].flags as ActivityFlags) + : null; + } +} diff --git a/packages/structures/src/presences/activities/ActivityAssets.ts b/packages/structures/src/presences/activities/ActivityAssets.ts new file mode 100644 index 000000000000..efec5db3ec8a --- /dev/null +++ b/packages/structures/src/presences/activities/ActivityAssets.ts @@ -0,0 +1,106 @@ +import { + CDNRoutes, + ImageFormat, + RouteBases, + type GatewayActivityAssets, + type ApplicationAssetFormat, +} from 'discord-api-types/v10'; +import { Structure } from '../../Structure.js'; +import { kData } from '../../utils/symbols.js'; +import { isFieldSet } from '../../utils/type-guards.js'; +import type { Partialize } from '../../utils/types.js'; + +/** + * Represents any activity assets 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 ActivityAssets extends Structure< + GatewayActivityAssets, + Omitted +> { + /** + * The template used for removing data from the raw data stored for each activity assets + */ + public static override readonly DataTemplate: Partial = {}; + + /** + * @param data - The raw data received from the API for the activity assets + */ + public constructor(data: Partialize) { + super(data); + } + + /** + * @see {@link https://discord.com/developers/docs/events/gateway-events#activity-object-activity-asset-image} + */ + public get largeImage() { + return this[kData].large_image; + } + + /** + * Returns the large image URL of this `largeImage` + * + * @param applicationId - The id of the application to whom this asset belongs to + * @param imageFormat - The desired format of the returned image URL + */ + public largeImageURL(applicationId: string, imageFormat: ApplicationAssetFormat = ImageFormat.PNG) { + return isFieldSet(this, 'largeImage', 'string') + ? `${RouteBases.cdn}${CDNRoutes.applicationAsset(applicationId, this.largeImage, imageFormat)}` + : null; + } + + /** + * Text displayed when hovering over the large image of the activity + */ + public get largeText() { + return this[kData].large_text; + } + + /** + * URL that is opened when clicking on the large image + */ + public get largeURL() { + return this[kData].large_url; + } + + /** + * @see {@link https://discord.com/developers/docs/events/gateway-events#activity-object-activity-asset-image} + */ + public get smallImage() { + return this[kData].small_image; + } + + /** + * Returns the small image URL of this `smallImage` + * + * @param applicationId - The id of the application to whom this asset belongs to + * @param imageFormat - The desired format of the returned image URL + */ + public smallImageURL(applicationId: string, imageFormat: ApplicationAssetFormat = ImageFormat.PNG) { + return isFieldSet(this, 'smallImage', 'string') + ? `${RouteBases.cdn}${CDNRoutes.applicationAsset(applicationId, this.smallImage, imageFormat)}` + : null; + } + + /** + * Text displayed when hovering over the small image of the activity + */ + public get smallText() { + return this[kData].small_text; + } + + /** + * URL that is opened when clicking on the small image + */ + public get smallURL() { + return this[kData].small_url; + } + + /** + * URL of the image which is displayed as a banner on a Game Invite + */ + public get inviteCoverImage() { + return this[kData].invite_cover_image; + } +} diff --git a/packages/structures/src/presences/activities/ActivityButton.ts b/packages/structures/src/presences/activities/ActivityButton.ts new file mode 100644 index 000000000000..7fdcdb6c132c --- /dev/null +++ b/packages/structures/src/presences/activities/ActivityButton.ts @@ -0,0 +1,42 @@ +import type { GatewayActivityButton } from 'discord-api-types/v10'; +import { Structure } from '../../Structure.js'; +import { kData } from '../../utils/symbols.js'; +import type { Partialize } from '../../utils/types.js'; + +/** + * Represents any activity button on Discord. + * + * @typeParam Omitted - Specify the properties that will not be stored in the raw data field as a union, implement via `DataTemplate`. + * @remarks When received over the gateway, the `buttons` field is an array of strings, which are the `button` labels. Bots cannot + * access a user's activity button URLs. When sending, the buttons field must be an array of this structure. + */ +export class ActivityButton extends Structure< + GatewayActivityButton, + Omitted +> { + /** + * The template used for removing data from the raw data stored for each activity button + */ + public static override readonly DataTemplate: Partial = {}; + + /** + * @param data - The raw data received from the API for the activity button + */ + public constructor(data: Partialize) { + super(data); + } + + /** + * Text shown on the button (1-32 characters) + */ + public get label() { + return this[kData].label; + } + + /** + * URL opened when clicking the button (1-512 characters) + */ + public get url() { + return this[kData].url; + } +} diff --git a/packages/structures/src/presences/activities/ActivityParty.ts b/packages/structures/src/presences/activities/ActivityParty.ts new file mode 100644 index 000000000000..18446cdf1003 --- /dev/null +++ b/packages/structures/src/presences/activities/ActivityParty.ts @@ -0,0 +1,54 @@ +import type { GatewayActivityParty } from 'discord-api-types/v10'; +import { Structure } from '../../Structure.js'; +import { kData } from '../../utils/symbols.js'; +import { isArrayFieldSet } from '../../utils/type-guards.js'; +import type { Partialize } from '../../utils/types.js'; + +/** + * Represents any activity party 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 ActivityParty extends Structure< + GatewayActivityParty, + Omitted +> { + /** + * The template used for removing data from the raw data stored for each activity party. + */ + public static override readonly DataTemplate: Partial = {}; + + /** + * @param data - The raw data received from the API for the activity party. + */ + public constructor(data: Partialize) { + super(data); + } + + /** + * The id of the party. + * + * @remarks This is an application-defined id and is not a Discord snowflake. + */ + public get id() { + return this[kData].id; + } + + /** + * The current size of the party + */ + public get currentSize() { + const array = this[kData].size; + + return array ? (isArrayFieldSet(array, 0, 'number') ? array[0] : null) : null; + } + + /** + * The maximum size of the party + */ + public get maximumSize() { + const array = this[kData].size; + + return array ? (isArrayFieldSet(array, 1, 'number') ? array[1] : null) : null; + } +} diff --git a/packages/structures/src/presences/activities/ActivitySecrets.ts b/packages/structures/src/presences/activities/ActivitySecrets.ts new file mode 100644 index 000000000000..136d493adb3a --- /dev/null +++ b/packages/structures/src/presences/activities/ActivitySecrets.ts @@ -0,0 +1,47 @@ +import type { GatewayActivitySecrets } from 'discord-api-types/v10'; +import { Structure } from '../../Structure.js'; +import { kData } from '../../utils/symbols.js'; +import type { Partialize } from '../../utils/types.js'; + +/** + * Represents any activity secrets 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 ActivitySecrets extends Structure< + GatewayActivitySecrets, + Omitted +> { + /** + * The template used for removing data from the raw data stored for each activity's secrets. + */ + public static override readonly DataTemplate: Partial = {}; + + /** + * @param data - The raw data received from the API for the activity's secrets. + */ + public constructor(data: Partialize) { + super(data); + } + + /** + * Secret for joining a party. + */ + public get join() { + return this[kData].join; + } + + /** + * Secret for spectating a game. + */ + public get spectate() { + return this[kData].spectate; + } + + /** + * Secret for a specific instanced match + */ + public get match() { + return this[kData].match; + } +} diff --git a/packages/structures/src/presences/activities/ActivityTimestamps.ts b/packages/structures/src/presences/activities/ActivityTimestamps.ts new file mode 100644 index 000000000000..3c169b95a700 --- /dev/null +++ b/packages/structures/src/presences/activities/ActivityTimestamps.ts @@ -0,0 +1,55 @@ +import type { GatewayActivityTimestamps } from 'discord-api-types/v10'; +import { Structure } from '../../Structure.js'; +import { kData } from '../../utils/symbols.js'; +import { isFieldSet } from '../../utils/type-guards.js'; +import type { Partialize } from '../../utils/types.js'; + +/** + * Represents any activity timestamp 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 ActivityTimestamps extends Structure< + GatewayActivityTimestamps, + Omitted +> { + /** + * The template used for removing data from the raw data stored for each activity timestamp + */ + public static override readonly DataTemplate: Partial = {}; + + /** + * @param data - The raw data received from the API for the activity timestamp + */ + public constructor(data: Partialize) { + super(data); + } + + /** + * Unix time (in milliseconds) of when the activity started. + */ + public get startTimestamp() { + return this[kData].start; + } + + /** + * Time of when the activity started. + */ + public get startDate() { + return isFieldSet(this[kData], 'start', 'number') ? new Date(this[kData].start) : null; + } + + /** + * Unix time (in milliseconds) of when the activity ends. + */ + public get endTimestamp() { + return this[kData].end; + } + + /** + * Time of when the activity ended. + */ + public get endDate() { + return isFieldSet(this[kData], 'end', 'number') ? new Date(this[kData].end) : null; + } +} diff --git a/packages/structures/src/presences/activities/index.ts b/packages/structures/src/presences/activities/index.ts new file mode 100644 index 000000000000..1bf2b767f00f --- /dev/null +++ b/packages/structures/src/presences/activities/index.ts @@ -0,0 +1,6 @@ +export * from './Activity.js'; +export * from './ActivityAssets.js'; +export * from './ActivityButton.js'; +export * from './ActivityParty.js'; +export * from './ActivitySecrets.js'; +export * from './ActivityTimestamps.js'; diff --git a/packages/structures/src/presences/index.ts b/packages/structures/src/presences/index.ts new file mode 100644 index 000000000000..4b2703b9364d --- /dev/null +++ b/packages/structures/src/presences/index.ts @@ -0,0 +1,4 @@ +export * from './activities/index.js'; + +export * from './ClientStatus.js'; +export * from './Presence.js'; diff --git a/packages/structures/src/soundboards/SoundboardSound.ts b/packages/structures/src/soundboards/SoundboardSound.ts index 4f1c29674816..4bdd74a7ab30 100644 --- a/packages/structures/src/soundboards/SoundboardSound.ts +++ b/packages/structures/src/soundboards/SoundboardSound.ts @@ -92,9 +92,8 @@ export class SoundboardSound * * @remarks only available for guild soundboard sounds */ - public get createdAt() { + public get createdDate() { const createdTimestamp = this.createdTimestamp; - return createdTimestamp ? new Date(createdTimestamp) : null; } } diff --git a/packages/structures/src/stageInstances/StageInstance.ts b/packages/structures/src/stageInstances/StageInstance.ts index 6549eb07a3a2..dffb7dffaf45 100644 --- a/packages/structures/src/stageInstances/StageInstance.ts +++ b/packages/structures/src/stageInstances/StageInstance.ts @@ -78,7 +78,7 @@ export class StageInstance ext /** * The time the stage instance was created at */ - public get createdAt() { + public get createdDate() { const createdTimestamp = this.createdTimestamp; return createdTimestamp ? new Date(createdTimestamp) : null; } diff --git a/packages/structures/src/stickers/Sticker.ts b/packages/structures/src/stickers/Sticker.ts index 1bd5c3498191..cc6046381499 100644 --- a/packages/structures/src/stickers/Sticker.ts +++ b/packages/structures/src/stickers/Sticker.ts @@ -82,7 +82,7 @@ export class Sticker extends Structu /** * The time the sticker was created at */ - public get createdAt() { + public get createdDate() { const createdTimestamp = this.createdTimestamp; return createdTimestamp ? new Date(createdTimestamp) : null; } diff --git a/packages/structures/src/stickers/StickerPack.ts b/packages/structures/src/stickers/StickerPack.ts index 3a190d969bd9..c9596dded3ba 100644 --- a/packages/structures/src/stickers/StickerPack.ts +++ b/packages/structures/src/stickers/StickerPack.ts @@ -78,7 +78,7 @@ export class StickerPack extends /** * The time the sticker pack was created at */ - public get createdAt() { + public get createdDate() { const createdTimestamp = this.createdTimestamp; return createdTimestamp ? new Date(createdTimestamp) : null; } diff --git a/packages/structures/src/subscriptions/Subscription.ts b/packages/structures/src/subscriptions/Subscription.ts index c6c56116fb2a..7d34d0e1e826 100644 --- a/packages/structures/src/subscriptions/Subscription.ts +++ b/packages/structures/src/subscriptions/Subscription.ts @@ -110,7 +110,7 @@ export class Subscription< /** * The time at which the current subscription period will start */ - public get currentPeriodStartAt() { + public get currentPeriodstartDate() { const startTimestamp = this.currentPeriodStartTimestamp; return startTimestamp ? new Date(startTimestamp) : null; } @@ -125,7 +125,7 @@ export class Subscription< /** * The time at which the current subscription period will end */ - public get currentPeriodEndsAt() { + public get currentPeriodendsDate() { const endTimestamp = this.currentPeriodEndTimestamp; return endTimestamp ? new Date(endTimestamp) : null; } @@ -149,7 +149,7 @@ export class Subscription< * * @remarks This is populated when the {@link Subscription#status} transitions to {@link SubscriptionStatus.Ending}. */ - public get canceledAt() { + public get canceledDate() { const canceledTimestamp = this.canceledTimestamp; return canceledTimestamp ? new Date(canceledTimestamp) : null; } @@ -171,7 +171,7 @@ export class Subscription< /** * The time the subscription was created at */ - public get createdAt() { + public get createdDate() { const createdTimestamp = this.createdTimestamp; return createdTimestamp ? new Date(createdTimestamp) : null; } diff --git a/packages/structures/src/teams/Team.ts b/packages/structures/src/teams/Team.ts index 76ebb0f75a4a..3b3e71da9fa3 100644 --- a/packages/structures/src/teams/Team.ts +++ b/packages/structures/src/teams/Team.ts @@ -62,7 +62,7 @@ export class Team extends Structure extends Structure>( * Turns a JavaScript Date object into the timestamp format used by Discord in payloads. * E.g. `2025-11-16T14:09:25.239000+00:00` * - * @private - * @param date a Date instance + * @internal + * @param date - a Date instance * @returns an ISO8601 timestamp with microseconds precision and explicit +00:00 timezone */ export function dateToDiscordISOTimestamp(date: Date) { diff --git a/packages/structures/src/utils/type-guards.ts b/packages/structures/src/utils/type-guards.ts index 8fe7129d2a95..e196b7c1f667 100644 --- a/packages/structures/src/utils/type-guards.ts +++ b/packages/structures/src/utils/type-guards.ts @@ -30,3 +30,25 @@ export function isFieldSet( + array: unknown, + targetIndex: Index, + type: Type, +): array is Record & unknown[] { + return Array.isArray(array) + ? array.length >= targetIndex + ? // eslint-disable-next-line valid-typeof + typeof array[targetIndex] === type + : false + : false; +} diff --git a/packages/structures/src/webhooks/Webhook.ts b/packages/structures/src/webhooks/Webhook.ts index 8b8336f9e989..9a12853a685d 100644 --- a/packages/structures/src/webhooks/Webhook.ts +++ b/packages/structures/src/webhooks/Webhook.ts @@ -101,7 +101,7 @@ export class Webhook extends Structu /** * The time the webhook was created at */ - public get createdAt() { + public get createdDate() { const createdTimestamp = this.createdTimestamp; return createdTimestamp ? new Date(createdTimestamp) : null; }