feat: @discordjs/structures (#10900)

* chore: init /structures

* feat: base structure

* feat: initial structures design attempt

* refactor(Structure): use unknown to store in kData

* feat(Structure): add Invite

refactor(Structure): patch to _patch

* refactor: symbol names and override location

* fix: don't possibly return 0 if discord borks

Co-authored-by: Synbulat Biishev <signin@syjalo.dev>

* refactor: use getter value instead of api

Co-authored-by: Synbulat Biishev <signin@syjalo.dev>

* refactor: cache createdTimestamp value

Co-authored-by: Qjuh <76154676+Qjuh@users.noreply.github.com>

* docs: better docs for what's done so far

* feat: add Mixin

* refactor(User): remove bitfield getters and add displayName

* feat(structures): add Connection

* feat(structures): add Channel base

* refactor(Mixin): trace prototype chain, allow construction

* fix(structures): fix mixin behavior

* fix(structures): data optimization call behavior from perf testing

* feat: channel mixins

* chore: update deps

* feat: channels and mixins

* chore: more typeguard tests

* fix: tests and some other issues

* feat: add ChannelWebhookMixin

* fix: more tests

* chore: tests and docs

* chore: docs

* fix: remove unneccessary omitted

* chore: apply code suggestions

* refactor: change how extended invite works

* fix: type imports

* Apply suggestions from code review

Co-authored-by: Almeida <github@almeidx.dev>

* fix: tests

* chore: add jsdoc

* refactor: apply code suggestions

* fix: don't instantiate sub-structures

* fix: don't do null default twice

* chore: use formatters, add _cache

* chore: lockfile

* chore: move MixinTypes to declaratiion file

* fix: tests

* fix: don't include source d.ts files for docs

* feat: bitfields

* feat: more bitfields

* refactor: remove DirectoryChannel structure

* chore: apply suggestions from code review

* chore: remove unused import

* refactor: use symbol for mixin toJSON, remove _ prefix

* chore: apply suggestions from code review

* refactor: remove bitfield casts

* refactor: remove special case for threadchannel types

* fix: apply code review suggestions

* refactor: bitfields always store bigint

* fix: tests

* chore: apply suggestions from code review

* fix: lint

* refactor: conditional structuredClone

* Apply suggestions from code review

Co-authored-by: ckohen <chaikohen@gmail.com>

* fix: code review errors

* fix: lint

* chore: bump dtypes

* Update packages/structures/cliff.toml

Co-authored-by: Jiralite <33201955+Jiralite@users.noreply.github.com>

* docs: link to VideoQualityMode

* chore: typo in comment

* chore: small nits in docs links

* chore: small nits

* docs: forgot one

* chore: update template

* chore: typos and things

* chore: apply suggestions from code review

* fix: tests and typeguards

* chore: don't clone appliedTags

* refactor: use a symbol for patch method

* fix: add missing readonly

* chore: remove todo comment

* refactor: use symbol for clone

* fix: add constraint to DataType

* chore: apply suggestions

* fix: dtypes bump

* chore: fix comment

* chore: add todo comment

* chore: mark bitfield as todo
chore: mark bit field as todo and edit readme

---------

Co-authored-by: ckohen <chaikohen@gmail.com>
Co-authored-by: Synbulat Biishev <signin@syjalo.dev>
Co-authored-by: Almeida <github@almeidx.dev>
Co-authored-by: Jiralite <33201955+Jiralite@users.noreply.github.com>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
This commit is contained in:
Qjuh
2025-07-12 20:24:30 +02:00
committed by GitHub
parent 591668099e
commit 3cff4d7412
89 changed files with 4593 additions and 15 deletions

View File

@@ -0,0 +1,6 @@
{
"name": "structures",
"org": "discordjs",
"packagePath": "packages/structures",
"identifierBase": false
}

28
packages/structures/.gitignore vendored Normal file
View File

@@ -0,0 +1,28 @@
# Packages
node_modules
# Log files
logs
*.log
npm-debug.log*
# Runtime data
pids
*.pid
*.seed
# Env
.env
# Dist
dist
dist-docs
# Docs
docs/**/*
!docs/README.md
# Miscellaneous
.turbo
.tmp
coverage

View File

@@ -0,0 +1,2 @@
/** @type {import('lint-staged').Config} */
module.exports = require('../../.lintstagedrc.json');

View File

@@ -0,0 +1,7 @@
.turbo
coverage
dist
dist-docs
docs/docs.api.json
CHANGELOG.md
tsup.config.bundled*

View File

@@ -0,0 +1,2 @@
/** @type {import('prettier').Config} */
module.exports = require('../../.prettierrc.json');

191
packages/structures/LICENSE Normal file
View File

@@ -0,0 +1,191 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
Copyright 2023 Noel Buechler
Copyright 2023 Chai Kohen
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@@ -0,0 +1,69 @@
<div align="center">
<br />
<p>
<a href="https://discord.js.org"><img src="https://discord.js.org/static/logo.svg" width="546" alt="discord.js" /></a>
</p>
<br />
<p>
<a href="https://discord.gg/djs"><img src="https://img.shields.io/discord/222078108977594368?color=5865F2&logo=discord&logoColor=white" alt="Discord server" /></a>
<a href="https://www.npmjs.com/package/@discordjs/structures"><img src="https://img.shields.io/npm/v/@discordjs/structures.svg?maxAge=3600" alt="npm version" /></a>
<a href="https://www.npmjs.com/package/@discordjs/structures"><img src="https://img.shields.io/npm/dt/@discordjs/structures.svg?maxAge=3600" alt="npm downloads" /></a>
<a href="https://github.com/discordjs/discord.js/actions"><img src="https://github.com/discordjs/discord.js/actions/workflows/tests.yml/badge.svg" alt="Tests status" /></a>
<a href="https://github.com/discordjs/discord.js/commits/main/packages/structures"><img alt="Last commit." src="https://img.shields.io/github/last-commit/discordjs/discord.js?logo=github&logoColor=ffffff&path=packages%2Fstructures" /></a>
<a href="https://codecov.io/gh/discordjs/discord.js"><img src="https://codecov.io/gh/discordjs/discord.js/branch/main/graph/badge.svg?precision=2&flag=structures" alt="Code coverage" /></a>
</p>
<p>
<a href="https://vercel.com/?utm_source=discordjs&utm_campaign=oss"><img src="https://raw.githubusercontent.com/discordjs/discord.js/main/.github/powered-by-vercel.svg" alt="Vercel" /></a>
<a href="https://www.cloudflare.com"><img src="https://raw.githubusercontent.com/discordjs/discord.js/main/.github/powered-by-workers.png" alt="Cloudflare Workers" height="44" /></a>
</p>
</div>
## About
`@discordjs/structures` is a low level wrapper around Discord JSON Objects, meant to be a foundation to build upon in a higher level library.
## Installation
**Node.js 22.12.0 or newer is required.**
```sh
npm install @discordjs/structures
yarn add @discordjs/structures
pnpm add @discordjs/structures
bun add @discordjs/structures
```
## Links
- [Website][website] ([source][website-source])
- [Documentation][documentation]
- [Guide][guide] ([source][guide-source])
Also see the v13 to v14 [Update Guide][guide-update], which includes updated and removed items from the library.
- [discord.js Discord server][discord]
- [Discord Developers Discord server][discord-developers]
- [GitHub][source]
- [npm][npm]
- [Related libraries][related-libs]
## Contributing
Before creating an issue, please ensure that it hasn't already been reported/suggested, and double-check the
[documentation][documentation].
See [the contribution guide][contributing] if you'd like to submit a PR.
## Help
If you don't understand something in the documentation, you are experiencing problems, or you just need a gentle nudge in the right direction, please don't hesitate to join our official [discord.js Server][discord].
[website]: https://discord.js.org
[website-source]: https://github.com/discordjs/discord.js/tree/main/apps/website
[documentation]: https://discord.js.org/docs/packages/structures/stable
[guide]: https://discordjs.guide/
[guide-source]: https://github.com/discordjs/guide
[guide-update]: https://discordjs.guide/additional-info/changes-in-v14.html
[discord]: https://discord.gg/djs
[discord-developers]: https://discord.gg/discord-developers
[source]: https://github.com/discordjs/discord.js/tree/main/packages/structures
[npm]: https://www.npmjs.com/package/@discordjs/structures
[related-libs]: https://discord.com/developers/docs/topics/community-resources#libraries
[contributing]: https://github.com/discordjs/discord.js/blob/main/.github/CONTRIBUTING.md

View File

@@ -0,0 +1,98 @@
import { describe, test, expect } from 'vitest';
import { kData, kPatch } from '../src/utils/symbols.js';
import type { APIData } from './mixinClasses.js';
import { Base, Mixed, MixedWithExtended } from './mixinClasses.js';
describe('Mixin function', () => {
const data: APIData = {
id: '1',
property1: 23,
};
test('Mixed class has all getters', () => {
const instance = new Mixed(data);
expect(instance.id).toBe(data.id);
expect(instance.property1).toBe(data.property1);
expect(instance.property2).toBe(data.property2);
});
test('Mixed class has all methods', () => {
const instance = new Mixed(data);
expect(instance.getId()).toBe(data.id);
expect(instance.getProperty1()).toBe(data.property1);
expect(instance.getProperty2()).toBe(data.property2);
expect(instance.getProperties()).toEqual({
property1: data.property1,
property2: data.property2,
});
});
test('Mixed with extended class has all getters', () => {
const instance = new MixedWithExtended(data);
expect(instance.id).toBe(data.id);
expect(instance.property1).toBe(data.property1);
expect(instance.property2).toBe(data.property2);
expect(instance.isExtended).toBe(true);
});
test('Mixed with extended class has all methods', () => {
const instance = new MixedWithExtended(data);
expect(instance.getId()).toBe(data.id);
expect(instance.getProperty1()).toBe(data.property1);
expect(instance.getProperty2()).toBe(data.property2);
expect(instance.getProperties()).toEqual({
property1: data.property1,
property2: data.property2,
});
});
test('Mixed class calls construct methods on construct', () => {
const instance1 = new Mixed(data);
const instance2 = new MixedWithExtended(data);
expect(instance1.constructCalled).toBe(true);
expect(instance2.constructCalled).toBe(true);
});
test('Mixed class respects mixin data optimizations', () => {
expect(typeof Object.getOwnPropertyDescriptor(Mixed.DataTemplate, 'mixinOptimize')?.set).toBe('function');
const missingOptimizedInstance = new Mixed(data);
const alreadyOptimizedInstance = new Mixed({ ...data, mixinOptimize: 'true', baseOptimize: 'true' });
const baseOptimizedInstance = new Base({ ...data, mixinOptimize: 'true', baseOptimize: 'true' });
expect(missingOptimizedInstance.baseOptimize).toBe(null);
expect(missingOptimizedInstance.mixinOptimize).toBe(null);
// Setters pass this
expect('baseOptimize' in missingOptimizedInstance[kData]).toBe(true);
expect('mixinOptimize' in missingOptimizedInstance[kData]).toBe(true);
expect(missingOptimizedInstance[kData].baseOptimize).toBeUndefined();
expect(missingOptimizedInstance[kData].mixinOptimize).toBeUndefined();
expect(alreadyOptimizedInstance.baseOptimize).toBe(true);
expect(alreadyOptimizedInstance.mixinOptimize).toBe(true);
// Setters pass this
expect('baseOptimize' in alreadyOptimizedInstance[kData]).toBe(true);
expect('mixinOptimize' in alreadyOptimizedInstance[kData]).toBe(true);
expect(alreadyOptimizedInstance[kData].baseOptimize).toBeUndefined();
expect(alreadyOptimizedInstance[kData].mixinOptimize).toBeUndefined();
expect(alreadyOptimizedInstance.toJSON()).toEqual({ ...data, mixinOptimize: 'true', baseOptimize: 'true' });
alreadyOptimizedInstance[kPatch]({ mixinOptimize: '', baseOptimize: '' });
expect(alreadyOptimizedInstance.baseOptimize).toBe(false);
expect(alreadyOptimizedInstance.mixinOptimize).toBe(false);
// Setters pass this
expect('baseOptimize' in alreadyOptimizedInstance[kData]).toBe(true);
expect('mixinOptimize' in alreadyOptimizedInstance[kData]).toBe(true);
expect(alreadyOptimizedInstance[kData].baseOptimize).toBeUndefined();
expect(alreadyOptimizedInstance[kData].mixinOptimize).toBeUndefined();
// Ensure mixin optimizations don't happen on base (ie overwritten DataTemplate)
expect(baseOptimizedInstance.baseOptimize).toBe(true);
expect('mixinOptimize' in baseOptimizedInstance).toBe(false);
// Setters pass this
expect('baseOptimize' in baseOptimizedInstance[kData]).toBe(true);
expect('mixinOptimize' in baseOptimizedInstance[kData]).toBe(true);
expect(baseOptimizedInstance[kData].baseOptimize).toBeUndefined();
expect(baseOptimizedInstance[kData].mixinOptimize).toBe('true');
});
});

View File

@@ -0,0 +1,65 @@
import { describe, test, expect, beforeEach } from 'vitest';
import { DataTemplatePropertyName, OptimizeDataPropertyName, Structure } from '../src/Structure.js';
import { kData, kPatch } from '../src/utils/symbols.js';
describe('Base Structure', () => {
const data = { test: true, patched: false, removed: true };
let struct: Structure<typeof data>;
beforeEach(() => {
// @ts-expect-error Structure constructor is protected
struct = new Structure(data);
// @ts-expect-error Structure.DataTemplate is protected
Structure.DataTemplate = {};
});
test('Data reference is not identical (clone via Object.assign)', () => {
expect(struct[kData]).not.toBe(data);
expect(struct[kData]).toEqual(data);
});
test('Remove properties via template (constructor)', () => {
// @ts-expect-error Structure.DataTemplate is protected
Structure.DataTemplate = { set removed(_) {} };
// @ts-expect-error Structure constructor is protected
const templatedStruct: Structure<typeof data> = new Structure(data);
expect(templatedStruct[kData].removed).toBe(undefined);
// Setters still exist and pass "in" test unfortunately
expect('removed' in templatedStruct[kData]).toBe(true);
expect(templatedStruct[kData]).toEqual({ test: true, patched: false });
});
test('patch clones data and updates in place', () => {
const dataBefore = struct[kData];
const patched = struct[kPatch]({ patched: true });
expect(patched[kData].patched).toBe(true);
// Patch in place
expect(struct[kData]).toBe(patched[kData]);
// Clones
expect(dataBefore.patched).toBe(false);
expect(dataBefore).not.toBe(patched[kData]);
});
test('Remove properties via template ([kPatch])', () => {
// @ts-expect-error Structure.DataTemplate is protected
Structure.DataTemplate = { set removed(_) {} };
// @ts-expect-error Structure constructor is protected
const templatedStruct: Structure<typeof data> = new Structure(data);
templatedStruct[kPatch]({ removed: false });
expect(templatedStruct[kData].removed).toBe(undefined);
// Setters still exist and pass "in" test unfortunately
expect('removed' in templatedStruct[kData]).toBe(true);
expect(templatedStruct[kData]).toEqual({ test: true, patched: false });
});
test('toJSON clones but retains data equality', () => {
const json = struct.toJSON();
expect(json).not.toBe(data);
expect(json).not.toBe(struct[kData]);
expect(struct[kData]).toEqual(json);
});
test("XPropertyName variable matches the actual property's names", () => {
expect(Structure[DataTemplatePropertyName]).toStrictEqual({});
expect(struct[OptimizeDataPropertyName]).toBeTypeOf('function');
});
});

View File

@@ -0,0 +1,741 @@
import type {
APIAnnouncementThreadChannel,
APIDMChannel,
APIGroupDMChannel,
APIGuildCategoryChannel,
APIGuildForumChannel,
APIGuildMediaChannel,
APIGuildStageVoiceChannel,
APIGuildVoiceChannel,
APINewsChannel,
APIPrivateThreadChannel,
APIPublicThreadChannel,
APITextChannel,
} from 'discord-api-types/v10';
import {
ForumLayoutType,
SortOrderType,
ChannelType,
OverwriteType,
ThreadAutoArchiveDuration,
VideoQualityMode,
ChannelFlags,
} from 'discord-api-types/v10';
import { describe, expect, test } from 'vitest';
import {
AnnouncementChannel,
AnnouncementThreadChannel,
CategoryChannel,
DMChannel,
ForumChannel,
ForumTag,
GroupDMChannel,
MediaChannel,
PermissionOverwrite,
PrivateThreadChannel,
PublicThreadChannel,
StageChannel,
TextChannel,
ThreadMetadata,
VoiceChannel,
} from '../src/index.js';
import { kData } from '../src/utils/symbols.js';
describe('text channel', () => {
const data: APITextChannel = {
id: '1',
name: 'test',
type: ChannelType.GuildText,
position: 0,
guild_id: '2',
last_message_id: '3',
last_pin_timestamp: '2020-10-10T13:50:17.209Z',
nsfw: true,
parent_id: '4',
permission_overwrites: [
{
allow: '123',
deny: '456',
type: OverwriteType.Member,
id: '5',
},
],
rate_limit_per_user: 9,
topic: 'hello',
default_auto_archive_duration: ThreadAutoArchiveDuration.OneHour,
default_thread_rate_limit_per_user: 30,
};
test('TextChannel has all properties', () => {
const instance = new TextChannel(data);
expect(instance.id).toBe(data.id);
expect(instance.name).toBe(data.name);
expect(instance.position).toBe(data.position);
expect(instance.defaultAutoArchiveDuration).toBe(data.default_auto_archive_duration);
expect(instance.defaultThreadRateLimitPerUser).toBe(data.default_thread_rate_limit_per_user);
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(Date.parse(data.last_pin_timestamp!));
expect(instance.lastPinAt?.toISOString()).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);
expect(instance.rateLimitPerUser).toBe(data.rate_limit_per_user);
expect(instance.topic).toBe(data.topic);
expect(instance.type).toBe(ChannelType.GuildText);
expect(instance.url).toBe('https://discord.com/channels/2/1');
expect(instance.toJSON()).toEqual(data);
});
test('type guards', () => {
const instance = new TextChannel(data);
expect(instance.isDMBased()).toBe(false);
expect(instance.isGuildBased()).toBe(true);
expect(instance.isPermissionCapable()).toBe(true);
expect(instance.isTextBased()).toBe(true);
expect(instance.isThread()).toBe(false);
expect(instance.isThreadOnly()).toBe(false);
expect(instance.isVoiceBased()).toBe(false);
expect(instance.isWebhookCapable()).toBe(true);
});
test('PermissionOverwrite sub-structure', () => {
const instances = data.permission_overwrites?.map((overwrite) => new PermissionOverwrite(overwrite));
expect(instances?.map((overwrite) => overwrite.toJSON())).toEqual(data.permission_overwrites);
expect(instances?.[0]?.allow?.toJSON()).toBe(data.permission_overwrites?.[0]?.allow);
expect(instances?.[0]?.deny?.toJSON()).toBe(data.permission_overwrites?.[0]?.deny);
expect(instances?.[0]?.id).toBe(data.permission_overwrites?.[0]?.id);
expect(instances?.[0]?.type).toBe(data.permission_overwrites?.[0]?.type);
});
});
describe('announcement channel', () => {
const data: APINewsChannel = {
id: '1',
name: 'test',
type: ChannelType.GuildAnnouncement,
position: 0,
guild_id: '2',
last_message_id: '3',
last_pin_timestamp: null,
nsfw: true,
parent_id: '4',
rate_limit_per_user: 9,
topic: 'hello',
default_auto_archive_duration: ThreadAutoArchiveDuration.OneHour,
default_thread_rate_limit_per_user: 30,
};
test('AnnouncementChannel has all properties', () => {
const instance = new AnnouncementChannel(data);
expect(instance.id).toBe(data.id);
expect(instance.name).toBe(data.name);
expect(instance.position).toBe(data.position);
expect(instance.defaultAutoArchiveDuration).toBe(data.default_auto_archive_duration);
expect(instance.defaultThreadRateLimitPerUser).toBe(data.default_thread_rate_limit_per_user);
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.nsfw).toBe(data.nsfw);
expect(instance.parentId).toBe(data.parent_id);
expect(instance[kData].permission_overwrites).toEqual(data.permission_overwrites);
expect(instance.rateLimitPerUser).toBe(data.rate_limit_per_user);
expect(instance.topic).toBe(data.topic);
expect(instance.type).toBe(ChannelType.GuildAnnouncement);
expect(instance.url).toBe('https://discord.com/channels/2/1');
expect(instance.toJSON()).toEqual(data);
});
test('type guards', () => {
const instance = new AnnouncementChannel(data);
expect(instance.isDMBased()).toBe(false);
expect(instance.isGuildBased()).toBe(true);
expect(instance.isPermissionCapable()).toBe(true);
expect(instance.isTextBased()).toBe(true);
expect(instance.isThread()).toBe(false);
expect(instance.isThreadOnly()).toBe(false);
expect(instance.isVoiceBased()).toBe(false);
expect(instance.isWebhookCapable()).toBe(true);
});
});
describe('category channel', () => {
const data: APIGuildCategoryChannel = {
id: '1',
name: 'test',
type: ChannelType.GuildCategory,
position: 0,
guild_id: '2',
permission_overwrites: [
{
allow: '123',
deny: '456',
type: OverwriteType.Member,
id: '5',
},
],
};
test('CategoryChannel has all properties', () => {
const instance = new CategoryChannel(data);
expect(instance.id).toBe(data.id);
expect(instance.name).toBe(data.name);
expect(instance.position).toBe(data.position);
expect(instance.flags?.toJSON()).toBe(data.flags);
expect(instance.guildId).toBe(data.guild_id);
expect(instance[kData].permission_overwrites).toEqual(data.permission_overwrites);
expect(instance.type).toBe(ChannelType.GuildCategory);
expect(instance.url).toBe('https://discord.com/channels/2/1');
expect(instance.toJSON()).toEqual(data);
});
test('type guards', () => {
const instance = new CategoryChannel(data);
expect(instance.isDMBased()).toBe(false);
expect(instance.isGuildBased()).toBe(true);
expect(instance.isPermissionCapable()).toBe(true);
expect(instance.isTextBased()).toBe(false);
expect(instance.isThread()).toBe(false);
expect(instance.isThreadOnly()).toBe(false);
expect(instance.isVoiceBased()).toBe(false);
expect(instance.isWebhookCapable()).toBe(false);
});
});
describe('DM channel', () => {
const dataNoRecipients: APIDMChannel = {
id: '1',
type: ChannelType.DM,
last_message_id: '3',
last_pin_timestamp: '2020-10-10T13:50:17.209Z',
name: null,
};
const data = {
...dataNoRecipients,
recipients: [
{
avatar: '123',
discriminator: '0',
global_name: 'tester',
id: '1',
username: 'test',
},
],
};
test('DMChannel has all properties', () => {
const instance = new DMChannel(data);
expect(instance.id).toBe(data.id);
expect(instance.name).toBe(data.name);
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(instance[kData].recipients).toEqual(data.recipients);
expect(instance.type).toBe(ChannelType.DM);
expect(instance.url).toBe('https://discord.com/channels/@me/1');
expect(instance.toJSON()).toEqual(data);
});
test('DMChannel with no recipients', () => {
const instance = new DMChannel(dataNoRecipients);
expect(instance[kData].recipients).toEqual(dataNoRecipients.recipients);
expect(instance.toJSON()).toEqual(dataNoRecipients);
});
test('type guards', () => {
const instance = new DMChannel(data);
expect(instance.isDMBased()).toBe(true);
expect(instance.isGuildBased()).toBe(false);
expect(instance.isPermissionCapable()).toBe(false);
expect(instance.isTextBased()).toBe(true);
expect(instance.isThread()).toBe(false);
expect(instance.isThreadOnly()).toBe(false);
expect(instance.isVoiceBased()).toBe(false);
expect(instance.isWebhookCapable()).toBe(false);
});
});
describe('GroupDM channel', () => {
const data: APIGroupDMChannel = {
id: '1',
type: ChannelType.GroupDM,
last_message_id: '3',
name: 'name',
recipients: [
{
avatar: '123',
discriminator: '0',
global_name: 'tester',
id: '1',
username: 'test',
},
],
last_pin_timestamp: null,
application_id: '34',
icon: 'abc',
managed: true,
owner_id: '567',
};
test('GroupDMChannel has all properties', () => {
const instance = new GroupDMChannel(data);
expect(instance.id).toBe(data.id);
expect(instance.name).toBe(data.name);
expect(instance.flags?.toJSON()).toBe(data.flags);
expect(instance.lastMessageId).toBe(data.last_message_id);
expect(instance[kData].recipients).toEqual(data.recipients);
expect(instance.applicationId).toBe(data.application_id);
expect(instance.managed).toBe(data.managed);
expect(instance.ownerId).toBe(data.owner_id);
expect(instance.type).toBe(ChannelType.GroupDM);
expect(instance.icon).toBe(data.icon);
expect(instance.url).toBe('https://discord.com/channels/@me/1');
expect(instance.toJSON()).toEqual(data);
});
test('type guards', () => {
const instance = new GroupDMChannel(data);
expect(instance.isDMBased()).toBe(true);
expect(instance.isGuildBased()).toBe(false);
expect(instance.isPermissionCapable()).toBe(false);
expect(instance.isTextBased()).toBe(true);
expect(instance.isThread()).toBe(false);
expect(instance.isThreadOnly()).toBe(false);
expect(instance.isVoiceBased()).toBe(false);
expect(instance.isWebhookCapable()).toBe(false);
});
});
describe('forum channel', () => {
const dataNoTags: Omit<APIGuildForumChannel, 'available_tags'> = {
id: '1',
name: 'test',
type: ChannelType.GuildForum,
position: 0,
guild_id: '2',
nsfw: true,
parent_id: '4',
permission_overwrites: [
{
allow: '123',
deny: '456',
type: OverwriteType.Member,
id: '5',
},
],
topic: 'hello',
default_auto_archive_duration: ThreadAutoArchiveDuration.OneHour,
default_thread_rate_limit_per_user: 30,
default_forum_layout: ForumLayoutType.GalleryView,
default_reaction_emoji: {
emoji_id: '159',
emoji_name: null,
},
default_sort_order: SortOrderType.LatestActivity,
};
const data: APIGuildForumChannel = {
...dataNoTags,
available_tags: [
{
name: 'emoji',
emoji_name: '😀',
moderated: false,
id: '789',
emoji_id: null,
},
],
};
test('ForumChannel has all properties', () => {
const instance = new ForumChannel(data);
expect(instance.id).toBe(data.id);
expect(instance.name).toBe(data.name);
expect(instance.position).toBe(data.position);
expect(instance.defaultAutoArchiveDuration).toBe(data.default_auto_archive_duration);
expect(instance.defaultThreadRateLimitPerUser).toBe(data.default_thread_rate_limit_per_user);
expect(instance.flags?.toJSON()).toBe(data.flags);
expect(instance.guildId).toBe(data.guild_id);
expect(instance.nsfw).toBe(data.nsfw);
expect(instance.parentId).toBe(data.parent_id);
expect(instance[kData].permission_overwrites).toEqual(data.permission_overwrites);
expect(instance.defaultForumLayout).toBe(data.default_forum_layout);
expect(instance.defaultReactionEmoji).toBe(data.default_reaction_emoji);
expect(instance.defaultSortOrder).toBe(data.default_sort_order);
expect(instance[kData].available_tags).toEqual(data.available_tags);
expect(instance.topic).toBe(data.topic);
expect(instance.type).toBe(ChannelType.GuildForum);
expect(instance.url).toBe('https://discord.com/channels/2/1');
expect(instance.toJSON()).toEqual(data);
});
test('type guards', () => {
const instance = new ForumChannel(data);
expect(instance.isDMBased()).toBe(false);
expect(instance.isGuildBased()).toBe(true);
expect(instance.isPermissionCapable()).toBe(true);
expect(instance.isTextBased()).toBe(false);
expect(instance.isThread()).toBe(false);
expect(instance.isThreadOnly()).toBe(true);
expect(instance.isVoiceBased()).toBe(false);
expect(instance.isWebhookCapable()).toBe(true);
});
test('ForumTag has all properties', () => {
const instances = data.available_tags.map((tag) => new ForumTag(tag));
expect(instances.map((tag) => tag.toJSON())).toEqual(data.available_tags);
expect(instances[0]?.id).toBe(data.available_tags[0]?.id);
expect(instances[0]?.emojiId).toBe(data.available_tags[0]?.emoji_id);
expect(instances[0]?.emojiName).toBe(data.available_tags[0]?.emoji_name);
expect(instances[0]?.name).toBe(data.available_tags[0]?.name);
expect(instances[0]?.moderated).toBe(data.available_tags[0]?.moderated);
expect(instances[0]?.emoji).toBe(data.available_tags[0]?.emoji_name);
});
test('omitted property from ForumChannel', () => {
const instance = new ForumChannel(dataNoTags);
expect(instance.toJSON()).toEqual(dataNoTags);
});
});
describe('media channel', () => {
const data: APIGuildMediaChannel = {
id: '1',
name: 'test',
type: ChannelType.GuildMedia,
position: 0,
guild_id: '2',
nsfw: true,
parent_id: '4',
permission_overwrites: [
{
allow: '123',
deny: '456',
type: OverwriteType.Member,
id: '5',
},
],
topic: 'hello',
default_auto_archive_duration: ThreadAutoArchiveDuration.OneHour,
default_thread_rate_limit_per_user: 30,
available_tags: [
{
name: 'emoji',
emoji_name: null,
moderated: false,
id: '789',
emoji_id: '444',
},
],
default_reaction_emoji: {
emoji_id: '159',
emoji_name: null,
},
default_sort_order: SortOrderType.LatestActivity,
};
test('MediaChannel has all properties', () => {
const instance = new MediaChannel(data);
expect(instance.id).toBe(data.id);
expect(instance.name).toBe(data.name);
expect(instance.position).toBe(data.position);
expect(instance.defaultAutoArchiveDuration).toBe(data.default_auto_archive_duration);
expect(instance.defaultThreadRateLimitPerUser).toBe(data.default_thread_rate_limit_per_user);
expect(instance.flags?.toJSON()).toBe(data.flags);
expect(instance.guildId).toBe(data.guild_id);
expect(instance.nsfw).toBe(data.nsfw);
expect(instance.parentId).toBe(data.parent_id);
expect(instance[kData].permission_overwrites).toEqual(data.permission_overwrites);
expect(instance[kData].available_tags).toEqual(data.available_tags);
expect(instance.topic).toBe(data.topic);
expect(instance.type).toBe(ChannelType.GuildMedia);
expect(instance.url).toBe('https://discord.com/channels/2/1');
expect(instance.toJSON()).toEqual(data);
});
test('type guards', () => {
const instance = new MediaChannel(data);
expect(instance.isDMBased()).toBe(false);
expect(instance.isGuildBased()).toBe(true);
expect(instance.isPermissionCapable()).toBe(true);
expect(instance.isTextBased()).toBe(false);
expect(instance.isThread()).toBe(false);
expect(instance.isThreadOnly()).toBe(true);
expect(instance.isVoiceBased()).toBe(false);
expect(instance.isWebhookCapable()).toBe(true);
});
test('ForumTag has all properties', () => {
const instances = data.available_tags.map((tag) => new ForumTag(tag));
expect(instances.map((tag) => tag.toJSON())).toEqual(data.available_tags);
expect(instances[0]?.emoji).toBe(`<:_:${data.available_tags[0]?.emoji_id}>`);
});
});
describe('voice channel', () => {
const data: APIGuildVoiceChannel = {
id: '1',
name: 'test',
type: ChannelType.GuildVoice,
position: 0,
guild_id: '2',
last_message_id: '3',
nsfw: true,
parent_id: '4',
permission_overwrites: [
{
allow: '123',
deny: '456',
type: OverwriteType.Member,
id: '5',
},
],
rate_limit_per_user: 9,
bitrate: 7,
rtc_region: 'somewhere',
user_limit: 100,
video_quality_mode: VideoQualityMode.Full,
};
test('VoiceChannel has all properties', () => {
const instance = new VoiceChannel(data);
expect(instance.id).toBe(data.id);
expect(instance.name).toBe(data.name);
expect(instance.position).toBe(data.position);
expect(instance.bitrate).toBe(data.bitrate);
expect(instance.rtcRegion).toBe(data.rtc_region);
expect(instance.flags?.toJSON()).toBe(data.flags);
expect(instance.guildId).toBe(data.guild_id);
expect(instance.lastMessageId).toBe(data.last_message_id);
expect(instance.videoQualityMode).toBe(data.video_quality_mode);
expect(instance.userLimit).toBe(data.user_limit);
expect(instance.nsfw).toBe(data.nsfw);
expect(instance.parentId).toBe(data.parent_id);
expect(instance[kData].permission_overwrites).toEqual(data.permission_overwrites);
expect(instance.rateLimitPerUser).toBe(data.rate_limit_per_user);
expect(instance.type).toBe(ChannelType.GuildVoice);
expect(instance.url).toBe('https://discord.com/channels/2/1');
expect(instance.toJSON()).toEqual(data);
});
test('type guards', () => {
const instance = new VoiceChannel(data);
expect(instance.isDMBased()).toBe(false);
expect(instance.isGuildBased()).toBe(true);
expect(instance.isPermissionCapable()).toBe(true);
expect(instance.isTextBased()).toBe(true);
expect(instance.isThread()).toBe(false);
expect(instance.isThreadOnly()).toBe(false);
expect(instance.isVoiceBased()).toBe(true);
expect(instance.isWebhookCapable()).toBe(true);
});
});
describe('stage channel', () => {
const data: APIGuildStageVoiceChannel = {
id: '1',
name: 'test',
type: ChannelType.GuildStageVoice,
position: 0,
guild_id: '2',
last_message_id: '3',
nsfw: true,
parent_id: '4',
permission_overwrites: [
{
allow: '123',
deny: '456',
type: OverwriteType.Member,
id: '5',
},
],
rate_limit_per_user: 9,
bitrate: 7,
rtc_region: 'somewhere',
user_limit: 100,
video_quality_mode: VideoQualityMode.Full,
};
test('StageChannel has all properties', () => {
const instance = new StageChannel(data);
expect(instance.id).toBe(data.id);
expect(instance.name).toBe(data.name);
expect(instance.position).toBe(data.position);
expect(instance.bitrate).toBe(data.bitrate);
expect(instance.rtcRegion).toBe(data.rtc_region);
expect(instance.flags?.toJSON()).toBe(data.flags);
expect(instance.guildId).toBe(data.guild_id);
expect(instance.lastMessageId).toBe(data.last_message_id);
expect(instance.videoQualityMode).toBe(data.video_quality_mode);
expect(instance.nsfw).toBe(data.nsfw);
expect(instance.parentId).toBe(data.parent_id);
expect(instance[kData].permission_overwrites).toEqual(data.permission_overwrites);
expect(instance.rateLimitPerUser).toBe(data.rate_limit_per_user);
expect(instance.type).toBe(ChannelType.GuildStageVoice);
expect(instance.url).toBe('https://discord.com/channels/2/1');
expect(instance.toJSON()).toEqual(data);
});
test('type guards', () => {
const instance = new StageChannel(data);
expect(instance.isDMBased()).toBe(false);
expect(instance.isGuildBased()).toBe(true);
expect(instance.isPermissionCapable()).toBe(true);
expect(instance.isTextBased()).toBe(true);
expect(instance.isThread()).toBe(false);
expect(instance.isThreadOnly()).toBe(false);
expect(instance.isVoiceBased()).toBe(true);
expect(instance.isWebhookCapable()).toBe(true);
});
});
describe('thread channels', () => {
const dataNoTags: Omit<APIPublicThreadChannel, 'applied_tags'> = {
id: '1',
name: 'test',
type: ChannelType.PublicThread,
guild_id: '2',
last_message_id: '3',
last_pin_timestamp: null,
nsfw: true,
parent_id: '4',
rate_limit_per_user: 9,
};
const dataPublic: APIPublicThreadChannel = {
...dataNoTags,
applied_tags: ['567'],
};
const dataAnnounce: APIAnnouncementThreadChannel = {
...dataPublic,
thread_metadata: {
archive_timestamp: '2024-09-08T12:01:02.345Z',
archived: false,
auto_archive_duration: ThreadAutoArchiveDuration.ThreeDays,
locked: true,
},
flags: ChannelFlags.Pinned,
type: ChannelType.AnnouncementThread,
};
const dataPrivate: APIPrivateThreadChannel = {
...dataPublic,
thread_metadata: {
...dataAnnounce.thread_metadata!,
create_timestamp: '2023-01-02T15:13:11.987Z',
invitable: true,
},
type: ChannelType.PrivateThread,
};
test('PublicThreadChannel has all properties', () => {
const instance = new PublicThreadChannel(dataPublic);
expect(instance.id).toBe(dataPublic.id);
expect(instance.name).toBe(dataPublic.name);
expect(instance.flags?.toJSON()).toBe(dataPublic.flags);
expect(instance.guildId).toBe(dataPublic.guild_id);
expect(instance.lastMessageId).toBe(dataPublic.last_message_id);
expect(instance.nsfw).toBe(dataPublic.nsfw);
expect(instance.parentId).toBe(dataPublic.parent_id);
expect(instance.rateLimitPerUser).toBe(dataPublic.rate_limit_per_user);
expect(instance.type).toBe(ChannelType.PublicThread);
expect(instance.appliedTags).toEqual(dataPublic.applied_tags);
expect(instance.memberCount).toBe(dataPublic.member_count);
expect(instance.messageCount).toBe(dataPublic.message_count);
expect(instance.totalMessageSent).toBe(dataPublic.total_message_sent);
expect(instance.url).toBe('https://discord.com/channels/2/1');
expect(instance.toJSON()).toEqual(dataPublic);
});
test('type guards PublicThread', () => {
const instance = new PublicThreadChannel(dataPublic);
expect(instance.isDMBased()).toBe(false);
expect(instance.isGuildBased()).toBe(true);
expect(instance.isPermissionCapable()).toBe(false);
expect(instance.isTextBased()).toBe(true);
expect(instance.isThread()).toBe(true);
expect(instance.isThreadOnly()).toBe(false);
expect(instance.isVoiceBased()).toBe(false);
expect(instance.isWebhookCapable()).toBe(false);
});
test('PrivateThreadChannel has all properties', () => {
const instance = new PrivateThreadChannel(dataPrivate);
expect(instance.id).toBe(dataPrivate.id);
expect(instance.name).toBe(dataPrivate.name);
expect(instance.flags?.toJSON()).toBe(dataPrivate.flags);
expect(instance.guildId).toBe(dataPrivate.guild_id);
expect(instance.lastMessageId).toBe(dataPrivate.last_message_id);
expect(instance.nsfw).toBe(dataPrivate.nsfw);
expect(instance.parentId).toBe(dataPrivate.parent_id);
expect(instance.rateLimitPerUser).toBe(dataPrivate.rate_limit_per_user);
expect(instance[kData].thread_metadata).toEqual(dataPrivate.thread_metadata);
expect(instance.type).toBe(ChannelType.PrivateThread);
expect(instance.url).toBe('https://discord.com/channels/2/1');
expect(instance.toJSON()).toEqual(dataPrivate);
});
test('type guards PrivateThread', () => {
const instance = new PrivateThreadChannel(dataPrivate);
expect(instance.isDMBased()).toBe(false);
expect(instance.isGuildBased()).toBe(true);
expect(instance.isPermissionCapable()).toBe(false);
expect(instance.isTextBased()).toBe(true);
expect(instance.isThread()).toBe(true);
expect(instance.isThreadOnly()).toBe(false);
expect(instance.isVoiceBased()).toBe(false);
expect(instance.isWebhookCapable()).toBe(false);
});
test('AnnouncementThreadChannel has all properties', () => {
const instance = new AnnouncementThreadChannel(dataAnnounce);
expect(instance.id).toBe(dataAnnounce.id);
expect(instance.name).toBe(dataAnnounce.name);
expect(instance.flags?.toJSON()).toBe(dataAnnounce.flags);
expect(instance.guildId).toBe(dataAnnounce.guild_id);
expect(instance.lastMessageId).toBe(dataAnnounce.last_message_id);
expect(instance.nsfw).toBe(dataAnnounce.nsfw);
expect(instance.parentId).toBe(dataAnnounce.parent_id);
expect(instance.rateLimitPerUser).toBe(dataAnnounce.rate_limit_per_user);
expect(instance[kData].thread_metadata).toEqual(dataAnnounce.thread_metadata);
expect(instance.type).toBe(ChannelType.AnnouncementThread);
expect(instance.url).toBe('https://discord.com/channels/2/1');
expect(instance.toJSON()).toEqual(dataAnnounce);
});
test('type guards AnnouncementThread', () => {
const instance = new AnnouncementThreadChannel(dataAnnounce);
expect(instance.isDMBased()).toBe(false);
expect(instance.isGuildBased()).toBe(true);
expect(instance.isPermissionCapable()).toBe(false);
expect(instance.isTextBased()).toBe(true);
expect(instance.isThread()).toBe(true);
expect(instance.isThreadOnly()).toBe(false);
expect(instance.isVoiceBased()).toBe(false);
expect(instance.isWebhookCapable()).toBe(false);
});
test('omitted property from PublicThread', () => {
const instance = new PublicThreadChannel(dataNoTags);
expect(instance.toJSON()).toEqual(dataNoTags);
expect(instance.appliedTags).toBe(null);
});
test('ThreadMetadata has all properties', () => {
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(instance.archivedTimestamp).toBe(Date.parse(dataPrivate.thread_metadata!.archive_timestamp));
expect(instance.createdAt?.toISOString()).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);
expect(instance.locked).toBe(dataPrivate.thread_metadata?.locked);
});
});

