diff --git a/packages/structures/__tests__/channels.test.ts b/packages/structures/__tests__/channels.test.ts index f81474c65..5ea7b70e0 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 9cf1307c7..e9bb6fff3 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 24bae3720..4e583b57d 100644 --- a/packages/structures/__tests__/message.test.ts +++ b/packages/structures/__tests__/message.test.ts @@ -107,10 +107,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); @@ -449,10 +449,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 000000000..408eb907f --- /dev/null +++ b/packages/structures/__tests__/presences.test.ts @@ -0,0 +1,438 @@ +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, + RouteBases, + CDNRoutes, +} from 'discord-api-types/v10'; +import { describe, test, expect, beforeEach } 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: '1091287989151342622', +}; + +const gatewayPresenceActivityTimestampsData: GatewayActivityTimestamps = { + start: 1_771_670_132, +}; + +const gatewayPresenceActivitySecretsData: GatewayActivitySecrets = { + join: '025ed05c71f639de8bfaa0d679d7c94b2fdce12f', + match: '4b2fdce12f639de8bfa7e3591b71a0d679d7c93f', +}; + +const gatewayPresenceActivityPartyData: GatewayActivityParty = { + id: '676544578976545764', + 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: '1006883369382064179', + name: 'activity-name', + type: ActivityType.Playing, + url: 'https://github.com/discordjs/discord.js', + created_at: 1_540_381_143_572, + timestamps: gatewayPresenceActivityTimestampsData, + application_id: '222078108977594368', + 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('Presences structures', () => { + describe('PresenceClientStatus sub-structure', () => { + const data = gatewayPresenceClientStatusData; + let instance: ClientStatus; + + beforeEach(() => { + 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 web = PresenceUpdateStatus.DoNotDisturb; + const mobile = PresenceUpdateStatus.Idle; + + const patched = instance[kPatch]({ + web, + mobile, + }); + + expect(instance.web).toEqual(web); + expect(instance.mobile).toEqual(mobile); + + expect(patched.toJSON()).toEqual({ + ...data, + web, + mobile, + }); + + expect(patched).toBe(instance); + }); + }); + + describe('PresenceUpdate sub-structure', () => { + const data = gatewayPresenceUpdateData; + let instance: Presence; + + beforeEach(() => { + 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 status = PresenceUpdateStatus.Online; + + const patched = instance[kPatch]({ + status, + }); + + expect(patched.status).toEqual(PresenceUpdateStatus.Online); + + expect(patched.toJSON()).toEqual({ + ...data, + status, + }); + + expect(patched).toBe(instance); + }); + }); + + describe('Presences sub-structures', () => { + describe('PresenceActivity sub-structure', () => { + const data = gatewayPresenceActivityData; + let instance: Activity; + + beforeEach(() => { + 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 status_display_type = StatusDisplayType.Name; + const state = '[PATCHED]-activity-state'; + const state_url = null; + const type = ActivityType.Custom; + + const patched = instance[kPatch]({ + status_display_type, + state, + state_url, + type, + }); + + expect(patched.toJSON()).toEqual({ + ...data, + status_display_type, + state, + state_url, + type, + }); + + expect(patched).toBe(instance); + }); + }); + + describe('PresenceActivityAssets sub-structure', () => { + const data = gatewayPresenceActivityAssetsData; + const applicationId = '23498573429574598'; + let instance: ActivityAssets; + + beforeEach(() => { + instance = new ActivityAssets(data); + }); + + 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)).toEqual( + `${RouteBases.cdn}${CDNRoutes.applicationAsset(applicationId, data.large_image!, ImageFormat.JPEG)}`, + ); + expect(instance.smallImageURL(applicationId, ImageFormat.JPEG)).toEqual( + `${RouteBases.cdn}${CDNRoutes.applicationAsset(applicationId, data.small_image!, ImageFormat.JPEG)}`, + ); + + expect(instance.smallURL).toBeUndefined(); + }); + + test('toJSON() returns expected values', () => { + expect(instance.toJSON()).toStrictEqual(data); + }); + + test('patching the structure works in-place', () => { + const large_text = 'large_text_patched_str'; + const small_url = 'https://discord.js.org/docs/packages/structures/main'; + + const patched = instance[kPatch]({ + large_text, + small_url, + }); + + expect(patched.largeText).toEqual(large_text); + expect(patched.smallURL).toEqual(small_url); + + expect(patched).toBe(instance); + expect(patched.toJSON()).toEqual({ + ...data, + large_text, + small_url, + }); + }); + }); + + describe('PresenceActivityButton sub-structure', () => { + const data = gatewayPresenceActivityButtonData; + let instance: ActivityButton; + + beforeEach(() => { + 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 label = '[PATCHED]-button-label'; + + const patched = instance[kPatch]({ + label, + }); + + expect(patched.label).toEqual(label); + + expect(patched).toEqual(instance); + + expect(patched.toJSON()).toEqual({ + ...data, + label, + }); + }); + }); + + describe('PresenceActivityParty sub-structure', () => { + const data = gatewayPresenceActivityPartyData; + let instance: ActivityParty; + + beforeEach(() => { + 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 size: [current_size: number, max_size: number] = [1, 999]; + + const patched = instance[kPatch]({ + size, + }); + + expect(instance.currentSize).toBe(size[0]); + expect(instance.maximumSize).toBe(size[1]); + + expect(patched).toBe(instance); + + expect(patched.toJSON()).toEqual({ + ...data, + size, + }); + }); + }); + + describe('PresenceActivitySecrets sub-structure', () => { + const data = gatewayPresenceActivitySecretsData; + let instance: ActivitySecrets; + + beforeEach(() => { + 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 spectate = 'e7eb30d2ee025ed05c71ea495f770b76454ee4e0'; + const match = '025ed05c71f639de8bfaa0d679d7c94b2fdce12f-new-match'; + + const patched = instance[kPatch]({ + match, + spectate, + }); + + expect(patched.match).toEqual(match); + expect(patched.spectate).toEqual(spectate); + + expect(patched).toBe(instance); + expect(patched.toJSON()).toEqual({ + ...data, + match, + spectate, + }); + }); + }); + + describe('PresenceActivityTimestamps sub-structure', () => { + const data = gatewayPresenceActivityTimestampsData; + let instance: ActivityTimestamps; + + beforeEach(() => { + 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 end = 1_771_670_132; + const patched = instance[kPatch]({ + end, + }); + + expect(patched.endDate?.valueOf()).toStrictEqual(end); + + expect(patched).toEqual(instance); + + expect(patched.toJSON()).toEqual({ + ...data, + end, + }); + }); + }); + }); +}); diff --git a/packages/structures/src/bitfields/ActivityFlagsBitField.ts b/packages/structures/src/bitfields/ActivityFlagsBitField.ts new file mode 100644 index 000000000..08e7bda25 --- /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/bitfields/index.ts b/packages/structures/src/bitfields/index.ts index 694b4815b..c7d0f1c56 100644 --- a/packages/structures/src/bitfields/index.ts +++ b/packages/structures/src/bitfields/index.ts @@ -1,5 +1,6 @@ export * from './BitField.js'; +export * from './ActivityFlagsBitField.js'; export * from './AttachmentFlagsBitField.js'; export * from './ChannelFlagsBitField.js'; export * from './MessageFlagsBitField.js'; diff --git a/packages/structures/src/channels/Channel.ts b/packages/structures/src/channels/Channel.ts index ab03b138b..ceee6591b 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 433c90122..1b8fe01ac 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 d82051c14..b396e4e04 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 5765008ea..7908a7b56 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 000000000..dc2848cb3 --- /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 000000000..c96ef191e --- /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://docs.discord.com/developers/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://docs.discord.com/developers/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://docs.discord.com/developers/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 000000000..dfad7cb21 --- /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://docs.discord.com/developers/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[kData], 'large_image', 'string') + ? `${RouteBases.cdn}${CDNRoutes.applicationAsset(applicationId, this[kData].large_image, 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://docs.discord.com/developers/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[kData], 'small_image', 'string') + ? `${RouteBases.cdn}${CDNRoutes.applicationAsset(applicationId, this[kData].small_image, 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 000000000..7fdcdb6c1 --- /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 000000000..18446cdf1 --- /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 000000000..136d493ad --- /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 000000000..3c169b95a --- /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 000000000..1bf2b767f --- /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 000000000..4b2703b93 --- /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 4f1c29674..4bdd74a7a 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 6549eb07a..dffb7dffa 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 1bd5c3498..cc6046381 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 3a190d969..c9596dded 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 939e4e36e..5c17e029f 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 currentPeriodEndDate() { 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 discord-api-types/v10#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 76ebb0f75..3b3e71da9 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` * + * @internal * @param date - a Date instance * @returns an ISO8601 timestamp with microseconds precision and explicit +00:00 timezone */ diff --git a/packages/structures/src/utils/type-guards.ts b/packages/structures/src/utils/type-guards.ts index 8fe7129d2..9d035f903 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 8b8336f9e..9a12853a6 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; }