View File

@@ -0,0 +1,90 @@
import type { APIExtendedInvite, APIInvite } from 'discord-api-types/v10';
import { InviteTargetType, InviteType } from 'discord-api-types/v10';
import { describe, expect, test } from 'vitest';
import { Invite } from '../src/index.js';
import { kPatch } from '../src/utils/symbols.js';
describe('Invite', () => {
const dataNoCode: Omit<APIInvite, 'code'> = {
type: InviteType.Guild,
channel: null,
approximate_member_count: 15,
approximate_presence_count: 35,
target_type: InviteTargetType.EmbeddedApplication,
};
const data: APIInvite = {
...dataNoCode,
code: '123',
};
const dataExtended: APIExtendedInvite = {
...data,
created_at: '2020-10-10T13:50:17.209Z',
max_age: 12,
max_uses: 34,
temporary: false,
uses: 5,
};
test('Invite has all properties', () => {
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.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.url).toBe('https://discord.gg/123');
expect(instance.toJSON()).toEqual(data);
expect(`${instance}`).toBe('https://discord.gg/123');
expect(instance.valueOf()).toBe(data.code);
});
test('extended Invite has all properties', () => {
const instance = new Invite(dataExtended);
expect(instance.type).toBe(data.type);
expect(instance.code).toBe(dataExtended.code);
expect(instance.createdAt?.toISOString()).toBe(dataExtended.created_at);
expect(instance.createdTimestamp).toBe(Date.parse(dataExtended.created_at));
expect(instance.maxAge).toBe(dataExtended.max_age);
expect(instance.maxUses).toBe(dataExtended.max_uses);
expect(instance.approximateMemberCount).toBe(dataExtended.approximate_member_count);
expect(instance.approximatePresenceCount).toBe(dataExtended.approximate_presence_count);
expect(instance.targetType).toBe(dataExtended.target_type);
expect(instance.temporary).toBe(dataExtended.temporary);
expect(instance.uses).toBe(dataExtended.uses);
expect(instance.expiresTimestamp).toStrictEqual(Date.parse('2020-10-10T13:50:29.209Z'));
expect(instance.expiresAt).toStrictEqual(new Date('2020-10-10T13:50:29.209Z'));
expect(instance.url).toBe('https://discord.gg/123');
expect(instance.toJSON()).toEqual({ ...dataExtended, expires_at: '2020-10-10T13:50:29.209Z' });
});
test('Invite with omitted properties', () => {
const instance = new Invite(dataNoCode);
expect(instance.toJSON()).toEqual(dataNoCode);
expect(instance.url).toBe(null);
expect(instance.code).toBe(undefined);
expect(`${instance}`).toBe('');
expect(instance.valueOf()).toEqual(Object.prototype.valueOf.apply(instance));
});
test('Invite with expiration', () => {
const instance = new Invite({ ...dataExtended, expires_at: '2020-10-10T13:50:29.209Z' });
expect(instance.toJSON()).toEqual({ ...dataExtended, expires_at: '2020-10-10T13:50:29.209Z' });
});
test('Patching Invite works in place', () => {
const instance1 = new Invite(data);
const instance2 = instance1[kPatch]({ max_age: 34 });
expect(instance1.toJSON()).not.toEqual(data);
expect(instance2).toBe(instance1);
});
});

View File

@@ -0,0 +1,132 @@
import { Mixin } from '../src/Mixin.js';
import type { MixinTypes } from '../src/MixinTypes.d.ts';
import { Structure } from '../src/Structure.js';
import { kData, kMixinConstruct, kMixinToJSON, kPatch } from '../src/utils/symbols.js';
export interface APIData {
baseOptimize?: string;
id: string;
mixinOptimize?: string;
property1?: number;
property2?: boolean;
}
export class Base<Omitted extends keyof APIData | '' = ''> extends Structure<APIData, Omitted> {
public static override readonly DataTemplate = {
set baseOptimize(_: unknown) {},
};
public baseOptimize: boolean | null = null;
public constructor(data: APIData) {
super(data);
this.optimizeData(data);
}
public override [kPatch](data: Partial<APIData>) {
super[kPatch](data);
return this;
}
public override optimizeData(data: Partial<APIData>) {
if ('baseOptimize' in data) {
this.baseOptimize = Boolean(data.baseOptimize);
}
}
public get id() {
return this[kData].id;
}
public getId() {
return this.id;
}
public override toJSON() {
const data = super.toJSON();
if (this.baseOptimize) {
data.baseOptimize = String(this.baseOptimize);
}
return data;
}
}
export interface MixinProperty1<Omitted extends keyof APIData | '' = ''> extends Base<Omitted> {
mixinOptimize: boolean | null;
}
export class MixinProperty1 {
public static readonly DataTemplate = {
set mixinOptimize(_: unknown) {},
};
public [kMixinConstruct]() {
this.mixinOptimize = null;
}
public optimizeData(data: Partial<APIData>) {
if ('mixinOptimize' in data) {
this.mixinOptimize = Boolean(data.mixinOptimize);
}
}
public get property1() {
return this[kData].property1;
}
public getProperty1() {
return this.property1;
}
protected [kMixinToJSON](data: Partial<APIData>) {
if (this.mixinOptimize) {
data.mixinOptimize = String(this.mixinOptimize);
}
}
}
export interface MixinProperty2<Omitted extends keyof APIData | '' = ''> extends Base<Omitted> {
constructCalled: boolean;
}
export class MixinProperty2 {
public [kMixinConstruct]() {
this.constructCalled = true;
}
public get property2() {
return this[kData].property2;
}
public getProperty2() {
return this.property2;
}
}
export class ExtendedMixinProperty2 extends MixinProperty2 {
// eslint-disable-next-line @typescript-eslint/class-literal-property-style
public get isExtended() {
return true;
}
}
export interface Mixed extends MixinTypes<Base, [MixinProperty1, MixinProperty2]> {}
export class Mixed extends Base {
public getProperties() {
return { property1: this.property1, property2: this.property2 };
}
}
Mixin(Mixed, [MixinProperty1, MixinProperty2]);
export interface MixedWithExtended extends MixinTypes<Base, [MixinProperty1, ExtendedMixinProperty2]> {}
export class MixedWithExtended extends Base {
public getProperties() {
return {
property1: this.property1,
property2: this.property2,
};
}
}
// Intentionally don't directly mix Property 2
Mixin(MixedWithExtended, [MixinProperty1, ExtendedMixinProperty2]);

View File

@@ -0,0 +1,40 @@
import { expectNotType, expectType } from 'tsd';
import { expectTypeOf } from 'vitest';
import type { MixinTypes } from '../../src/MixinTypes.d.ts';
import type { kMixinConstruct } from '../../src/utils/symbols.js';
import type { MixinProperty1, Base, MixinProperty2 } from '../mixinClasses.js';
declare const extendsNoOmit: Omit<MixinProperty1, keyof Base | typeof kMixinConstruct>;
declare const extendsOmitProperty1: Omit<MixinProperty1<'property1'>, keyof Base | typeof kMixinConstruct>;
declare const extendsBothNoOmit: Omit<MixinProperty1 & MixinProperty2, keyof Base | typeof kMixinConstruct>;
declare const extendsBothOmitProperty1: Omit<
MixinProperty1<'property1'> & MixinProperty2<'property1'>,
keyof Base | typeof kMixinConstruct
>;
declare const extendsBothOmitBoth: Omit<
MixinProperty1<'property1'> & MixinProperty2<'property2'>,
keyof Base | typeof kMixinConstruct
>;
expectType<MixinTypes<Base, [MixinProperty1]>>(extendsNoOmit);
expectType<MixinTypes<Base<'property1'>, [MixinProperty1<'property1'>]>>(extendsOmitProperty1);
expectNotType<MixinTypes<Base, [MixinProperty1]>>(extendsOmitProperty1);
expectNotType<MixinTypes<Base<'property1'>, [MixinProperty1<'property1'>]>>(extendsNoOmit);
expectType<MixinTypes<Base, [MixinProperty1, MixinProperty2]>>(extendsBothNoOmit);
// Since MixinProperty2 doesn't utilize the type of property1 in kData, this works and is ok
expectType<MixinTypes<Base<'property1'>, [MixinProperty1<'property1'>, MixinProperty2]>>(extendsBothOmitProperty1);
expectNotType<MixinTypes<Base, [MixinProperty1, MixinProperty2]>>(extendsBothOmitProperty1);
// Since MixinProperty2 doesn't utilize the type of property1 in kData, this works and is ok
expectNotType<MixinTypes<Base<'property1'>, [MixinProperty1<'property1'>, MixinProperty2]>>(extendsBothNoOmit);
// Earlier mixins in the list must specify all properties because of the way merging works
expectType<
MixinTypes<Base<'property1' | 'property2'>, [MixinProperty1<'property1' | 'property2'>, MixinProperty2<'property2'>]>
>(extendsBothOmitBoth);
expectTypeOf<MixinTypes<Base<'property1'>, [MixinProperty1]>>().toBeNever();
// @ts-expect-error Shouldn't be able to assign non identical omits
expectTypeOf<MixinTypes<Base, [MixinProperty1<'property1'>]>>()
// Separate line so ts-expect-error doesn't match this ever
.toBeNever();

View File

@@ -0,0 +1,79 @@
import type { ChannelType, GuildChannelType, GuildTextChannelType, ThreadChannelType } from 'discord-api-types/v10';
import { expectNever, expectType } from 'tsd';
import type { Channel } from '../../src/index.js';
declare const channel: Channel;
if (channel.isGuildBased()) {
expectType<string>(channel.guildId);
expectType<GuildChannelType>(channel.type);
if (channel.isDMBased()) {
expectNever(channel);
}
if (channel.isPermissionCapable()) {
expectType<Exclude<GuildChannelType, ChannelType.GuildDirectory | ThreadChannelType>>(channel.type);
}
if (channel.isTextBased()) {
expectType<GuildTextChannelType>(channel.type);
}
if (channel.isWebhookCapable()) {
expectType<ChannelType.GuildForum | ChannelType.GuildMedia | Exclude<GuildTextChannelType, ThreadChannelType>>(
channel.type,
);
}
if (channel.isThread()) {
expectType<ThreadChannelType>(channel.type);
}
if (channel.isThreadOnly()) {
expectType<ChannelType.GuildForum | ChannelType.GuildMedia>(channel.type);
}
if (channel.isVoiceBased()) {
expectType<ChannelType.GuildStageVoice | ChannelType.GuildVoice>(channel.type);
if (!channel.isTextBased()) {
expectNever(channel);
}
if (!channel.isWebhookCapable()) {
expectNever(channel);
}
}
}
if (channel.isDMBased()) {
expectType<ChannelType.DM | ChannelType.GroupDM>(channel.type);
if (channel.isGuildBased()) {
expectNever(channel);
}
if (channel.isPermissionCapable()) {
expectNever(channel);
}
if (channel.isWebhookCapable()) {
expectNever(channel);
}
if (channel.isVoiceBased()) {
expectNever(channel);
}
if (channel.isThread()) {
expectNever(channel);
}
if (channel.isThreadOnly()) {
expectNever(channel);
}
if (channel.isTextBased()) {
expectType<ChannelType.DM | ChannelType.GroupDM>(channel.type);
}
}

View File

@@ -0,0 +1,11 @@
{
"extends": "../../api-extractor.json",
"docModel": {
"projectFolderUrl": "https://github.com/discordjs/discord.js/tree/main/packages/structures"
},
"compiler": {
"overrideTsconfig": {
"exclude": ["src/**/*.d.ts"]
}
}
}

View File

@@ -0,0 +1,79 @@
[changelog]
header = """
# Changelog
All notable changes to this project will be documented in this file.\n
"""
body = """
{%- macro remote_url() -%}
https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }}
{%- endmacro -%}
{% if version %}\
# [{{ version | trim_start_matches(pat="v") }}]\
{% if previous %}\
{% if previous.version %}\
({{ self::remote_url() }}/compare/{{ previous.version }}...{{ version }})\
{% else %}\
({{ self::remote_url() }}/tree/{{ version }})\
{% endif %}\
{% endif %} \
- ({{ timestamp | date(format="%Y-%m-%d") }})
{% else %}\
# [unreleased]
{% endif %}\
{% for group, commits in commits | group_by(attribute="group") %}
## {{ group | upper_first }}
{% for commit in commits %}
- {% if commit.scope %}\
**{{commit.scope}}:** \
{% endif %}\
{{ commit.message | upper_first }} ([{{ commit.id | truncate(length=7, end="") }}]({{ self::remote_url() }}/commit/{{ commit.id }}))\
{% if commit.github.username %} by @{{ commit.github.username }}{%- endif %}\
{% if commit.breaking %}\
{% for footer in commit.footers %}\
{% if footer.breaking %}\
\n{% raw %} {% endraw %}- **{{ footer.token }}{{ footer.separator }}** {{ footer.value }}\
{% endif %}\
{% endfor %}\
{% endif %}\
{% endfor %}
{% endfor %}\
{% if github.contributors | filter(attribute="is_first_time", value=true) | length %}\
\n### New Contributors\n
{% for contributor in github.contributors | filter(attribute="is_first_time", value=true) %}\
* @{{ contributor.username }} made their first contribution in #{{ contributor.pr_number }}
{% endfor %}\
{% endif %}\n
"""
trim = true
footer = ""
[git]
conventional_commits = true
filter_unconventional = true
commit_parsers = [
{ message = "^feat", group = "Features"},
{ message = "^fix", group = "Bug Fixes"},
{ message = "^docs", group = "Documentation"},
{ message = "^perf", group = "Performance"},
{ message = "^refactor", group = "Refactor"},
{ message = "^types", group = "Typings"},
{ message = ".*deprecated", body = ".*deprecated", group = "Deprecation"},
{ message = "^revert", skip = true},
{ message = "^style", group = "Styling"},
{ message = "^test", group = "Testing"},
{ message = "^chore", skip = true},
{ message = "^ci", skip = true},
{ message = "^build", skip = true},
{ body = ".*security", group = "Security"},
]
filter_commits = true
protect_breaking_commits = true
tag_pattern = "@discordjs/structures@[0-9]*"
ignore_tags = ""
topo_order = false
sort_commits = "newest"
[remote.github]
owner = "discordjs"
repo = "discord.js"

View File

@@ -0,0 +1 @@
## [View the documentation here.](https://discord.js.org/docs/packages/structures/main)

View File

@@ -0,0 +1,98 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@discordjs/structures",
"version": "0.1.0",
"description": "Wrapper around Discord's structures",
"scripts": {
"build": "tsc --noEmit && tsup",
"build:docs": "tsc -p tsconfig.docs.json && cpy \"./src/*.d.ts\" \"./dist-docs\"",
"test": "vitest run --config ../../vitest.config.ts",
"lint": "prettier --check . && cross-env TIMING=1 eslint --format=pretty src",
"format": "prettier --write . && cross-env TIMING=1 eslint --fix --format=pretty src",
"fmt": "pnpm run format",
"docs": "pnpm run build:docs && api-extractor run --local --minify && generate-split-documentation",
"prepack": "pnpm run build && pnpm run lint",
"changelog": "git cliff --prepend ./CHANGELOG.md -u -c ./cliff.toml -r ../../ --include-path 'packages/structures/*'",
"release": "cliff-jumper"
},
"exports": {
".": {
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"import": {
"types": "./dist/index.d.mts",
"default": "./dist/index.mjs"
}
}
},
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"directories": {
"lib": "src",
"test": "__tests__"
},
"files": [
"dist"
],
"contributors": [
"Crawl <icrawltogo@gmail.com>",
"SpaceEEC <spaceeec@yahoo.com>",
"Vlad Frangu <me@vladfrangu.dev>",
"Aura Román <kyradiscord@gmail.com>",
"Chai Kohen <chaikohen@gmail.com>"
],
"license": "Apache-2.0",
"keywords": [
"discord",
"api",
"discordapp",
"discordjs"
],
"repository": {
"type": "git",
"url": "https://github.com/discordjs/discord.js.git",
"directory": "packages/structures"
},
"bugs": {
"url": "https://github.com/discordjs/discord.js/issues"
},
"homepage": "https://discord.js.org",
"dependencies": {
"@discordjs/formatters": "workspace:^",
"@sapphire/snowflake": "^3.5.5",
"discord-api-types": "^0.38.15"
},
"devDependencies": {
"@discordjs/api-extractor": "workspace:^",
"@discordjs/scripts": "workspace:^",
"@favware/cliff-jumper": "^4.1.0",
"@types/node": "^22.15.2",
"@vitest/coverage-v8": "^3.1.1",
"cpy-cli": "^5.0.0",
"cross-env": "^7.0.3",
"esbuild-plugin-version-injector": "^1.2.1",
"eslint": "^9.25.1",
"eslint-config-neon": "^0.2.7",
"eslint-formatter-compact": "^8.40.0",
"eslint-formatter-pretty": "^6.0.1",
"prettier": "^3.5.3",
"tsd": "^0.31.2",
"tsup": "^8.4.0",
"turbo": "^2.5.2",
"typescript": "~5.8.3",
"vitest": "^3.1.1"
},
"engines": {
"node": ">=22.12.0"
},
"publishConfig": {
"access": "public",
"provenance": true
},
"tsd": {
"directory": "__tests__/types"
}
}

View File

@@ -0,0 +1,179 @@
import { DataTemplatePropertyName, OptimizeDataPropertyName, type Structure } from './Structure.js';
import { kMixinConstruct, kMixinToJSON } from './utils/symbols.js';
export type Mixinable<ClassType> = new (...args: unknown[]) => ClassType;
export type MixinBase<BaseClass extends Structure<{}>> =
BaseClass extends Structure<infer DataType, infer Omitted> ? Structure<DataType, Omitted> : never;
/**
* Copies the prototype (getters, setters, and methods) of all mixins to the destination class.
* For type information see {@link MixinTypes}
*
* @param destination - The class to apply the mixins to, must extend the base that the mixins expect it to.
* @param mixins - Classes that contain "pure" prototypes to be copied on top of the destination class prototype
* @remarks All mixins should be "pure" in that they only contain getters, setters, and methods.
* The runtime code will only copy these, and adding properties to the class only results
* in the types of the mixed class being wrong.
* @example
* ```
* // Interface merging on the mixin to give type access to props on the base and kData that are available once copied
* interface TextMixin extends Channel {}
* class TextMixin {
* // Methods / getters
* }
*
* // Interface merging on the mixed class to give it accurate type information within the declaration and when instantiated
* interface TextChannel extends MixinTypes<Channel, [TextMixin]> {}
* class TextChannel extends Channel {}
*
* // Apply for runtime
* Mixin(TextChannel, [TextMixin])
* ```
* @typeParam DestinationClass - The class to be mixed, ensures that the mixins provided can be used with this destination
*/
export function Mixin<DestinationClass extends typeof Structure<{}>>(
destination: DestinationClass,
mixins: Mixinable<MixinBase<DestinationClass['prototype']>>[],
) {
const dataTemplates: Record<string, unknown>[] = [];
const dataOptimizations: ((data: unknown) => void)[] = [];
const enrichToJSONs: ((data: Partial<unknown>) => void)[] = [];
const constructors: ((data: Partial<unknown>) => void)[] = [];
for (const mixin of mixins) {
// The entire prototype chain, in reverse order, since we want to copy it all
const prototypeChain: MixinBase<DestinationClass['prototype']>[] = [];
let extendedClass = mixin;
while (extendedClass.prototype !== undefined) {
if (
DataTemplatePropertyName in extendedClass &&
typeof extendedClass.DataTemplate === 'object' &&
// eslint-disable-next-line no-eq-null, eqeqeq
extendedClass.DataTemplate != null
) {
dataTemplates.push(extendedClass.DataTemplate as Record<string, unknown>);
}
prototypeChain.unshift(extendedClass.prototype);
extendedClass = Object.getPrototypeOf(extendedClass);
}
for (const prototype of prototypeChain) {
// Symboled data isn't traversed by Object.entries, we can handle it here
if (prototype[kMixinConstruct]) {
constructors.push(prototype[kMixinConstruct]);
}
if (prototype[kMixinToJSON]) {
enrichToJSONs.push(prototype[kMixinToJSON]);
}
// Copy instance methods and setters / getters
const originalDescriptors = Object.getOwnPropertyDescriptors(prototype);
const usingDescriptors: { [prop: string]: PropertyDescriptor } = {};
for (const [prop, descriptor] of Object.entries(originalDescriptors)) {
// Drop constructor
if (['constructor'].includes(prop)) {
continue;
}
// Special case for optimize function, we want to combine these
if (prop === OptimizeDataPropertyName) {
if (typeof descriptor.value !== 'function')
throw new RangeError(`Expected ${prop} to be a function, received ${typeof descriptor.value} instead.`);
dataOptimizations.push(descriptor.value);
continue;
}
// Shouldn't be anything other than these without being instantiated, but just in case
if (
typeof descriptor.get !== 'undefined' ||
typeof descriptor.set !== 'undefined' ||
typeof descriptor.value === 'function'
) {
usingDescriptors[prop] = descriptor;
}
}
Object.defineProperties(destination.prototype, usingDescriptors);
}
}
// Set the function to call any mixed constructors
if (constructors.length > 0) {
Object.defineProperty(destination.prototype, kMixinConstruct, {
writable: true,
enumerable: false,
configurable: true,
// eslint-disable-next-line func-name-matching
value: function _mixinConstructors(data: Partial<unknown>) {
for (const construct of constructors) {
construct.call(this, data);
}
},
});
}
// Combine all optimizations into a single function
const baseOptimize = Object.getOwnPropertyDescriptor(destination, OptimizeDataPropertyName);
if (baseOptimize && typeof baseOptimize.value === 'function') {
// call base last (mimic constructor behavior)
dataOptimizations.push(baseOptimize.value);
}
const superOptimize = Object.getOwnPropertyDescriptor(
Object.getPrototypeOf(destination).prototype,
OptimizeDataPropertyName,
);
// the mixin base optimize should call super, so we can ignore the super in that case
if (!baseOptimize && superOptimize && typeof superOptimize.value === 'function') {
// call super first (mimic constructor behavior)
dataOptimizations.unshift(superOptimize.value);
}
// If there's more than one optimization or if there's an optimization that isn't on the destination (base)
if (dataOptimizations.length > 1 || (dataOptimizations.length === 1 && !baseOptimize)) {
Object.defineProperty(destination.prototype, OptimizeDataPropertyName, {
writable: true,
enumerable: false,
configurable: true,
// eslint-disable-next-line func-name-matching
value: function _mixinOptimizeData(data: unknown) {
for (const optimization of dataOptimizations) {
optimization.call(this, data);
}
},
});
}
if (enrichToJSONs.length > 0) {
Object.defineProperty(destination.prototype, kMixinToJSON, {
writable: true,
enumerable: false,
configurable: true,
// eslint-disable-next-line func-name-matching
value: function _mixinToJSON(data: Partial<unknown>) {
for (const enricher of enrichToJSONs) {
enricher.call(this, data);
}
},
});
}
// Copy the properties (setters) of each mixins template to the destinations template
if (dataTemplates.length > 0) {
if (!Object.getOwnPropertyDescriptor(destination, DataTemplatePropertyName)) {
Object.defineProperty(destination, DataTemplatePropertyName, {
value: Object.defineProperties({}, Object.getOwnPropertyDescriptors(destination[DataTemplatePropertyName])),
writable: true,
enumerable: true,
configurable: true,
});
}
for (const template of dataTemplates) {
Object.defineProperties(destination[DataTemplatePropertyName], Object.getOwnPropertyDescriptors(template));
}
}
}

23
packages/structures/src/MixinTypes.d.ts vendored Normal file
View File

@@ -0,0 +1,23 @@
import type { MixinBase } from './Mixin.js';
import type { Structure } from './Structure.js';
import type { kData, kMixinConstruct } from './utils/symbols.js';
import type { CollapseUnion, MergePrototypes } from './utils/types.js';
/**
* Type utility to provide accurate types for the runtime effects of {@link Mixin}
*
* @typeParam BaseClass - The class that is being directly extended, must match the class that the mixins are expecting
* @typeParam Mixins - The mixins that will be applied to this class via a {@link Mixin} call
*/
export type MixinTypes<BaseClass extends Structure<{}>, Mixins extends readonly MixinBase<BaseClass>[]> = CollapseUnion<
BaseClass extends Structure<infer DataType, infer Omitted>
? Mixins[number] extends Structure<DataType, Omitted>
? // prettier-ignore
Structure<DataType, Omitted>[typeof kData] extends
// @ts-expect-error kData is protected
Mixins[number][typeof kData]
? Omit<MergePrototypes<Mixins>, keyof BaseClass | typeof kMixinConstruct>
: never
: never
: never
>;

View File

@@ -0,0 +1,144 @@
import { kClone, kData, kMixinConstruct, kMixinToJSON, kPatch } from './utils/symbols.js';
import type { ReplaceOmittedWithUnknown } from './utils/types.js';
export const DataTemplatePropertyName = 'DataTemplate';
export const OptimizeDataPropertyName = 'optimizeData';
/**
* Represents a data model from the Discord API
*
* @privateRemarks
* Explanation of the type complexity surround Structure:
*
* There are two layers of Omitted generics, one here, which allows omitting things at the library level so we do not accidentally
* access them, in addition to whatever the user does at the layer above.
*
* The second layer, in the exported structure is effectively a type cast that allows the getters types to match whatever data template is used
*
* In order to safely set and access this data, the constructor and patch take data as "partial" and forcibly assigns it to kData. To accommodate this,
* kData stores properties as `unknown` when it is omitted, which allows accessing the property in getters even when it may not actually be present.
* This is the most technically correct way of representing the value, especially since there is no way to guarantee runtime matches the "type cast."
*/
export abstract class Structure<DataType extends {}, Omitted extends keyof DataType | '' = ''> {
/**
* A construct function used when mixing to allow mixins to set optimized property defaults
*
* @internal
* @remarks This should only be used to set defaults, setting optimized values should be done
* in the mixins `optimizeData` method, which will be called automatically.
* @param data - The full API data received by the Structure
*/
protected [kMixinConstruct]?(data: Partial<DataType>): void;
/**
* A function used when mixing to allow mixins to add properties to the result of toJSON
*
* @internal
* @remarks This should only be used to add properties that the mixin optimizes, if the raw
* JSON data is unchanged the property will already be returned.
* @param data - The result of the base class toJSON Structure before it gets returned
*/
protected [kMixinToJSON]?(data: Partial<DataType>): void;
/**
* The template used for removing data from the raw data stored for each Structure.
*
* @remarks This template should be overridden in all subclasses to provide more accurate type information.
* The template in the base {@link Structure} class will have no effect on most subclasses for this reason.
*/
protected static readonly DataTemplate: Record<string, unknown> = {};
/**
* @returns A cloned version of the data template, ready to create a new data object.
*/
private getDataTemplate() {
return Object.create((this.constructor as typeof Structure).DataTemplate);
}
/**
* The raw data from the API for this structure
*
* @internal
*/
protected [kData]: Readonly<ReplaceOmittedWithUnknown<Omitted, DataType>>;
/**
* Creates a new structure to represent API data
*
* @param data - the data from the API that this structure will represent
* @remarks To be made public in subclasses
* @internal
*/
public constructor(data: Readonly<Partial<DataType>>, ..._rest: unknown[]) {
this[kData] = Object.assign(this.getDataTemplate(), data);
this[kMixinConstruct]?.(data);
}
/**
* Patches the raw data of this object in place
*
* @param data - the updated data from the API to patch with
* @remarks To be made public in subclasses
* @returns this
* @internal
*/
protected [kPatch](data: Readonly<Partial<DataType>>): this {
this[kData] = Object.assign(this.getDataTemplate(), this[kData], data);
this.optimizeData(data);
return this;
}
/**
* Creates a clone of this structure
*
* @returns a clone of this
* @internal
*/
protected [kClone](patchPayload?: Readonly<Partial<DataType>>): typeof this {
const clone = this.toJSON();
// @ts-expect-error constructor is of abstract class is unknown
return new this.constructor(
// Ensure the ts-expect-error only applies to the constructor call
patchPayload ? Object.assign(clone, patchPayload) : clone,
);
}
/**
* Function called to ensure stored raw data is in optimized formats, used in tandem with a data template
*
* @example created_timestamp is an ISO string, this can be stored in optimized form as a number
* @param _data - the raw data received from the API to optimize
* @remarks Implementation to be done in subclasses and mixins where needed.
* For typescript users, mixins must use the closest ancestors access modifier.
* @remarks Automatically called in Structure[kPatch] but must be called manually in the constructor
* of any class implementing this method.
* @remarks Additionally, when implementing, ensure to call `super._optimizeData` if any class in the super chain aside
* from Structure contains an implementation.
* Note: mixins do not need to call super ever as the process of mixing walks the prototype chain.
* @virtual
* @internal
*/
protected optimizeData(_data: Partial<DataType>) {}
/**
* Transforms this object to its JSON format with raw API data (or close to it),
* automatically called by `JSON.stringify()` when this structure is stringified
*
* @remarks
* The type of this data is determined by omissions at runtime and is only guaranteed for default omissions
* @privateRemarks
* When omitting properties at the library level, this must be overridden to re-add those properties
*/
public toJSON(): DataType {
// This will be DataType provided nothing is omitted, when omits occur, subclass needs to overwrite this.
const data =
// Spread is way faster than structuredClone, but is shallow. So use it only if there is no nested objects
(
Object.values(this[kData]).some((value) => typeof value === 'object' && value !== null)
? structuredClone(this[kData])
: { ...this[kData] }
) as DataType;
this[kMixinToJSON]?.(data);
return data;
}
}

View File

@@ -0,0 +1,203 @@
import type { EnumLike, NonAbstract, RecursiveReadonlyArray } from '../utils/types.js';
// TODO: this currently is mostly copied from mainlib discord.js v14 and definitely needs a refactor in a later iteration
/**
* Data that can be resolved to give a bit field. This can be:
* A bit number (this can be a number literal or a value taken from {@link (BitField:class).Flags})
* A string bit number
* An instance of BitField
* An Array of BitFieldResolvable
*/
export type BitFieldResolvable<Flags extends string> =
| Flags
| Readonly<BitField<Flags>>
| RecursiveReadonlyArray<Flags | Readonly<BitField<Flags>> | bigint | number | `${bigint}`>
| bigint
| number
| `${bigint}`;
/**
* Data structure that makes it easy to interact with a bit field.
*/
export abstract class BitField<Flags extends string> {
/**
* Numeric bit field flags.
*
* @remarks Defined in extension classes
*/
public static readonly Flags: EnumLike<unknown, bigint | number> = {};
public static readonly DefaultBit: bigint = 0n;
/**
* Bitfield of the packed bits
*/
public bitField: bigint;
declare public ['constructor']: NonAbstract<typeof BitField<Flags>>;
/**
* @param bits - Bit(s) to read from
*/
public constructor(bits: BitFieldResolvable<Flags> = this.constructor.DefaultBit) {
this.bitField = this.constructor.resolve(bits);
}
/**
* Checks whether the bit field has a bit, or any of multiple bits.
*
* @param bit - Bit(s) to check for
* @returns Whether the bit field has the bit(s)
*/
public any(bit: BitFieldResolvable<Flags>) {
return (this.bitField & this.constructor.resolve(bit)) !== this.constructor.DefaultBit;
}
/**
* Checks if this bit field equals another
*
* @param bit - Bit(s) to check for
* @returns Whether this bit field equals the other
*/
public equals(bit: BitFieldResolvable<Flags>) {
return this.bitField === this.constructor.resolve(bit);
}
/**
* Checks whether the bit field has a bit, or multiple bits.
*
* @param bit - Bit(s) to check for
* @returns Whether the bit field has the bit(s)
*/
public has(bit: BitFieldResolvable<Flags>, ..._hasParams: unknown[]) {
const resolvedBit = this.constructor.resolve(bit);
return (this.bitField & resolvedBit) === resolvedBit;
}
/**
* Gets all given bits that are missing from the bit field.
*
* @param bits - Bit(s) to check for
* @param hasParams - Additional parameters for the has method, if any
* @returns A bit field containing the missing bits
*/
public missing(bits: BitFieldResolvable<Flags>, ...hasParams: readonly unknown[]) {
return new this.constructor(bits).remove(this).toArray(...hasParams);
}
/**
* Freezes these bits, making them immutable.
*
* @returns This bit field but frozen
*/
public freeze() {
return Object.freeze(this);
}
/**
* Adds bits to these ones.
*
* @param bits - Bits to add
* @returns These bits or new BitField if the instance is frozen.
*/
public add(...bits: BitFieldResolvable<Flags>[]) {
let total = this.constructor.DefaultBit;
for (const bit of bits) {
total |= this.constructor.resolve(bit);
}
if (Object.isFrozen(this)) return new this.constructor(this.bitField | total);
this.bitField |= total;
return this;
}
/**
* Removes bits from these.
*
* @param bits - Bits to remove
* @returns These bits or new BitField if the instance is frozen.
*/
public remove(...bits: BitFieldResolvable<Flags>[]) {
let total = this.constructor.DefaultBit;
for (const bit of bits) {
total |= this.constructor.resolve(bit);
}
if (Object.isFrozen(this)) return new this.constructor(this.bitField & ~total);
this.bitField &= ~total;
return this;
}
/**
* Gets an object mapping field names to a boolean indicating whether the bit is available.
*
* @param hasParams - Additional parameters for the has method, if any
* @returns An object mapping field names to a boolean indicating whether the bit is available
*/
public serialize(...hasParams: readonly unknown[]) {
const serialized: Partial<Record<keyof Flags, boolean>> = {};
for (const [flag, bit] of Object.entries(this.constructor.Flags)) {
if (Number.isNaN(Number(flag))) serialized[flag as keyof Flags] = this.has(bit as bigint | number, ...hasParams);
}
return serialized;
}
/**
* Gets an Array of bit field names based on the bits available.
*
* @param hasParams - Additional parameters for the has method, if any
* @returns An Array of bit field names
*/
public toArray(...hasParams: readonly unknown[]) {
return [...this[Symbol.iterator](...hasParams)];
}
public toJSON(asNumber?: boolean) {
if (asNumber) {
if (this.bitField > Number.MAX_SAFE_INTEGER) {
throw new RangeError(
`Cannot convert bitfield value ${this.bitField} to number, as it is bigger than ${Number.MAX_SAFE_INTEGER} (the maximum safe integer)`,
);
}
return Number(this.bitField);
}
return this.bitField.toString();
}
public valueOf() {
return this.bitField;
}
public *[Symbol.iterator](...hasParams: unknown[]) {
for (const bitName of Object.keys(this.constructor.Flags)) {
if (Number.isNaN(Number(bitName)) && this.has(bitName as Flags, ...hasParams)) yield bitName as Flags;
}
}
/**
* Resolves bit fields to their numeric form.
*
* @param bit - bit(s) to resolve
* @returns the numeric value of the bit fields
*/
public static resolve<Flags extends string = string>(bit: BitFieldResolvable<Flags>): bigint {
const DefaultBit = this.DefaultBit;
if (typeof bit === 'bigint' && bit >= DefaultBit) return bit;
if (typeof bit === 'number' && BigInt(bit) >= DefaultBit) return BigInt(bit);
if (bit instanceof BitField) return bit.bitField;
if (Array.isArray(bit)) {
return bit.map((bit_) => this.resolve(bit_)).reduce((prev, bit_) => prev | bit_, DefaultBit);
}
if (typeof bit === 'string') {
if (!Number.isNaN(Number(bit))) return BigInt(bit);
if (bit in this.Flags) return this.Flags[bit as keyof typeof this.Flags];
}
throw new Error(`BitFieldInvalid: ${JSON.stringify(bit)}`);
}
}

View File

@@ -0,0 +1,16 @@
import { ChannelFlags } from 'discord-api-types/v10';
import { BitField } from './BitField.js';
/**
* Data structure that makes it easy to interact with a {@link (Channel:class).flags} bitfield.
*/
export class ChannelFlagsBitField extends BitField<keyof ChannelFlags> {
/**
* Numeric guild channel flags.
*/
public static override readonly Flags = ChannelFlags;
public override toJSON() {
return super.toJSON(true);
}
}

View File

@@ -0,0 +1,76 @@
/* eslint-disable unicorn/consistent-function-scoping */
import { PermissionFlagsBits } from 'discord-api-types/v10';
import type { BitFieldResolvable } from './BitField.js';
import { BitField } from './BitField.js';
/**
* Data structure that makes it easy to interact with a permission bit field. All {@link GuildMember}s have a set of
* permissions in their guild, and each channel in the guild may also have {@link PermissionOverwrite}s for the member
* that override their default permissions.
*/
export class PermissionsBitField extends BitField<keyof typeof PermissionFlagsBits> {
/**
* Numeric permission flags.
*
* @see {@link https://discord.com/developers/docs/topics/permissions#permissions-bitwise-permission-flags}
*/
public static override Flags = PermissionFlagsBits;
/**
* Bit field representing every permission combined
*/
public static readonly All = Object.values(PermissionFlagsBits).reduce((all, perm) => all | perm, 0n);
/**
* Bit field representing the default permissions for users
*/
public static readonly Default = 104_324_673n;
/**
* Bit field representing the permissions required for moderators of stage channels
*/
public static readonly StageModerator =
PermissionFlagsBits.ManageChannels | PermissionFlagsBits.MuteMembers | PermissionFlagsBits.MoveMembers;
/**
* Gets all given bits that are missing from the bit field.
*
* @param bits - Bit(s) to check for
* @param checkAdmin - Whether to allow the administrator permission to override
* @returns A bit field containing the missing permissions
*/
public override missing(bits: BitFieldResolvable<keyof typeof PermissionFlagsBits>, checkAdmin = true) {
return checkAdmin && this.has(PermissionFlagsBits.Administrator) ? [] : super.missing(bits);
}
/**
* Checks whether the bit field has a permission, or any of multiple permissions.
*
* @param permission - Permission(s) to check for
* @param checkAdmin - Whether to allow the administrator permission to override
* @returns Whether the bit field has the permission(s)
*/
public override any(permission: BitFieldResolvable<keyof typeof PermissionFlagsBits>, checkAdmin = true) {
return (checkAdmin && super.has(PermissionFlagsBits.Administrator)) || super.any(permission);
}
/**
* Checks whether the bit field has a permission, or multiple permissions.
*
* @param permission - Permission(s) to check for
* @param checkAdmin - Whether to allow the administrator permission to override
* @returns Whether the bit field has the permission(s)
*/
public override has(permission: BitFieldResolvable<keyof typeof PermissionFlagsBits>, checkAdmin = true) {
return (checkAdmin && super.has(PermissionFlagsBits.Administrator)) || super.has(permission);
}
/**
* Gets an Array of bitfield names based on the permissions available.
*
* @returns An Array of permission names
*/
public override toArray() {
return super.toArray(false);
}
}

View File

@@ -0,0 +1,4 @@
export * from './BitField.js';
export * from './ChannelFlagsBitField.js';
export * from './PermissionsBitField.js';

View File

@@ -0,0 +1,46 @@
import type { APINewsChannel, ChannelType } from 'discord-api-types/v10';
import { Mixin } from '../Mixin.js';
import type { MixinTypes } from '../MixinTypes.d.ts';
import type { Partialize } from '../utils/types.js';
import { Channel } from './Channel.js';
import { ChannelParentMixin } from './mixins/ChannelParentMixin.js';
import { ChannelPermissionMixin } from './mixins/ChannelPermissionMixin.js';
import { ChannelPinMixin } from './mixins/ChannelPinMixin.js';
import { ChannelSlowmodeMixin } from './mixins/ChannelSlowmodeMixin.js';
import { ChannelTopicMixin } from './mixins/ChannelTopicMixin.js';
import { TextChannelMixin } from './mixins/TextChannelMixin.js';
export interface AnnouncementChannel<Omitted extends keyof APINewsChannel | '' = ''>
extends MixinTypes<
Channel<ChannelType.GuildAnnouncement>,
[
TextChannelMixin<ChannelType.GuildAnnouncement>,
ChannelParentMixin<ChannelType.GuildAnnouncement>,
ChannelPermissionMixin<ChannelType.GuildAnnouncement>,
ChannelPinMixin<ChannelType.GuildAnnouncement>,
ChannelSlowmodeMixin<ChannelType.GuildAnnouncement>,
ChannelTopicMixin<ChannelType.GuildAnnouncement>,
]
> {}
/**
* Sample Implementation of a structure for announcement channels, usable by direct end consumers.
*/
export class AnnouncementChannel<Omitted extends keyof APINewsChannel | '' = ''> extends Channel<
ChannelType.GuildAnnouncement,
Omitted
> {
public constructor(data: Partialize<APINewsChannel, Omitted>) {
super(data);
this.optimizeData(data);
}
}
Mixin(AnnouncementChannel, [
TextChannelMixin,
ChannelParentMixin,
ChannelPermissionMixin,
ChannelPinMixin,
ChannelSlowmodeMixin,
ChannelTopicMixin,
]);

View File

@@ -0,0 +1,49 @@
import type { APIAnnouncementThreadChannel, ChannelType } from 'discord-api-types/v10';
import { Mixin } from '../Mixin.js';
import type { MixinTypes } from '../MixinTypes.d.ts';
import type { Partialize } from '../utils/types.js';
import { Channel } from './Channel.js';
import { ChannelOwnerMixin } from './mixins/ChannelOwnerMixin.js';
import { ChannelParentMixin } from './mixins/ChannelParentMixin.js';
import { ChannelPinMixin } from './mixins/ChannelPinMixin.js';
import { ChannelSlowmodeMixin } from './mixins/ChannelSlowmodeMixin.js';
import { GuildChannelMixin } from './mixins/GuildChannelMixin.js';
import { TextChannelMixin } from './mixins/TextChannelMixin.js';
import { ThreadChannelMixin } from './mixins/ThreadChannelMixin.js';
export interface AnnouncementThreadChannel<Omitted extends keyof APIAnnouncementThreadChannel | '' = ''>
extends MixinTypes<
Channel<ChannelType.AnnouncementThread>,
[
TextChannelMixin<ChannelType.AnnouncementThread>,
ChannelOwnerMixin<ChannelType.AnnouncementThread>,
ChannelParentMixin<ChannelType.AnnouncementThread>,
ChannelPinMixin<ChannelType.AnnouncementThread>,
ChannelSlowmodeMixin<ChannelType.AnnouncementThread>,
GuildChannelMixin<ChannelType.AnnouncementThread>,
ThreadChannelMixin<ChannelType.AnnouncementThread>,
]
> {}
/**
* Sample Implementation of a structure for announcement threads, usable by direct end consumers.
*/
export class AnnouncementThreadChannel<Omitted extends keyof APIAnnouncementThreadChannel | '' = ''> extends Channel<
ChannelType.AnnouncementThread,
Omitted
> {
public constructor(data: Partialize<APIAnnouncementThreadChannel, Omitted>) {
super(data);
this.optimizeData?.(data);
}
}
Mixin(AnnouncementThreadChannel, [
TextChannelMixin,
ChannelOwnerMixin,
ChannelParentMixin,
ChannelPinMixin,
ChannelSlowmodeMixin,
GuildChannelMixin,
ThreadChannelMixin,
]);

View File

@@ -0,0 +1,28 @@
import type { APIGuildCategoryChannel, ChannelType } from 'discord-api-types/v10';
import { Mixin } from '../Mixin.js';
import type { MixinTypes } from '../MixinTypes.d.ts';
import type { Partialize } from '../utils/types.js';
import { Channel } from './Channel.js';
import { ChannelPermissionMixin } from './mixins/ChannelPermissionMixin.js';
import { GuildChannelMixin } from './mixins/GuildChannelMixin.js';
export interface CategoryChannel<Omitted extends keyof APIGuildCategoryChannel | '' = ''>
extends MixinTypes<
Channel<ChannelType.GuildCategory>,
[ChannelPermissionMixin<ChannelType.GuildCategory>, GuildChannelMixin<ChannelType.GuildCategory>]
> {}
/**
* Sample Implementation of a structure for category channels, usable by direct end consumers.
*/
export class CategoryChannel<Omitted extends keyof APIGuildCategoryChannel | '' = ''> extends Channel<
ChannelType.GuildCategory,
Omitted
> {
public constructor(data: Partialize<APIGuildCategoryChannel, Omitted>) {
super(data);
this.optimizeData(data);
}
}
Mixin(CategoryChannel, [ChannelPermissionMixin, GuildChannelMixin]);

View File

@@ -0,0 +1,185 @@
import { DiscordSnowflake } from '@sapphire/snowflake';
import type { APIChannel, APIPartialChannel, ChannelType, ChannelFlags } from 'discord-api-types/v10';
import { Structure } from '../Structure.js';
import { ChannelFlagsBitField } from '../bitfields/ChannelFlagsBitField.js';
import { kData, kPatch } from '../utils/symbols.js';
import { isIdSet } from '../utils/type-guards.js';
import type { Partialize } from '../utils/types.js';
import type { ChannelPermissionMixin } from './mixins/ChannelPermissionMixin.js';
import type { ChannelWebhookMixin } from './mixins/ChannelWebhookMixin.js';
import type { DMChannelMixin } from './mixins/DMChannelMixin.js';
import type { GuildChannelMixin } from './mixins/GuildChannelMixin.js';
import type { TextChannelMixin } from './mixins/TextChannelMixin.js';
import type { ThreadChannelMixin } from './mixins/ThreadChannelMixin.js';
import type { ThreadOnlyChannelMixin } from './mixins/ThreadOnlyChannelMixin.js';
import type { VoiceChannelMixin } from './mixins/VoiceChannelMixin.js';
export type PartialChannel = Channel<ChannelType, Exclude<keyof APIChannel, keyof APIPartialChannel>>;
/**
* The data stored by a {@link Channel} structure based on its {@link (Channel:class)."type"} property.
*/
export type ChannelDataType<Type extends ChannelType | 'unknown'> = Type extends ChannelType
? Extract<APIChannel, { type: Type }>
: APIPartialChannel;
/**
* Represents any channel on Discord.
*
* @typeParam Type - Specify the type of the channel being constructed for more accurate data types
* @typeParam Omitted - Specify the properties that will not be stored in the raw data field as a union, implement via `DataTemplate`
* @remarks Although this class _can_ be instantiated directly for any channel type,
* it's intended to be subclassed with the appropriate mixins for each channel type.
*/
export class Channel<
Type extends ChannelType | 'unknown' = ChannelType,
Omitted extends keyof ChannelDataType<Type> | '' = '',
> extends Structure<ChannelDataType<Type>, Omitted> {
/**
* The template used for removing data from the raw data stored for each Channel.
*
* @remarks This template is only guaranteed to apply to channels constructed directly via `new Channel()`.
* Use the appropriate subclass template to remove data from that channel type.
*/
public static override readonly DataTemplate: Partial<APIChannel> = {};
/**
* @param data - The raw data received from the API for the channel
*/
public constructor(data: Partialize<ChannelDataType<Type>, Omitted>) {
super(data as ChannelDataType<Type>);
}
/**
* {@inheritDoc Structure.[kPatch]}
*
* @internal
*/
public override [kPatch](data: Partial<ChannelDataType<Type>>) {
return super[kPatch](data);
}
/**
* The id of the channel
*/
public get id() {
return this[kData].id;
}
/**
* The type of the channel
*/
public get type() {
// This cast can be incorrect when type is omitted and if the wrong type of channel was constructed
return this[kData].type as Type extends 'unknown' ? number : Type;
}
/**
* The name of the channel, null for DMs
*
* @privateRemarks The type of `name` can be narrowed in Guild Channels and DM channels to string and null respectively,
* respecting Omit behaviors
*/
public get name() {
return this[kData].name;
}
/**
* The flags that are applied to the channel.
*
* @privateRemarks The type of `flags` can be narrowed in Guild Channels and DMChannel to ChannelFlags, and in GroupDM channel
* to null, respecting Omit behaviors
*/
public get flags() {
const flags =
'flags' in this[kData] && typeof this[kData].flags === 'number' ? (this[kData].flags as ChannelFlags) : null;
return flags ? new ChannelFlagsBitField(flags) : null;
}
/**
* The timestamp the channel was created at
*/
public get createdTimestamp() {
return isIdSet(this.id) ? DiscordSnowflake.timestampFrom(this.id) : null;
}
/**
* The time the channel was created at
*/
public get createdAt() {
const createdTimestamp = this.createdTimestamp;
return createdTimestamp ? new Date(createdTimestamp) : null;
}
/**
* Indicates whether this channel is a thread channel
*
* @privateRemarks Overridden to `true` on `ThreadChannelMixin`
*/
public isThread(): this is ThreadChannelMixin & this {
return false;
}
/**
* Indicates whether this channel can contain messages
*
* @privateRemarks Overridden to `true` on `TextChannelMixin`
*/
public isTextBased(): this is TextChannelMixin & this {
return false;
}
/**
* Indicates whether this channel is in a guild
*
* @privateRemarks Overridden to `true` on `GuildChannelMixin`
*/
public isGuildBased(): this is GuildChannelMixin & this {
return false;
}
/**
* Indicates whether this channel is a DM or DM Group
*
* @privateRemarks Overridden to `true` on `DMChannelMixin`
*/
public isDMBased(): this is DMChannelMixin & this {
return false;
}
/**
* Indicates whether this channel has voice connection capabilities
*
* @privateRemarks Overridden to `true` on `VoiceChannelMixin`
*/
public isVoiceBased(): this is VoiceChannelMixin & this {
return false;
}
/**
* Indicates whether this channel only allows thread creation
*
* @privateRemarks Overridden to `true` on `ThreadOnlyChannelMixin`
*/
public isThreadOnly(): this is ThreadOnlyChannelMixin & this {
return false;
}
/**
* Indicates whether this channel can have permission overwrites
*
* @privateRemarks Overridden to `true` on `ChannelPermissionsMixin`
*/
public isPermissionCapable(): this is ChannelPermissionMixin & this {
return false;
}
/**
* Indicates whether this channel can have webhooks
*
* @privateRemarks Overridden to `true` on `ChannelWebhooksMixin`
*/
public isWebhookCapable(): this is ChannelWebhookMixin & this {
return false;
}
}

View File

@@ -0,0 +1,26 @@
import type { APIDMChannel, ChannelType } from 'discord-api-types/v10';
import { Mixin } from '../Mixin.js';
import type { MixinTypes } from '../MixinTypes.d.ts';
import type { Partialize } from '../utils/types.js';
import { Channel } from './Channel.js';
import { ChannelPinMixin } from './mixins/ChannelPinMixin.js';
import { DMChannelMixin } from './mixins/DMChannelMixin.js';
import { TextChannelMixin } from './mixins/TextChannelMixin.js';
export interface DMChannel<Omitted extends keyof APIDMChannel | '' = ''>
extends MixinTypes<
Channel<ChannelType.DM>,
[DMChannelMixin<ChannelType.DM>, TextChannelMixin<ChannelType.DM>, ChannelPinMixin<ChannelType.DM>]
> {}
/**
* Sample Implementation of a structure for dm channels, usable by direct end consumers.
*/
export class DMChannel<Omitted extends keyof APIDMChannel | '' = ''> extends Channel<ChannelType.DM, Omitted> {
public constructor(data: Partialize<APIDMChannel, Omitted>) {
super(data);
this.optimizeData(data);
}
}
Mixin(DMChannel, [DMChannelMixin, TextChannelMixin, ChannelPinMixin]);

View File

@@ -0,0 +1,44 @@
import type { APIGuildForumChannel, ChannelType } from 'discord-api-types/v10';
import { Mixin } from '../Mixin.js';
import type { MixinTypes } from '../MixinTypes.d.ts';
import { kData } from '../utils/symbols.js';
import type { Partialize } from '../utils/types.js';
import { Channel } from './Channel.js';
import { ChannelParentMixin } from './mixins/ChannelParentMixin.js';
import { ChannelPermissionMixin } from './mixins/ChannelPermissionMixin.js';
import { ChannelTopicMixin } from './mixins/ChannelTopicMixin.js';
import { ThreadOnlyChannelMixin } from './mixins/ThreadOnlyChannelMixin.js';
export interface ForumChannel<Omitted extends keyof APIGuildForumChannel | '' = ''>
extends MixinTypes<
Channel<ChannelType.GuildForum>,
[
ChannelParentMixin<ChannelType.GuildForum>,
ChannelPermissionMixin<ChannelType.GuildForum>,
ChannelTopicMixin<ChannelType.GuildForum>,
ThreadOnlyChannelMixin<ChannelType.GuildForum>,
]
> {}
/**
* Sample Implementation of a structure for forum channels, usable by direct end consumers.
*/
export class ForumChannel<Omitted extends keyof APIGuildForumChannel | '' = ''> extends Channel<
ChannelType.GuildForum,
Omitted
> {
public constructor(data: Partialize<APIGuildForumChannel, Omitted>) {
super(data);
this.optimizeData(data);
}
/**
* The default forum layout view used to display posts in this channel.
* Defaults to 0, which indicates a layout view has not been set by a channel admin.
*/
public get defaultForumLayout() {
return this[kData].default_forum_layout;
}
}
Mixin(ForumChannel, [ChannelParentMixin, ChannelPermissionMixin, ChannelTopicMixin, ThreadOnlyChannelMixin]);

View File

@@ -0,0 +1,57 @@
import type { APIGuildForumTag } from 'discord-api-types/v10';
import { Structure } from '../Structure.js';
import { kData } from '../utils/symbols.js';
import type { Partialize } from '../utils/types.js';
/**
* Represents metadata of a thread channel 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 ForumTag<Omitted extends keyof APIGuildForumTag | '' = ''> extends Structure<APIGuildForumTag, Omitted> {
public constructor(data: Partialize<APIGuildForumTag, Omitted>) {
super(data);
}
/**
* The id of the tag.
*/
public get id() {
return this[kData].id;
}
/**
* The name of the tag.
*/
public get name() {
return this[kData].name;
}
/**
* Whether this tag can only be added to or removed from threads by a member with the {@link discord-api-types/v10#(PermissionFlagsBits:variable) | ManageThreads} permission.
*/
public get moderated() {
return this[kData].moderated;
}
/**
* The id of a guild's custom emoji.
*/
public get emojiId() {
return this[kData].emoji_id;
}
/**
* The unicode character of the emoji.
*/
public get emojiName() {
return this[kData].emoji_name;
}
/**
* The textual representation of this tag's emoji. Either a unicode character or a guild emoji mention.
*/
public get emoji() {
return this.emojiName ?? `<:_:${this.emojiId}>`;
}
}

View File

@@ -0,0 +1,35 @@
import type { APIGroupDMChannel, ChannelType } from 'discord-api-types/v10';
import { Mixin } from '../Mixin.js';
import type { MixinTypes } from '../MixinTypes.d.ts';
import type { Partialize } from '../utils/types.js';
import { Channel } from './Channel.js';
import { ChannelOwnerMixin } from './mixins/ChannelOwnerMixin.js';
import { DMChannelMixin } from './mixins/DMChannelMixin.js';
import { GroupDMMixin } from './mixins/GroupDMMixin.js';
import { TextChannelMixin } from './mixins/TextChannelMixin.js';
export interface GroupDMChannel<Omitted extends keyof APIGroupDMChannel | '' = ''>
extends MixinTypes<
Channel<ChannelType.GroupDM>,
[
DMChannelMixin<ChannelType.GroupDM>,
TextChannelMixin<ChannelType.GroupDM>,
ChannelOwnerMixin<ChannelType.GroupDM>,
GroupDMMixin,
]
> {}
/**
* Sample Implementation of a structure for group dm channels, usable by direct end consumers.
*/
export class GroupDMChannel<Omitted extends keyof APIGroupDMChannel | '' = ''> extends Channel<
ChannelType.GroupDM,
Omitted
> {
public constructor(data: Partialize<APIGroupDMChannel, Omitted>) {
super(data);
this.optimizeData(data);
}
}
Mixin(GroupDMChannel, [DMChannelMixin, TextChannelMixin, ChannelOwnerMixin, GroupDMMixin]);

View File

@@ -0,0 +1,35 @@
import type { APIGuildMediaChannel, ChannelType } from 'discord-api-types/v10';
import { Mixin } from '../Mixin.js';
import type { MixinTypes } from '../MixinTypes.d.ts';
import type { Partialize } from '../utils/types.js';
import { Channel } from './Channel.js';
import { ChannelParentMixin } from './mixins/ChannelParentMixin.js';
import { ChannelPermissionMixin } from './mixins/ChannelPermissionMixin.js';
import { ChannelTopicMixin } from './mixins/ChannelTopicMixin.js';
import { ThreadOnlyChannelMixin } from './mixins/ThreadOnlyChannelMixin.js';
export interface MediaChannel<Omitted extends keyof APIGuildMediaChannel | '' = ''>
extends MixinTypes<
Channel<ChannelType.GuildMedia>,
[
ChannelParentMixin<ChannelType.GuildMedia>,
ChannelPermissionMixin<ChannelType.GuildMedia>,
ChannelTopicMixin<ChannelType.GuildMedia>,
ThreadOnlyChannelMixin<ChannelType.GuildMedia>,
]
> {}
/**
* Sample Implementation of a structure for media channels, usable by direct end consumers.
*/
export class MediaChannel<Omitted extends keyof APIGuildMediaChannel | '' = ''> extends Channel<
ChannelType.GuildMedia,
Omitted
> {
public constructor(data: Partialize<APIGuildMediaChannel, Omitted>) {
super(data);
this.optimizeData(data);
}
}
Mixin(MediaChannel, [ChannelParentMixin, ChannelPermissionMixin, ChannelTopicMixin, ThreadOnlyChannelMixin]);

View File

@@ -0,0 +1,94 @@
import type { APIOverwrite } from 'discord-api-types/v10';
import { Structure } from '../Structure.js';
import { PermissionsBitField } from '../bitfields/PermissionsBitField.js';
import { kAllow, kData, kDeny } from '../utils/symbols.js';
import type { Partialize } from '../utils/types.js';
/**
* Represents metadata of a thread channel 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 PermissionOverwrite<Omitted extends keyof APIOverwrite | '' = 'allow' | 'deny'> extends Structure<
APIOverwrite,
Omitted
> {
protected [kAllow]: bigint | null = null;
protected [kDeny]: bigint | null = null;
public constructor(data: Partialize<APIOverwrite, Omitted>) {
super(data);
this.optimizeData(data);
}
/**
* The template used for removing data from the raw data stored for each ThreadMetadata
*
* @remarks This template has defaults, if you want to remove additional data and keep the defaults,
* use `Object.defineProperties`. To override the defaults, set this value directly.
*/
public static override readonly DataTemplate: Partial<APIOverwrite> = {
set allow(_: string) {},
set deny(_: string) {},
};
/**
* {@inheritDoc Structure.optimizeData}
*/
protected override optimizeData(data: Partial<APIOverwrite>) {
if (data.allow) {
this[kAllow] = BigInt(data.allow);
}
if (data.deny) {
this[kDeny] = BigInt(data.deny);
}
}
/**
* The permission bit set allowed by this overwrite.
*/
public get allow() {
const allow = this[kAllow];
return typeof allow === 'bigint' ? new PermissionsBitField(allow) : null;
}
/**
* The permission bit set denied by this overwrite.
*/
public get deny() {
const deny = this[kDeny];
return typeof deny === 'bigint' ? new PermissionsBitField(deny) : null;
}
/**
* The role or user id for this overwrite.
*/
public get id() {
return this[kData].id;
}
/**
* The type of this overwrite.
*/
public get type() {
return this[kData].type;
}
/**
* {@inheritDoc Structure.toJSON}
*/
public override toJSON() {
const clone = super.toJSON();
if (this[kAllow]) {
clone.allow = this[kAllow].toString();
}
if (this[kDeny]) {
clone.deny = this[kDeny].toString();
}
return clone;
}
}

View File

@@ -0,0 +1,46 @@
import type { APIPrivateThreadChannel, ChannelType } from 'discord-api-types/v10';
import { Mixin } from '../Mixin.js';
import type { MixinTypes } from '../MixinTypes.d.ts';
import type { Partialize } from '../utils/types.js';
import { Channel } from './Channel.js';
import { ChannelOwnerMixin } from './mixins/ChannelOwnerMixin.js';
import { ChannelParentMixin } from './mixins/ChannelParentMixin.js';
import { ChannelPinMixin } from './mixins/ChannelPinMixin.js';
import { ChannelSlowmodeMixin } from './mixins/ChannelSlowmodeMixin.js';
import { TextChannelMixin } from './mixins/TextChannelMixin.js';
import { ThreadChannelMixin } from './mixins/ThreadChannelMixin.js';
export interface PrivateThreadChannel<Omitted extends keyof APIPrivateThreadChannel | '' = ''>
extends MixinTypes<
Channel<ChannelType.PrivateThread>,
[
TextChannelMixin<ChannelType.PrivateThread>,
ChannelOwnerMixin<ChannelType.PrivateThread>,
ChannelParentMixin<ChannelType.PrivateThread>,
ChannelPinMixin<ChannelType.PrivateThread>,
ChannelSlowmodeMixin<ChannelType.PrivateThread>,
ThreadChannelMixin<ChannelType.PrivateThread>,
]
> {}
/**
* Sample Implementation of a structure for private thread channels, usable by direct end consumers.
*/
export class PrivateThreadChannel<Omitted extends keyof APIPrivateThreadChannel | '' = ''> extends Channel<
ChannelType.PrivateThread,
Omitted
> {
public constructor(data: Partialize<APIPrivateThreadChannel, Omitted>) {
super(data);
this.optimizeData(data);
}
}
Mixin(PrivateThreadChannel, [
TextChannelMixin,
ChannelOwnerMixin,
ChannelParentMixin,
ChannelPinMixin,
ChannelSlowmodeMixin,
ThreadChannelMixin,
]);

View File

@@ -0,0 +1,49 @@
import type { APIPublicThreadChannel, ChannelType } from 'discord-api-types/v10';
import { Mixin } from '../Mixin.js';
import type { MixinTypes } from '../MixinTypes.d.ts';
import type { Partialize } from '../utils/types.js';
import { Channel } from './Channel.js';
import { AppliedTagsMixin } from './mixins/AppliedTagsMixin.js';
import { ChannelOwnerMixin } from './mixins/ChannelOwnerMixin.js';
import { ChannelParentMixin } from './mixins/ChannelParentMixin.js';
import { ChannelPinMixin } from './mixins/ChannelPinMixin.js';
import { ChannelSlowmodeMixin } from './mixins/ChannelSlowmodeMixin.js';
import { TextChannelMixin } from './mixins/TextChannelMixin.js';
import { ThreadChannelMixin } from './mixins/ThreadChannelMixin.js';
export interface PublicThreadChannel<Omitted extends keyof APIPublicThreadChannel | '' = ''>
extends MixinTypes<
Channel<ChannelType.PublicThread>,
[
TextChannelMixin<ChannelType.PublicThread>,
ChannelOwnerMixin<ChannelType.PublicThread>,
ChannelParentMixin<ChannelType.PublicThread>,
ChannelPinMixin<ChannelType.PublicThread>,
ChannelSlowmodeMixin<ChannelType.PublicThread>,
ThreadChannelMixin<ChannelType.PublicThread>,
AppliedTagsMixin,
]
> {}
/**
* Sample Implementation of a structure for public thread channels, usable by direct end consumers.
*/
export class PublicThreadChannel<Omitted extends keyof APIPublicThreadChannel | '' = ''> extends Channel<
ChannelType.PublicThread,
Omitted
> {
public constructor(data: Partialize<APIPublicThreadChannel, Omitted>) {
super(data);
this.optimizeData(data);
}
}
Mixin(PublicThreadChannel, [
TextChannelMixin,
ChannelOwnerMixin,
ChannelParentMixin,
ChannelPinMixin,
ChannelSlowmodeMixin,
ThreadChannelMixin,
AppliedTagsMixin,
]);

View File

@@ -0,0 +1,40 @@
import type { APIGuildStageVoiceChannel, ChannelType } from 'discord-api-types/v10';
import { Mixin } from '../Mixin.js';
import type { MixinTypes } from '../MixinTypes.d.ts';
import type { Partialize } from '../utils/types.js';
import { Channel } from './Channel.js';
import { ChannelParentMixin } from './mixins/ChannelParentMixin.js';
import { ChannelPermissionMixin } from './mixins/ChannelPermissionMixin.js';
import { ChannelSlowmodeMixin } from './mixins/ChannelSlowmodeMixin.js';
import { ChannelWebhookMixin } from './mixins/ChannelWebhookMixin.js';
import { VoiceChannelMixin } from './mixins/VoiceChannelMixin.js';
export interface StageChannel<Omitted extends keyof APIGuildStageVoiceChannel | '' = ''>
extends MixinTypes<
Channel<ChannelType.GuildStageVoice>,
[
ChannelParentMixin<ChannelType.GuildStageVoice>,
ChannelPermissionMixin<ChannelType.GuildStageVoice>,
ChannelSlowmodeMixin<ChannelType.GuildStageVoice>,
ChannelWebhookMixin<ChannelType.GuildStageVoice>,
VoiceChannelMixin<ChannelType.GuildStageVoice>,
]
> {}
export class StageChannel<Omitted extends keyof APIGuildStageVoiceChannel | '' = ''> extends Channel<
ChannelType.GuildStageVoice,
Omitted
> {
public constructor(data: Partialize<APIGuildStageVoiceChannel, Omitted>) {
super(data);
this.optimizeData(data);
}
}
Mixin(StageChannel, [
ChannelParentMixin,
ChannelPermissionMixin,
ChannelSlowmodeMixin,
ChannelWebhookMixin,
VoiceChannelMixin,
]);

View File

@@ -0,0 +1,43 @@
import type { APITextChannel, ChannelType } from 'discord-api-types/v10';
import { Mixin } from '../Mixin.js';
import type { MixinTypes } from '../MixinTypes.d.ts';
import type { Partialize } from '../utils/types.js';
import { Channel } from './Channel.js';
import { ChannelParentMixin } from './mixins/ChannelParentMixin.js';
import { ChannelPermissionMixin } from './mixins/ChannelPermissionMixin.js';
import { ChannelPinMixin } from './mixins/ChannelPinMixin.js';
import { ChannelSlowmodeMixin } from './mixins/ChannelSlowmodeMixin.js';
import { ChannelTopicMixin } from './mixins/ChannelTopicMixin.js';
import { TextChannelMixin } from './mixins/TextChannelMixin.js';
export interface TextChannel<Omitted extends keyof APITextChannel | '' = ''>
extends MixinTypes<
Channel<ChannelType.GuildText>,
[
TextChannelMixin<ChannelType.GuildText>,
ChannelParentMixin<ChannelType.GuildText>,
ChannelPermissionMixin<ChannelType.GuildText>,
ChannelPinMixin<ChannelType.GuildText>,
ChannelSlowmodeMixin<ChannelType.GuildText>,
ChannelTopicMixin<ChannelType.GuildText>,
]
> {}
export class TextChannel<Omitted extends keyof APITextChannel | '' = ''> extends Channel<
ChannelType.GuildText,
Omitted
> {
public constructor(data: Partialize<APITextChannel, Omitted>) {
super(data);
this.optimizeData(data);
}
}
Mixin(TextChannel, [
TextChannelMixin,
ChannelParentMixin,
ChannelPermissionMixin,
ChannelPinMixin,
ChannelSlowmodeMixin,
ChannelTopicMixin,
]);

View File

@@ -0,0 +1,120 @@
import type { APIThreadMetadata } from 'discord-api-types/v10';
import { Structure } from '../Structure.js';
import { kArchiveTimestamp, kCreatedTimestamp, kData } from '../utils/symbols.js';
import type { Partialize } from '../utils/types.js';
/**
* Represents metadata of a thread channel 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 ThreadMetadata<
Omitted extends keyof APIThreadMetadata | '' = 'archive_timestamp' | 'create_timestamp',
> extends Structure<APIThreadMetadata, Omitted> {
protected [kArchiveTimestamp]: number | null = null;
protected [kCreatedTimestamp]: number | null = null;
public constructor(data: Partialize<APIThreadMetadata, Omitted>) {
super(data);
this.optimizeData(data);
}
/**
* The template used for removing data from the raw data stored for each ThreadMetadata
*
* @remarks This template has defaults, if you want to remove additional data and keep the defaults,
* use `Object.defineProperties`. To override the defaults, set this value directly.
*/
public static override readonly DataTemplate: Partial<APIThreadMetadata> = {
set create_timestamp(_: string) {},
set archive_timestamp(_: string) {},
};
/**
* {@inheritDoc Structure.optimizeData}
*/
protected override optimizeData(data: Partial<APIThreadMetadata>) {
if (data.create_timestamp) {
this[kCreatedTimestamp] = Date.parse(data.create_timestamp);
}
if (data.archive_timestamp) {
this[kArchiveTimestamp] = Date.parse(data.archive_timestamp);
}
}
/**
* Whether the thread is archived.
*/
public get archived() {
return this[kData].archived;
}
/**
* The timestamp when the thread's archive status was last changed, used for calculating recent activity.
*/
public get archivedTimestamp() {
return this[kArchiveTimestamp];
}
/**
* The timestamp when the thread was created; only populated for threads created after 2022-01-09.
*/
public get createdTimestamp() {
return this[kCreatedTimestamp];
}
/**
* The thread will stop showing in the channel list after auto_archive_duration minutes of inactivity,
*/
public get autoArchiveDuration() {
return this[kData].auto_archive_duration;
}
/**
* Whether non-moderators can add other non-moderators to a thread; only available on private threads.
*/
public get invitable() {
return this[kData].invitable;
}
/**
* Whether the thread is locked; when a thread is locked, only users with {@link discord-api-types/v10#(PermissionFlagsBits:variable) | ManageThreads} can unarchive it.
*/
public get locked() {
return this[kData].locked;
}
/**
* The time the thread was archived at
*/
public get archivedAt() {
const archivedTimestamp = this.archivedTimestamp;
return archivedTimestamp ? new Date(archivedTimestamp) : null;
}
/**
* The time the thread was created at
*/
public get createdAt() {
const createdTimestamp = this.createdTimestamp;
return createdTimestamp ? new Date(createdTimestamp) : null;
}
/**
* {@inheritDoc Structure.toJSON}
*/
public override toJSON() {
const data = super.toJSON();
if (this[kArchiveTimestamp]) {
data.archive_timestamp = new Date(this[kArchiveTimestamp]).toISOString();
}
if (this[kCreatedTimestamp]) {
data.create_timestamp = new Date(this[kCreatedTimestamp]).toISOString();
}
return data;
}
}

View File

@@ -0,0 +1,40 @@
import type { APIGuildVoiceChannel, ChannelType } from 'discord-api-types/v10';
import { Mixin } from '../Mixin.js';
import type { MixinTypes } from '../MixinTypes.d.ts';
import type { Partialize } from '../utils/types.js';
import { Channel } from './Channel.js';
import { ChannelParentMixin } from './mixins/ChannelParentMixin.js';
import { ChannelPermissionMixin } from './mixins/ChannelPermissionMixin.js';
import { ChannelSlowmodeMixin } from './mixins/ChannelSlowmodeMixin.js';
import { ChannelWebhookMixin } from './mixins/ChannelWebhookMixin.js';
import { VoiceChannelMixin } from './mixins/VoiceChannelMixin.js';
export interface VoiceChannel<Omitted extends keyof APIGuildVoiceChannel | '' = ''>
extends MixinTypes<
Channel<ChannelType.GuildVoice>,
[
ChannelParentMixin<ChannelType.GuildVoice>,
ChannelPermissionMixin<ChannelType.GuildVoice>,
ChannelSlowmodeMixin<ChannelType.GuildVoice>,
ChannelWebhookMixin<ChannelType.GuildVoice>,
VoiceChannelMixin<ChannelType.GuildVoice>,
]
> {}
export class VoiceChannel<Omitted extends keyof APIGuildVoiceChannel | '' = ''> extends Channel<
ChannelType.GuildVoice,
Omitted
> {
public constructor(data: Partialize<APIGuildVoiceChannel, Omitted>) {
super(data);
this.optimizeData(data);
}
}
Mixin(VoiceChannel, [
ChannelParentMixin,
ChannelPermissionMixin,
ChannelSlowmodeMixin,
ChannelWebhookMixin,
VoiceChannelMixin,
]);

View File

@@ -0,0 +1,21 @@
export * from './mixins/index.js';
export * from './ForumTag.js';
export * from './PermissionOverwrite.js';
export * from './ThreadMetadata.js';
export * from './Channel.js';
export * from './AnnouncementChannel.js';
export * from './AnnouncementThreadChannel.js';
export * from './CategoryChannel.js';
// export * from './DirectoryChannel.js';
export * from './DMChannel.js';
export * from './ForumChannel.js';
export * from './GroupDMChannel.js';
export * from './MediaChannel.js';
export * from './PrivateThreadChannel.js';
export * from './PublicThreadChannel.js';
export * from './StageChannel.js';
export * from './TextChannel.js';
export * from './VoiceChannel.js';

View File

@@ -0,0 +1,14 @@
import type { ChannelType } from 'discord-api-types/v10';
import { kData } from '../../utils/symbols.js';
import type { Channel } from '../Channel.js';
export interface AppliedTagsMixin extends Channel<ChannelType.PublicThread> {}
export class AppliedTagsMixin {
/**
* The ids of the set of tags that have been applied to a thread in a {@link (ForumChannel:class)} or a {@link (MediaChannel:class)}.
*/
public get appliedTags(): readonly string[] | null {
return Array.isArray(this[kData].applied_tags) ? this[kData].applied_tags : null;
}
}

View File

@@ -0,0 +1,14 @@
import type { ChannelType, ThreadChannelType } from 'discord-api-types/v10';
import { kData } from '../../utils/symbols.js';
import type { Channel } from '../Channel.js';
export interface ChannelOwnerMixin<Type extends ChannelType.GroupDM | ThreadChannelType> extends Channel<Type> {}
export class ChannelOwnerMixin<Type extends ChannelType.GroupDM | ThreadChannelType> {
/**
* The id of the creator of the group DM or thread
*/
public get ownerId() {
return this[kData].owner_id;
}
}

View File

@@ -0,0 +1,21 @@
import type { ChannelType, GuildChannelType } from 'discord-api-types/v10';
import { kData } from '../../utils/symbols.js';
import { GuildChannelMixin } from './GuildChannelMixin.js';
export class ChannelParentMixin<
Type extends Exclude<GuildChannelType, ChannelType.GuildCategory | ChannelType.GuildDirectory>,
> extends GuildChannelMixin<Type> {
/**
* The id of the parent category for a channel (each parent category can contain up to 50 channels) or id of the parent channel for a thread
*/
public get parentId() {
return this[kData].parent_id;
}
/**
* Whether the channel is nsfw
*/
public get nsfw() {
return this[kData].nsfw;
}
}

View File

@@ -0,0 +1,34 @@
import type { ChannelType, GuildChannelType, ThreadChannelType } from 'discord-api-types/v10';
import { kData } from '../../utils/symbols.js';
import type { Channel } from '../Channel.js';
export interface ChannelPermissionMixin<
Type extends Exclude<GuildChannelType, ChannelType.GuildDirectory | ThreadChannelType> = Exclude<
GuildChannelType,
ChannelType.GuildDirectory | ThreadChannelType
>,
> extends Channel<Type> {}
/**
* @remarks has an array of sub-structures {@link PermissionOverwrite} that extending mixins should add to their DataTemplate and _optimizeData
*/
export class ChannelPermissionMixin<
Type extends Exclude<GuildChannelType, ChannelType.GuildDirectory | ThreadChannelType> = Exclude<
GuildChannelType,
ChannelType.GuildDirectory | ThreadChannelType
>,
> {
/**
* The sorting position of the channel
*/
public get position() {
return this[kData].position;
}
/**
* Indicates whether this channel can have permission overwrites
*/
public isPermissionCapable(): this is ChannelPermissionMixin & this {
return true;
}
}

View File

@@ -0,0 +1,62 @@
import type { ChannelType, ThreadChannelType } from 'discord-api-types/v10';
import { kLastPinTimestamp, kMixinConstruct, kMixinToJSON } from '../../utils/symbols.js';
import type { Channel, ChannelDataType } from '../Channel.js';
export interface ChannelPinMixin<
Type extends ChannelType.DM | ChannelType.GuildAnnouncement | ChannelType.GuildText | ThreadChannelType,
> extends Channel<Type> {}
export class ChannelPinMixin<
Type extends ChannelType.DM | ChannelType.GuildAnnouncement | ChannelType.GuildText | ThreadChannelType,
> {
/**
* The timestamp of when the last pin in the channel happened
*/
declare protected [kLastPinTimestamp]: number | null;
/**
* The template used for removing data from the raw data stored for each Channel.
*/
public static readonly DataTemplate: Partial<
ChannelDataType<ChannelType.DM | ChannelType.GuildAnnouncement | ChannelType.GuildText | ThreadChannelType>
> = {
set last_pin_timestamp(_: string) {},
};
public [kMixinConstruct]() {
this[kLastPinTimestamp] ??= null;
}
/**
* {@inheritDoc Structure.optimizeData}
*/
protected optimizeData(data: Partial<ChannelDataType<Type>>) {
if (data.last_pin_timestamp) {
this[kLastPinTimestamp] = Date.parse(data.last_pin_timestamp);
}
}
/**
* The timestamp of when the last pin in the channel happened.
*/
public get lastPinTimestamp() {
return this[kLastPinTimestamp];
}
/**
* The Date of when the last pin in the channel happened
*/
public get lastPinAt() {
const lastPinTimestamp = this.lastPinTimestamp;
return lastPinTimestamp ? new Date(lastPinTimestamp) : null;
}
/**
* Adds data from optimized properties omitted from [kData].
*
* @param data - the result of {@link (Structure:class).toJSON}
*/
protected [kMixinToJSON](data: Partial<ChannelDataType<Type>>) {
data.last_pin_timestamp = this[kLastPinTimestamp] ? new Date(this[kLastPinTimestamp]).toISOString() : null;
}
}

View File

@@ -0,0 +1,12 @@
import type { GuildTextChannelType } from 'discord-api-types/v10';
import { kData } from '../../utils/symbols.js';
import { TextChannelMixin } from './TextChannelMixin.js';
export class ChannelSlowmodeMixin<Type extends GuildTextChannelType> extends TextChannelMixin<Type> {
/**
* The rate limit per user (slowmode) of this channel.
*/
public get rateLimitPerUser() {
return this[kData].rate_limit_per_user;
}
}

View File

@@ -0,0 +1,33 @@
import type { ChannelType } from 'discord-api-types/v10';
import { kData } from '../../utils/symbols.js';
import type { Channel } from '../Channel.js';
import { ChannelWebhookMixin } from './ChannelWebhookMixin.js';
export interface ChannelTopicMixin<
Type extends ChannelType.GuildAnnouncement | ChannelType.GuildForum | ChannelType.GuildMedia | ChannelType.GuildText,
> extends Channel<Type> {}
export class ChannelTopicMixin<
Type extends ChannelType.GuildAnnouncement | ChannelType.GuildForum | ChannelType.GuildMedia | ChannelType.GuildText,
> extends ChannelWebhookMixin<Type> {
/**
* The topic of this channel.
*/
public get topic() {
return this[kData].topic;
}
/**
* The duration after which new threads get archived by default on this channel.
*/
public get defaultAutoArchiveDuration() {
return this[kData].default_auto_archive_duration;
}
/**
* The default value for rate limit per user (slowmode) on new threads in this channel.
*/
public get defaultThreadRateLimitPerUser() {
return this[kData].default_thread_rate_limit_per_user;
}
}

View File

@@ -0,0 +1,23 @@
import type { ChannelType, GuildTextChannelType, ThreadChannelType } from 'discord-api-types/v10';
import type { Channel } from '../Channel.js';
export interface ChannelWebhookMixin<
Type extends ChannelType.GuildForum | ChannelType.GuildMedia | Exclude<GuildTextChannelType, ThreadChannelType> =
| ChannelType.GuildForum
| ChannelType.GuildMedia
| Exclude<GuildTextChannelType, ThreadChannelType>,
> extends Channel<Type> {}
export class ChannelWebhookMixin<
Type extends ChannelType.GuildForum | ChannelType.GuildMedia | Exclude<GuildTextChannelType, ThreadChannelType> =
| ChannelType.GuildForum
| ChannelType.GuildMedia
| Exclude<GuildTextChannelType, ThreadChannelType>,
> {
/**
* Indicates whether this channel can have webhooks
*/
public isWebhookCapable(): this is ChannelWebhookMixin & this {
return true;
}
}

View File

@@ -0,0 +1,27 @@
import { channelLink } from '@discordjs/formatters';
import type { ChannelType } from 'discord-api-types/v10';
import type { User } from '../../users/User.js';
import type { Channel } from '../Channel.js';
export interface DMChannelMixin<
Type extends ChannelType.DM | ChannelType.GroupDM = ChannelType.DM | ChannelType.GroupDM,
> extends Channel<Type> {}
/**
* @remarks has recipients, an array of sub-structures {@link User} that extending mixins should add to their DataTemplate and _optimizeData
*/
export class DMChannelMixin<Type extends ChannelType.DM | ChannelType.GroupDM = ChannelType.DM | ChannelType.GroupDM> {
/**
* The URL to this channel.
*/
public get url() {
return channelLink(this.id);
}
/**
* Indicates whether this channel is a DM or DM Group
*/
public isDMBased(): this is DMChannelMixin & this {
return true;
}
}

View File

@@ -0,0 +1,28 @@
import type { ChannelType } from 'discord-api-types/v10';
import { kData } from '../../utils/symbols.js';
import type { Channel } from '../Channel.js';
export interface GroupDMMixin extends Channel<ChannelType.GroupDM> {}
export class GroupDMMixin {
/**
* The icon hash of the group DM.
*/
public get icon() {
return this[kData].icon;
}
/**
* Whether the channel is managed by an application via the `gdm.join` OAuth2 scope.
*/
public get managed() {
return this[kData].managed;
}
/**
* The application id of the group DM creator if it is bot-created.
*/
public get applicationId() {
return this[kData].application_id;
}
}

View File

@@ -0,0 +1,40 @@
import { channelLink } from '@discordjs/formatters';
import type { GuildChannelType } from 'discord-api-types/v10';
import { ChannelFlagsBitField } from '../../bitfields/ChannelFlagsBitField.js';
import { kData } from '../../utils/symbols.js';
import type { Channel } from '../Channel.js';
export interface GuildChannelMixin<Type extends GuildChannelType = GuildChannelType> extends Channel<Type> {}
export class GuildChannelMixin<Type extends GuildChannelType = GuildChannelType> {
/**
* The flags that are applied to the channel.
*
* @privateRemarks The type of `flags` can be narrowed in Guild Channels and DMChannel to ChannelFlags, and in GroupDM channel
* to null, respecting Omit behaviors
*/
public get flags() {
return this[kData].flags ? new ChannelFlagsBitField(this[kData].flags) : null;
}
/**
* THe id of the guild this channel is in.
*/
public get guildId() {
return this[kData].guild_id!;
}
/**
* The URL to this channel.
*/
public get url() {
return channelLink(this.id, this.guildId);
}
/**
* Indicates whether this channel is in a guild
*/
public isGuildBased(): this is GuildChannelMixin & this {
return true;
}
}

View File

@@ -0,0 +1,21 @@
import type { TextChannelType } from 'discord-api-types/v10';
import { kData } from '../../utils/symbols.js';
import type { Channel } from '../Channel.js';
export interface TextChannelMixin<Type extends TextChannelType = TextChannelType> extends Channel<Type> {}
export class TextChannelMixin<Type extends TextChannelType = TextChannelType> {
/**
* The id of the last message sent in this channel.
*/
public get lastMessageId() {
return this[kData].last_message_id;
}
/**
* Indicates whether this channel can contain messages
*/
public isTextBased(): this is TextChannelMixin & this {
return true;
}
}

View File

@@ -0,0 +1,39 @@
import type { ThreadChannelType } from 'discord-api-types/v10';
import { kData } from '../../utils/symbols.js';
import type { Channel } from '../Channel.js';
export interface ThreadChannelMixin<Type extends ThreadChannelType = ThreadChannelType> extends Channel<Type> {}
/**
* @remarks has a sub-structure {@link ThreadMetadata} that extending mixins should add to their DataTemplate and _optimizeData
*/
export class ThreadChannelMixin<Type extends ThreadChannelType = ThreadChannelType> {
/**
* The approximate count of users in a thread, stops counting at 50
*/
public get memberCount() {
return this[kData].member_count;
}
/**
* The number of messages (not including the initial message or deleted messages) in a thread.
*/
public get messageCount() {
return this[kData].message_count;
}
/**
* The number of messages ever sent in a thread, it's similar to message_count on message creation,
* but will not decrement the number when a message is deleted.
*/
public get totalMessageSent() {
return this[kData].total_message_sent;
}
/**
* Indicates whether this channel is a thread channel
*/
public isThread(): this is ThreadChannelMixin & this {
return true;
}
}

View File

@@ -0,0 +1,37 @@
import type { ChannelType } from 'discord-api-types/v10';
import { kData } from '../../utils/symbols.js';
import type { Channel } from '../Channel.js';
export interface ThreadOnlyChannelMixin<
Type extends ChannelType.GuildForum | ChannelType.GuildMedia = ChannelType.GuildForum | ChannelType.GuildMedia,
> extends Channel<Type> {}
/**
* @remarks has an array of sub-structures {@link ForumTag} that extending mixins should add to their DataTemplate and _optimizeData
*/
export class ThreadOnlyChannelMixin<
Type extends ChannelType.GuildForum | ChannelType.GuildMedia = ChannelType.GuildForum | ChannelType.GuildMedia,
> {
/**
* The emoji to show in the add reaction button on a thread in this channel.
*/
public get defaultReactionEmoji() {
return this[kData].default_reaction_emoji;
}
/**
* The default sort order type used to order posts in this channel.
*
* @defaultValue `null` indicates a preferred sort order hasn't been set.
*/
public get defaultSortOrder() {
return this[kData].default_sort_order!;
}
/**
* Indicates whether this channel only allows thread creation
*/
public isThreadOnly(): this is ThreadOnlyChannelMixin & this {
return true;
}
}

View File

@@ -0,0 +1,51 @@
import type { ChannelType } from 'discord-api-types/v10';
import { kData } from '../../utils/symbols.js';
import type { Channel } from '../Channel.js';
import { TextChannelMixin } from './TextChannelMixin.js';
export interface VoiceChannelMixin<
Type extends ChannelType.GuildStageVoice | ChannelType.GuildVoice =
| ChannelType.GuildStageVoice
| ChannelType.GuildVoice,
> extends Channel<Type> {}
export class VoiceChannelMixin<
Type extends ChannelType.GuildStageVoice | ChannelType.GuildVoice =
| ChannelType.GuildStageVoice
| ChannelType.GuildVoice,
> extends TextChannelMixin<Type> {
/**
* The bitrate (in bits) of the voice channel.
*/
public get bitrate() {
return this[kData].bitrate!;
}
/**
* The voice region id for this channel, automatic when set to null.
*/
public get rtcRegion() {
return this[kData].rtc_region!;
}
/**
* The camera video quality mode of the voice channel, {@link discord-api-types/v10#(VideoQualityMode:enum) | Auto} when not present.
*/
public get videoQualityMode() {
return this[kData].video_quality_mode!;
}
/**
* The user limit of the voice channel.
*/
public get userLimit() {
return this[kData].user_limit!;
}
/**
* Indicates whether this channel has voice connection capabilities
*/
public override isVoiceBased(): this is VoiceChannelMixin & this {
return true;
}
}

View File

@@ -0,0 +1,15 @@
export * from './AppliedTagsMixin.js';
export * from './ChannelOwnerMixin.js';
export * from './ChannelParentMixin.js';
export * from './ChannelPermissionMixin.js';
export * from './ChannelPinMixin.js';
export * from './ChannelSlowmodeMixin.js';
export * from './ChannelTopicMixin.js';
export * from './ChannelWebhookMixin.js';
export * from './DMChannelMixin.js';
export * from './GroupDMMixin.js';
export * from './GuildChannelMixin.js';
export * from './TextChannelMixin.js';
export * from './ThreadChannelMixin.js';
export * from './ThreadOnlyChannelMixin.js';
export * from './VoiceChannelMixin.js';

View File

@@ -0,0 +1,9 @@
export * from './bitfields/index.js';
export * from './channels/index.js';
export * from './invites/index.js';
export * from './users/index.js';
export * from './Structure.js';
export * from './Mixin.js';
export * from './utils/optimization.js';
export type * from './utils/types.js';
export type * from './MixinTypes.d.ts';

View File

@@ -0,0 +1,220 @@
import { type APIInvite, type APIExtendedInvite, RouteBases } from 'discord-api-types/v10';
import { Structure } from '../Structure.js';
import { kData, kExpiresTimestamp, kCreatedTimestamp, kPatch } from '../utils/symbols.js';
import type { Partialize } from '../utils/types.js';
export interface APIActualInvite extends APIInvite, Partial<Omit<APIExtendedInvite, keyof APIInvite>> {}
/**
* Represents an invitation to a Discord channel
*
* @typeParam Omitted - Specify the properties that will not be stored in the raw data field as a union, implement via `DataTemplate`
*/
export class Invite<Omitted extends keyof APIActualInvite | '' = 'created_at' | 'expires_at'> extends Structure<
APIActualInvite,
Omitted
> {
/**
* The template used for removing data from the raw data stored for each Invite
*
* @remarks This template has defaults, if you want to remove additional data and keep the defaults,
* use `Object.defineProperties`. To override the defaults, set this value directly.
*/
public static override readonly DataTemplate: Partial<APIActualInvite> = {
set created_at(_: string) {},
set expires_at(_: string) {},
};
/**
* Optimized storage of {@link discord-api-types/v10#(APIActualInvite:interface).expires_at}
*
* @internal
*/
protected [kExpiresTimestamp]: number | null = null;
/**
* Optimized storage of {@link discord-api-types/v10#(APIActualInvite:interface).created_at}
*
* @internal
*/
protected [kCreatedTimestamp]: number | null = null;
/**
* @param data - The raw data received from the API for the invite
*/
public constructor(data: Partialize<APIActualInvite, Omitted>) {
super(data);
this.optimizeData(data);
}
/**
* {@inheritDoc Structure.[kPatch]}
*
* @internal
*/
public override [kPatch](data: Partial<APIActualInvite>) {
super[kPatch](data);
return this;
}
/**
* {@inheritDoc Structure.optimizeData}
*
* @internal
*/
protected override optimizeData(data: Partial<APIActualInvite>) {
if (data.expires_at) {
this[kExpiresTimestamp] = Date.parse(data.expires_at);
}
if (data.created_at) {
this[kCreatedTimestamp] = Date.parse(data.created_at);
}
}
/**
* The code for this invite
*/
public get code() {
return this[kData].code;
}
/**
* The target type (for voice channel invites)
*/
public get targetType() {
return this[kData].target_type;
}
/**
* The type of this invite
*/
public get type() {
return this[kData].type;
}
/**
* The approximate number of online members of the guild this invite is for
*
* @remarks Only available when the invite was fetched from `GET /invites/<code>` with counts
*/
public get approximatePresenceCount() {
return this[kData].approximate_presence_count;
}
/**
* The approximate total number of members of the guild this invite is for
*
* @remarks Only available when the invite was fetched from `GET /invites/<code>` with counts
*/
public get approximateMemberCount() {
return this[kData].approximate_member_count;
}
/**
* The timestamp this invite will expire at
*/
public get expiresTimestamp() {
if (this[kExpiresTimestamp]) {
return this[kExpiresTimestamp];
}
const createdTimestamp = this.createdTimestamp;
const maxAge = this.maxAge;
if (createdTimestamp && maxAge) {
this[kExpiresTimestamp] = createdTimestamp + (maxAge as number) * 1_000;
}
return this[kExpiresTimestamp];
}
/**
* The time the invite will expire at
*/
public get expiresAt() {
const expiresTimestamp = this.expiresTimestamp;
return expiresTimestamp ? new Date(expiresTimestamp) : null;
}
/**
* The number of times this invite has been used
*/
public get uses() {
return this[kData].uses;
}
/**
* The maximum number of times this invite can be used
*/
public get maxUses() {
return this[kData].max_uses;
}
/**
* The maximum age of the invite, in seconds, 0 for non-expiring
*/
public get maxAge() {
return this[kData].max_age;
}
/**
* Whether this invite only grants temporary membership
*/
public get temporary() {
return this[kData].temporary;
}
/**
* The timestamp this invite was created at
*/
public get createdTimestamp() {
return this[kCreatedTimestamp];
}
/**
* The time the invite was created at
*/
public get createdAt() {
const createdTimestamp = this.createdTimestamp;
return createdTimestamp ? new Date(createdTimestamp) : null;
}
/**
* The URL to the invite
*/
public get url() {
return this.code ? `${RouteBases.invite}/${this.code}` : null;
}
/**
* When concatenated with a string, this automatically concatenates the invite's URL instead of the object.
*
* @returns The URL to the invite or an empty string if it doesn't have a code
*/
public override toString() {
return this.url ?? '';
}
/**
* {@inheritDoc Structure.toJSON}
*/
public override toJSON() {
const clone = super.toJSON();
if (this[kExpiresTimestamp]) {
clone.expires_at = new Date(this[kExpiresTimestamp]).toISOString();
}
if (this[kCreatedTimestamp]) {
clone.created_at = new Date(this[kCreatedTimestamp]).toISOString();
}
return clone;
}
/**
* Returns the primitive value of the specified object.
*/
public override valueOf() {
return this.code ?? super.valueOf();
}
}

View File

@@ -0,0 +1 @@
export * from './Invite.js';

View File

@@ -0,0 +1,40 @@
import type { APIAvatarDecorationData } from 'discord-api-types/v10';
import { Structure } from '../Structure.js';
import { kData } from '../utils/symbols.js';
import type { Partialize } from '../utils/types.js';
/**
* Represents metadata of an avatar decoration of a User.
*
* @typeParam Omitted - Specify the properties that will not be stored in the raw data field as a union, implement via `DataTemplate`
*/
export class AvatarDecorationData<Omitted extends keyof APIAvatarDecorationData | '' = ''> extends Structure<
APIAvatarDecorationData,
Omitted
> {
/**
* The template used for removing data from the raw data stored for each Connection
*/
public static override readonly DataTemplate: Partial<APIAvatarDecorationData> = {};
/**
* @param data - The raw data received from the API for the connection
*/
public constructor(data: Partialize<APIAvatarDecorationData, Omitted>) {
super(data);
}
/**
* The id of the SKU this avatar decoration is part of.
*/
public get skuId() {
return this[kData].sku_id;
}
/**
* The asset of this avatar decoration.
*/
public get asset() {
return this[kData].asset;
}
}

View File

@@ -0,0 +1,95 @@
import type { APIConnection } from 'discord-api-types/v10';
import { Structure } from '../Structure.js';
import { kData, kPatch } from '../utils/symbols.js';
import type { Partialize } from '../utils/types.js';
/**
* Represents a user's connection 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 Connection<Omitted extends keyof APIConnection | '' = ''> extends Structure<APIConnection, Omitted> {
/**
* The template used for removing data from the raw data stored for each Connection
*/
public static override readonly DataTemplate: Partial<APIConnection> = {};
/**
* @param data - The raw data received from the API for the connection
*/
public constructor(data: Partialize<APIConnection, Omitted>) {
super(data);
}
/**
* {@inheritDoc Structure.[kPatch]}
*
* @internal
*/
public override [kPatch](data: Partial<APIConnection>) {
return super[kPatch](data);
}
/**
* The id of the connection account
*/
public get id() {
return this[kData].id;
}
/**
* The username of the connection account
*/
public get name() {
return this[kData].name;
}
/**
* The type of service this connection is for
*/
public get type() {
return this[kData].type;
}
/**
* Whether the connection is revoked
*/
public get revoked() {
return this[kData].revoked ?? false;
}
/**
* Whether the connection is verified
*/
public get verified() {
return this[kData].verified;
}
/**
* Whether friend sync is enabled for this connection
*/
public get friendSync() {
return this[kData].friend_sync;
}
/**
* Whether activities related to this connection are shown in the users presence
*/
public get showActivity() {
return this[kData].show_activity;
}
/**
* Whether this connection has an Oauth2 token for console voice transfer
*/
public get twoWayLink() {
return this[kData].two_way_link;
}
/**
* The visibility state for this connection
*/
public get visibility() {
return this[kData].visibility;
}
}

View File

@@ -0,0 +1,180 @@
import { DiscordSnowflake } from '@sapphire/snowflake';
import type { APIUser } from 'discord-api-types/v10';
import { Structure } from '../Structure.js';
import { kData, kPatch } from '../utils/symbols.js';
import { isIdSet } from '../utils/type-guards.js';
import type { Partialize } from '../utils/types.js';
/**
* Represents any user 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 a substructure `AvatarDecorationData`, which needs to be instantiated and stored by an extending class using it
*/
export class User<Omitted extends keyof APIUser | '' = ''> extends Structure<APIUser, Omitted> {
/**
* The template used for removing data from the raw data stored for each User
*/
public static override readonly DataTemplate: Partial<APIUser> = {};
/**
* @param data - The raw data received from the API for the user
*/
public constructor(data: Partialize<APIUser, Omitted>) {
super(data);
}
/**
* {@inheritDoc Structure.[kPatch]}
*
* @internal
*/
public override [kPatch](data: Partial<APIUser>) {
return super[kPatch](data);
}
/**
* The user's id
*/
public get id() {
return this[kData].id;
}
/**
* The username of the user
*/
public get username() {
return this[kData].username;
}
/**
* The user's 4 digit tag, if a bot
*/
public get discriminator() {
return this[kData].discriminator;
}
/**
* The user's display name, the application name for bots
*/
public get globalName() {
return this[kData].global_name;
}
/**
* The name displayed in the client for this user when no nickname is set
*/
public get displayName() {
return this.globalName ?? this.username;
}
/**
* The user avatar's hash
*/
public get avatar() {
return this[kData].avatar;
}
/**
* Whether the user is a bot
*/
public get bot() {
return this[kData].bot ?? false;
}
/**
* Whether the user is an Official Discord System user
*/
public get system() {
return this[kData].system ?? false;
}
/**
* Whether the user has mfa enabled
*
* @remarks This property is only set when the user was fetched with an OAuth2 token and the `identify` scope
*/
public get mfaEnabled() {
return this[kData].mfa_enabled;
}
/**
* The user's banner hash
*
* @remarks This property is only set when the user was manually fetched
*/
public get banner() {
return this[kData].banner;
}
/**
* The base 10 accent color of the user's banner
*
* @remarks This property is only set when the user was manually fetched
*/
public get accentColor() {
return this[kData].accent_color;
}
/**
* The user's primary Discord language
*
* @remarks This property is only set when the user was fetched with an Oauth2 token and the `identify` scope
*/
public get locale() {
return this[kData].locale;
}
/**
* Whether the email on the user's account has been verified
*
* @remarks This property is only set when the user was fetched with an OAuth2 token and the `email` scope
*/
public get verified() {
return this[kData].verified;
}
/**
* The user's email
*
* @remarks This property is only set when the user was fetched with an OAuth2 token and the `email` scope
*/
public get email() {
return this[kData].email;
}
/**
* The type of nitro subscription on the user's account
*
* @remarks This property is only set when the user was fetched with an OAuth2 token and the `identify` scope
*/
public get premiumType() {
return this[kData].premium_type;
}
/**
* The timestamp the user was created at
*/
public get createdTimestamp() {
return isIdSet(this.id) ? DiscordSnowflake.timestampFrom(this.id) : null;
}
/**
* The time the user was created at
*/
public get createdAt() {
const createdTimestamp = this.createdTimestamp;
return createdTimestamp ? new Date(createdTimestamp) : null;
}
/**
* The hexadecimal version of the user accent color, with a leading hash
*
* @remarks This property is only set when the user was manually fetched
*/
public get hexAccentColor() {
const accentColor = this.accentColor;
if (typeof accentColor !== 'number') return accentColor;
return `#${accentColor.toString(16).padStart(6, '0')}`;
}
}

View File

@@ -0,0 +1,3 @@
export * from './AvatarDecorationData.js';
export * from './User.js';
export * from './Connection.js';

View File

@@ -0,0 +1,10 @@
export function extendTemplate<SuperTemplate extends Record<string, unknown>>(
superTemplate: SuperTemplate,
additions: Record<string, unknown>,
): Record<string, unknown> & SuperTemplate {
return Object.defineProperties(additions, Object.getOwnPropertyDescriptors(superTemplate)) as Record<
string,
unknown
> &
SuperTemplate;
}

View File

@@ -0,0 +1,15 @@
export const kData = Symbol.for('djs.structures.data');
export const kClone = Symbol.for('djs.structures.clone');
export const kPatch = Symbol.for('djs.structures.patch');
export const kExpiresTimestamp = Symbol.for('djs.structures.expiresTimestamp');
export const kCreatedTimestamp = Symbol.for('djs.structures.createdTimestamp');
export const kEditedTimestamp = Symbol.for('djs.structures.editedTimestamp');
export const kArchiveTimestamp = Symbol.for('djs.structures.archiveTimestamp');
export const kAllow = Symbol.for('djs.structures.allow');
export const kDeny = Symbol.for('djs.structures.deny');
export const kLastPinTimestamp = Symbol.for('djs.structures.lastPinTimestamp');
export const kMixinConstruct = Symbol.for('djs.structures.mixin.construct');
export const kMixinToJSON = Symbol.for('djs.structures.mixin.toJSON');

View File

@@ -0,0 +1,3 @@
export function isIdSet(id: unknown): id is bigint | string {
return typeof id === 'string' || typeof id === 'bigint';
}

View File

@@ -0,0 +1,36 @@
export type ReplaceOmittedWithUnknown<Omitted extends keyof Data | '', Data> = {
[Key in keyof Data]: Key extends Omitted ? unknown : Data[Key];
};
export type CollapseUnion<Type> = Type extends infer Union ? { [Key in keyof Union]: Union[Key] } : never;
export type OptionalPropertyNames<Type> = {
[Key in keyof Type]-?: {} extends { [Prop in Key]: Type[Key] } ? Key : never;
}[keyof Type];
export type MergePrototype<Class1, Class2> = Pick<Class1, Exclude<keyof Class1, keyof Class2>> &
Pick<Class2, Exclude<keyof Class2, OptionalPropertyNames<Class2>>> &
Pick<Class2, Exclude<OptionalPropertyNames<Class2>, keyof Class1>> & {
[Prop in OptionalPropertyNames<Class2> & keyof Class1]: Class1[Prop] | Exclude<Class2[Prop], undefined>;
};
export type MergePrototypes<ClassArray extends readonly unknown[]> = ClassArray extends [infer Class1]
? Class1
: ClassArray extends [infer Class1, ...infer Rest]
? MergePrototype<Class1, MergePrototypes<Rest>>
: never;
export interface RecursiveReadonlyArray<ItemType> extends ReadonlyArray<ItemType | RecursiveReadonlyArray<ItemType>> {}
export type EnumLike<Enum, Value> = Record<keyof Enum, Value>;
export type If<Check, Value, True, False = never> = Check extends Value ? (Value extends Check ? True : False) : False;
export type NonAbstract<Type extends abstract new (...args: any) => any> = Type extends abstract new (
...args: infer Args
) => infer Instance
? Pick<Type, keyof Type> & (new (...args: Args) => Instance)
: never;
export type Partialize<Type, Omitted extends keyof Type | ''> = Omit<Type, Omitted> &
Partial<Pick<Type, Exclude<Omitted, ''>>>;

View File

@@ -0,0 +1,9 @@
{
"$schema": "https://json.schemastore.org/tsconfig.json",
"extends": "../../tsconfig.docs.json",
"compilerOptions": {
"outDir": "dist-docs"
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules"]
}

View File

@@ -0,0 +1,22 @@
{
"$schema": "https://json.schemastore.org/tsconfig.json",
"extends": "./tsconfig.json",
"compilerOptions": {
"allowJs": true
},
"include": [
"*.ts",
"*.js",
"*.cjs",
"*.mjs",
"src/**/*.ts",
"src/**/*.js",
"src/**/*.cjs",
"src/**/*.mjs",
"bin",
"scripts",
"__tests__",
"__mocks__"
],
"exclude": ["node_modules"]
}

View File

@@ -0,0 +1,9 @@
{
"$schema": "https://json.schemastore.org/tsconfig.json",
"extends": "../../tsconfig.json",
"include": ["src/**/*.ts", "src/**/*.js", "src/**/*.cjs", "src/**/*.mjs", "bin"],
"exclude": ["node_modules"],
"compilerOptions": {
"experimentalDecorators": false
}
}

View File

@@ -0,0 +1,10 @@
{
"$schema": "https://json.schemastore.org/tsconfig.json",
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": true,
"skipLibCheck": true
},
"include": ["__tests__/**/*.ts"],
"exclude": ["node_modules"]
}

View File

@@ -0,0 +1,6 @@
import { esbuildPluginVersionInjector } from 'esbuild-plugin-version-injector';
import { createTsupConfig } from '../../tsup.config.js';
export default createTsupConfig({
esbuildPlugins: [esbuildPluginVersionInjector()],
});