diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index f1d33f58a..598820ebc 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -27,6 +27,7 @@ pnpm-lock.yaml @discordjs/core /packages/proxy-container/ @discordjs/proxy /packages/rest/ @discordjs/rest /packages/scripts/ @discordjs/scripts +/packages/structures/ @discordjs/structures /packages/ui/ @discordjs/ui /packages/util/ @discordjs/util /packages/voice/ @discordjs/core diff --git a/.github/ISSUE_TEMPLATE/01-package_bug_report.yml b/.github/ISSUE_TEMPLATE/01-package_bug_report.yml index 9a70b864b..7427f983b 100644 --- a/.github/ISSUE_TEMPLATE/01-package_bug_report.yml +++ b/.github/ISSUE_TEMPLATE/01-package_bug_report.yml @@ -24,6 +24,7 @@ body: - proxy - proxy-container - rest + - structures - ui - util - voice diff --git a/.github/ISSUE_TEMPLATE/03-feature_request.yml b/.github/ISSUE_TEMPLATE/03-feature_request.yml index 5c2d330be..d6ffc4074 100644 --- a/.github/ISSUE_TEMPLATE/03-feature_request.yml +++ b/.github/ISSUE_TEMPLATE/03-feature_request.yml @@ -26,6 +26,7 @@ body: - proxy - proxy-container - rest + - structures - ui - util - voice diff --git a/.github/issue-labeler.yml b/.github/issue-labeler.yml index ff7e027a8..3e6a8c384 100644 --- a/.github/issue-labeler.yml +++ b/.github/issue-labeler.yml @@ -37,6 +37,9 @@ packages:proxy-container: packages:rest: - "### Which (application|package|application or package) is this (bug report|feature request) for\\?\\n\\nrest\\n" +packages:structures: + - "### Which (application|package|application or package) is this (bug + report|feature request) for\\?\\n\\nstructures\\n" packages:ui: - "### Which (application|package|application or package) is this (bug report|feature request) for\\?\\n\\ui\\n" diff --git a/.github/labeler.yml b/.github/labeler.yml index c60f0e768..d4214ce85 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -78,6 +78,11 @@ packages:rest: - any-glob-to-any-file: - packages/rest/* - packages/rest/**/* +packages:structures: + - changed-files: + - any-glob-to-any-file: + - packages/structures/* + - packages/structures/**/* packages:ui: - changed-files: - any-glob-to-any-file: diff --git a/.github/labels.yml b/.github/labels.yml index 07e5c8238..62ecd2f4e 100644 --- a/.github/labels.yml +++ b/.github/labels.yml @@ -80,6 +80,8 @@ color: fbca04 - name: packages:rest color: fbca04 +- name: packages:structures + color: fbca04 - name: packages:ui color: fbca04 - name: packages:util diff --git a/.github/workflows/deprecate-version.yml b/.github/workflows/deprecate-version.yml index 111a1d627..c2900400b 100644 --- a/.github/workflows/deprecate-version.yml +++ b/.github/workflows/deprecate-version.yml @@ -17,6 +17,7 @@ on: - '@discordjs/next' - '@discordjs/proxy' - '@discordjs/rest' + - '@discordjs/structures' - '@discordjs/util' - '@discordjs/voice' - '@discordjs/ws' diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml index 7dbfc58ec..714f82e12 100644 --- a/.github/workflows/documentation.yml +++ b/.github/workflows/documentation.yml @@ -86,7 +86,7 @@ jobs: - name: Build docs with main api-extractor if: ${{ inputs.ref && inputs.ref != 'main' }} run: | - declare -a PACKAGES=("brokers" "builders" "collection" "core" "discord.js" "formatters" "next" "proxy" "rest" "util" "voice" "ws") + declare -a PACKAGES=("brokers" "builders" "collection" "core" "discord.js" "formatters" "next" "proxy" "rest" "structures" "util" "voice" "ws") for PACKAGE in "${PACKAGES[@]}"; do cd "packages/${PACKAGE}" sed -i 's!https://github.com/discordjs/discord.js/tree/main!https://github.com/discordjs/discord.js/tree/${{ inputs.ref }}!' api-extractor.json @@ -219,7 +219,7 @@ jobs: - name: Move docs to correct directory if: ${{ env.REF_TYPE == 'branch' }} run: | - declare -a PACKAGES=("brokers" "builders" "collection" "core" "discord.js" "formatters" "next" "proxy" "rest" "util" "voice" "ws") + declare -a PACKAGES=("brokers" "builders" "collection" "core" "discord.js" "formatters" "next" "proxy" "rest" "structures" "util" "voice" "ws") for PACKAGE in "${PACKAGES[@]}"; do if [[ "${PACKAGE}" == "discord.js" ]]; then mkdir -p "out/${PACKAGE}" diff --git a/.github/workflows/publish-dev.yml b/.github/workflows/publish-dev.yml index 5dc2e4af9..6742d1412 100644 --- a/.github/workflows/publish-dev.yml +++ b/.github/workflows/publish-dev.yml @@ -28,6 +28,8 @@ jobs: folder: 'proxy' - package: '@discordjs/rest' folder: 'rest' + - package: '@discordjs/structures' + folder: 'structures' - package: '@discordjs/util' folder: 'util' - package: '@discordjs/voice' diff --git a/apps/website/package.json b/apps/website/package.json index 50ee4ecc0..dc1a9de6d 100644 --- a/apps/website/package.json +++ b/apps/website/package.json @@ -5,7 +5,7 @@ "description": "Imagine a bot... the most popular way to build discord bots", "private": true, "scripts": { - "build:copy_readme": "cpy \"../../packages/(discord.js|brokers|builders|collection|core|formatters|next|proxy|rest|util|voice|ws)/README.md\" \"src/assets/readme\" --rename='home-{{basename}}'", + "build:copy_readme": "cpy \"../../packages/(discord.js|brokers|builders|collection|core|formatters|next|proxy|rest|structures|util|voice|ws)/README.md\" \"src/assets/readme\" --rename='home-{{basename}}'", "build:check": "tsc --noEmit", "build:local": "cross-env NEXT_PUBLIC_LOCAL_DEV=true pnpm run build:prod", "build:prod": "pnpm run build:copy_readme && pnpm run build:next", diff --git a/apps/website/src/util/constants.ts b/apps/website/src/util/constants.ts index 46d17f043..36619c05e 100644 --- a/apps/website/src/util/constants.ts +++ b/apps/website/src/util/constants.ts @@ -9,6 +9,7 @@ export const PACKAGES = [ { name: 'next' }, { name: 'proxy' }, { name: 'rest' }, + { name: 'structures' }, { name: 'util' }, { name: 'voice' }, { name: 'ws' }, diff --git a/eslint.config.js b/eslint.config.js index 7dbe13b6d..461563d6d 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -244,6 +244,14 @@ export default tseslint.config( 'unicorn/prefer-node-protocol': 0, }, }, + { + files: [`packages/structures/**/*${commonFiles}`], + rules: { + '@typescript-eslint/no-empty-interface': 0, + '@typescript-eslint/no-empty-object-type': 0, + '@typescript-eslint/no-unsafe-declaration-merging': 0, + }, + }, { files: [`packages/voice/**/*${commonFiles}`], rules: { diff --git a/packages/actions/src/uploadCoverage/action.yml b/packages/actions/src/uploadCoverage/action.yml index 816e89efe..1434d8fc9 100644 --- a/packages/actions/src/uploadCoverage/action.yml +++ b/packages/actions/src/uploadCoverage/action.yml @@ -88,6 +88,15 @@ runs: flags: rest token: ${{ inputs.CODECOV_TOKEN }} + - name: Upload Structures Coverage + if: ${{ hashFiles('packages/structures/coverage/cobertura-coverage.xml') != '' }} + uses: codecov/codecov-action@v4 + with: + files: ./packages/structures/coverage/cobertura-coverage.xml + disable_search: true + flags: structures + token: ${{ inputs.CODECOV_TOKEN }} + - name: Upload Util Coverage if: ${{ hashFiles('packages/util/coverage/cobertura-coverage.xml') != '' }} uses: codecov/codecov-action@v4 diff --git a/packages/scripts/src/shared.ts b/packages/scripts/src/shared.ts index 32a8a001b..a8dff5021 100644 --- a/packages/scripts/src/shared.ts +++ b/packages/scripts/src/shared.ts @@ -10,6 +10,7 @@ export const PACKAGES = [ 'next', 'proxy', 'rest', + 'structures', 'util', 'voice', 'ws', diff --git a/packages/structures/.cliff-jumperrc.json b/packages/structures/.cliff-jumperrc.json new file mode 100644 index 000000000..62fb98f7c --- /dev/null +++ b/packages/structures/.cliff-jumperrc.json @@ -0,0 +1,6 @@ +{ + "name": "structures", + "org": "discordjs", + "packagePath": "packages/structures", + "identifierBase": false +} diff --git a/packages/structures/.gitignore b/packages/structures/.gitignore new file mode 100644 index 000000000..90500960f --- /dev/null +++ b/packages/structures/.gitignore @@ -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 diff --git a/packages/structures/.lintstagedrc.js b/packages/structures/.lintstagedrc.js new file mode 100644 index 000000000..c46f610c8 --- /dev/null +++ b/packages/structures/.lintstagedrc.js @@ -0,0 +1,2 @@ +/** @type {import('lint-staged').Config} */ +module.exports = require('../../.lintstagedrc.json'); diff --git a/packages/structures/.prettierignore b/packages/structures/.prettierignore new file mode 100644 index 000000000..9aef7953d --- /dev/null +++ b/packages/structures/.prettierignore @@ -0,0 +1,7 @@ +.turbo +coverage +dist +dist-docs +docs/docs.api.json +CHANGELOG.md +tsup.config.bundled* diff --git a/packages/structures/.prettierrc.js b/packages/structures/.prettierrc.js new file mode 100644 index 000000000..f723230a0 --- /dev/null +++ b/packages/structures/.prettierrc.js @@ -0,0 +1,2 @@ +/** @type {import('prettier').Config} */ +module.exports = require('../../.prettierrc.json'); diff --git a/packages/structures/LICENSE b/packages/structures/LICENSE new file mode 100644 index 000000000..ee2a64b25 --- /dev/null +++ b/packages/structures/LICENSE @@ -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. diff --git a/packages/structures/README.md b/packages/structures/README.md new file mode 100644 index 000000000..73af52f5a --- /dev/null +++ b/packages/structures/README.md @@ -0,0 +1,69 @@ +
+
+

+ discord.js +

+
+

+ Discord server + npm version + npm downloads + Tests status + Last commit. + Code coverage +

+

+ Vercel + Cloudflare Workers +

+
+ +## 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 diff --git a/packages/structures/__tests__/Mixin.test.ts b/packages/structures/__tests__/Mixin.test.ts new file mode 100644 index 000000000..2eaa082b9 --- /dev/null +++ b/packages/structures/__tests__/Mixin.test.ts @@ -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'); + }); +}); diff --git a/packages/structures/__tests__/Structure.test.ts b/packages/structures/__tests__/Structure.test.ts new file mode 100644 index 000000000..29a2390ef --- /dev/null +++ b/packages/structures/__tests__/Structure.test.ts @@ -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; + 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 = 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 = 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'); + }); +}); diff --git a/packages/structures/__tests__/channels.test.ts b/packages/structures/__tests__/channels.test.ts new file mode 100644 index 000000000..647f796ea --- /dev/null +++ b/packages/structures/__tests__/channels.test.ts @@ -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 = { + 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 = { + 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); + }); +}); diff --git a/packages/structures/__tests__/invite.test.ts b/packages/structures/__tests__/invite.test.ts new file mode 100644 index 000000000..256c3aa85 --- /dev/null +++ b/packages/structures/__tests__/invite.test.ts @@ -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 = { + 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); + }); +}); diff --git a/packages/structures/__tests__/mixinClasses.ts b/packages/structures/__tests__/mixinClasses.ts new file mode 100644 index 000000000..4ef6acb3e --- /dev/null +++ b/packages/structures/__tests__/mixinClasses.ts @@ -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 extends Structure { + 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) { + super[kPatch](data); + return this; + } + + public override optimizeData(data: Partial) { + 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 extends Base { + mixinOptimize: boolean | null; +} +export class MixinProperty1 { + public static readonly DataTemplate = { + set mixinOptimize(_: unknown) {}, + }; + + public [kMixinConstruct]() { + this.mixinOptimize = null; + } + + public optimizeData(data: Partial) { + 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) { + if (this.mixinOptimize) { + data.mixinOptimize = String(this.mixinOptimize); + } + } +} + +export interface MixinProperty2 extends Base { + 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 {} +export class Mixed extends Base { + public getProperties() { + return { property1: this.property1, property2: this.property2 }; + } +} + +Mixin(Mixed, [MixinProperty1, MixinProperty2]); + +export interface MixedWithExtended extends MixinTypes {} +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]); diff --git a/packages/structures/__tests__/types/Mixin.test-d.ts b/packages/structures/__tests__/types/Mixin.test-d.ts new file mode 100644 index 000000000..59ef4878f --- /dev/null +++ b/packages/structures/__tests__/types/Mixin.test-d.ts @@ -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; +declare const extendsOmitProperty1: Omit, keyof Base | typeof kMixinConstruct>; +declare const extendsBothNoOmit: Omit; +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>(extendsNoOmit); +expectType, [MixinProperty1<'property1'>]>>(extendsOmitProperty1); +expectNotType>(extendsOmitProperty1); +expectNotType, [MixinProperty1<'property1'>]>>(extendsNoOmit); + +expectType>(extendsBothNoOmit); +// Since MixinProperty2 doesn't utilize the type of property1 in kData, this works and is ok +expectType, [MixinProperty1<'property1'>, MixinProperty2]>>(extendsBothOmitProperty1); +expectNotType>(extendsBothOmitProperty1); +// Since MixinProperty2 doesn't utilize the type of property1 in kData, this works and is ok +expectNotType, [MixinProperty1<'property1'>, MixinProperty2]>>(extendsBothNoOmit); + +// Earlier mixins in the list must specify all properties because of the way merging works +expectType< + MixinTypes, [MixinProperty1<'property1' | 'property2'>, MixinProperty2<'property2'>]> +>(extendsBothOmitBoth); + +expectTypeOf, [MixinProperty1]>>().toBeNever(); +// @ts-expect-error Shouldn't be able to assign non identical omits +expectTypeOf]>>() + // Separate line so ts-expect-error doesn't match this ever + .toBeNever(); diff --git a/packages/structures/__tests__/types/channels.test-d.ts b/packages/structures/__tests__/types/channels.test-d.ts new file mode 100644 index 000000000..bbb662ab3 --- /dev/null +++ b/packages/structures/__tests__/types/channels.test-d.ts @@ -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(channel.guildId); + expectType(channel.type); + + if (channel.isDMBased()) { + expectNever(channel); + } + + if (channel.isPermissionCapable()) { + expectType>(channel.type); + } + + if (channel.isTextBased()) { + expectType(channel.type); + } + + if (channel.isWebhookCapable()) { + expectType>( + channel.type, + ); + } + + if (channel.isThread()) { + expectType(channel.type); + } + + if (channel.isThreadOnly()) { + expectType(channel.type); + } + + if (channel.isVoiceBased()) { + expectType(channel.type); + if (!channel.isTextBased()) { + expectNever(channel); + } + + if (!channel.isWebhookCapable()) { + expectNever(channel); + } + } +} + +if (channel.isDMBased()) { + expectType(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(channel.type); + } +} diff --git a/packages/structures/api-extractor.json b/packages/structures/api-extractor.json new file mode 100644 index 000000000..4feae888c --- /dev/null +++ b/packages/structures/api-extractor.json @@ -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"] + } + } +} diff --git a/packages/structures/cliff.toml b/packages/structures/cliff.toml new file mode 100644 index 000000000..211142607 --- /dev/null +++ b/packages/structures/cliff.toml @@ -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" diff --git a/packages/structures/docs/README.md b/packages/structures/docs/README.md new file mode 100644 index 000000000..6d683030d --- /dev/null +++ b/packages/structures/docs/README.md @@ -0,0 +1 @@ +## [View the documentation here.](https://discord.js.org/docs/packages/structures/main) diff --git a/packages/structures/package.json b/packages/structures/package.json new file mode 100644 index 000000000..3b69b2d00 --- /dev/null +++ b/packages/structures/package.json @@ -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 ", + "SpaceEEC ", + "Vlad Frangu ", + "Aura Román ", + "Chai Kohen " + ], + "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" + } +} diff --git a/packages/structures/src/Mixin.ts b/packages/structures/src/Mixin.ts new file mode 100644 index 000000000..8f637a5a9 --- /dev/null +++ b/packages/structures/src/Mixin.ts @@ -0,0 +1,179 @@ +import { DataTemplatePropertyName, OptimizeDataPropertyName, type Structure } from './Structure.js'; +import { kMixinConstruct, kMixinToJSON } from './utils/symbols.js'; + +export type Mixinable = new (...args: unknown[]) => ClassType; + +export type MixinBase> = + BaseClass extends Structure ? Structure : 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 {} + * 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>( + destination: DestinationClass, + mixins: Mixinable>[], +) { + const dataTemplates: Record[] = []; + const dataOptimizations: ((data: unknown) => void)[] = []; + const enrichToJSONs: ((data: Partial) => void)[] = []; + const constructors: ((data: Partial) => void)[] = []; + + for (const mixin of mixins) { + // The entire prototype chain, in reverse order, since we want to copy it all + const prototypeChain: MixinBase[] = []; + 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); + } + + 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) { + 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) { + 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)); + } + } +} diff --git a/packages/structures/src/MixinTypes.d.ts b/packages/structures/src/MixinTypes.d.ts new file mode 100644 index 000000000..94f38190c --- /dev/null +++ b/packages/structures/src/MixinTypes.d.ts @@ -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, Mixins extends readonly MixinBase[]> = CollapseUnion< + BaseClass extends Structure + ? Mixins[number] extends Structure + ? // prettier-ignore + Structure[typeof kData] extends + // @ts-expect-error kData is protected + Mixins[number][typeof kData] + ? Omit, keyof BaseClass | typeof kMixinConstruct> + : never + : never + : never +>; diff --git a/packages/structures/src/Structure.ts b/packages/structures/src/Structure.ts new file mode 100644 index 000000000..9e817f87d --- /dev/null +++ b/packages/structures/src/Structure.ts @@ -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 { + /** + * 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): 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): 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 = {}; + + /** + * @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>; + + /** + * 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>, ..._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>): 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>): 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) {} + + /** + * 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; + } +} diff --git a/packages/structures/src/bitfields/BitField.ts b/packages/structures/src/bitfields/BitField.ts new file mode 100644 index 000000000..e7e0138c4 --- /dev/null +++ b/packages/structures/src/bitfields/BitField.ts @@ -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 + | Readonly> + | RecursiveReadonlyArray> | bigint | number | `${bigint}`> + | bigint + | number + | `${bigint}`; + +/** + * Data structure that makes it easy to interact with a bit field. + */ +export abstract class BitField { + /** + * Numeric bit field flags. + * + * @remarks Defined in extension classes + */ + public static readonly Flags: EnumLike = {}; + + public static readonly DefaultBit: bigint = 0n; + + /** + * Bitfield of the packed bits + */ + public bitField: bigint; + + declare public ['constructor']: NonAbstract>; + + /** + * @param bits - Bit(s) to read from + */ + public constructor(bits: BitFieldResolvable = 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) { + 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) { + 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, ..._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, ...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[]) { + 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[]) { + 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> = {}; + 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(bit: BitFieldResolvable): 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)}`); + } +} diff --git a/packages/structures/src/bitfields/ChannelFlagsBitField.ts b/packages/structures/src/bitfields/ChannelFlagsBitField.ts new file mode 100644 index 000000000..309cf9615 --- /dev/null +++ b/packages/structures/src/bitfields/ChannelFlagsBitField.ts @@ -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 { + /** + * Numeric guild channel flags. + */ + public static override readonly Flags = ChannelFlags; + + public override toJSON() { + return super.toJSON(true); + } +} diff --git a/packages/structures/src/bitfields/PermissionsBitField.ts b/packages/structures/src/bitfields/PermissionsBitField.ts new file mode 100644 index 000000000..de38b4239 --- /dev/null +++ b/packages/structures/src/bitfields/PermissionsBitField.ts @@ -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 { + /** + * 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, 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, 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, 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); + } +} diff --git a/packages/structures/src/bitfields/index.ts b/packages/structures/src/bitfields/index.ts new file mode 100644 index 000000000..011821c8a --- /dev/null +++ b/packages/structures/src/bitfields/index.ts @@ -0,0 +1,4 @@ +export * from './BitField.js'; + +export * from './ChannelFlagsBitField.js'; +export * from './PermissionsBitField.js'; diff --git a/packages/structures/src/channels/AnnouncementChannel.ts b/packages/structures/src/channels/AnnouncementChannel.ts new file mode 100644 index 000000000..ccf125155 --- /dev/null +++ b/packages/structures/src/channels/AnnouncementChannel.ts @@ -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 + extends MixinTypes< + Channel, + [ + TextChannelMixin, + ChannelParentMixin, + ChannelPermissionMixin, + ChannelPinMixin, + ChannelSlowmodeMixin, + ChannelTopicMixin, + ] + > {} + +/** + * Sample Implementation of a structure for announcement channels, usable by direct end consumers. + */ +export class AnnouncementChannel extends Channel< + ChannelType.GuildAnnouncement, + Omitted +> { + public constructor(data: Partialize) { + super(data); + this.optimizeData(data); + } +} + +Mixin(AnnouncementChannel, [ + TextChannelMixin, + ChannelParentMixin, + ChannelPermissionMixin, + ChannelPinMixin, + ChannelSlowmodeMixin, + ChannelTopicMixin, +]); diff --git a/packages/structures/src/channels/AnnouncementThreadChannel.ts b/packages/structures/src/channels/AnnouncementThreadChannel.ts new file mode 100644 index 000000000..418516c9b --- /dev/null +++ b/packages/structures/src/channels/AnnouncementThreadChannel.ts @@ -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 + extends MixinTypes< + Channel, + [ + TextChannelMixin, + ChannelOwnerMixin, + ChannelParentMixin, + ChannelPinMixin, + ChannelSlowmodeMixin, + GuildChannelMixin, + ThreadChannelMixin, + ] + > {} + +/** + * Sample Implementation of a structure for announcement threads, usable by direct end consumers. + */ +export class AnnouncementThreadChannel extends Channel< + ChannelType.AnnouncementThread, + Omitted +> { + public constructor(data: Partialize) { + super(data); + this.optimizeData?.(data); + } +} + +Mixin(AnnouncementThreadChannel, [ + TextChannelMixin, + ChannelOwnerMixin, + ChannelParentMixin, + ChannelPinMixin, + ChannelSlowmodeMixin, + GuildChannelMixin, + ThreadChannelMixin, +]); diff --git a/packages/structures/src/channels/CategoryChannel.ts b/packages/structures/src/channels/CategoryChannel.ts new file mode 100644 index 000000000..56b119635 --- /dev/null +++ b/packages/structures/src/channels/CategoryChannel.ts @@ -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 + extends MixinTypes< + Channel, + [ChannelPermissionMixin, GuildChannelMixin] + > {} + +/** + * Sample Implementation of a structure for category channels, usable by direct end consumers. + */ +export class CategoryChannel extends Channel< + ChannelType.GuildCategory, + Omitted +> { + public constructor(data: Partialize) { + super(data); + this.optimizeData(data); + } +} + +Mixin(CategoryChannel, [ChannelPermissionMixin, GuildChannelMixin]); diff --git a/packages/structures/src/channels/Channel.ts b/packages/structures/src/channels/Channel.ts new file mode 100644 index 000000000..2ea00eacd --- /dev/null +++ b/packages/structures/src/channels/Channel.ts @@ -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>; + +/** + * The data stored by a {@link Channel} structure based on its {@link (Channel:class)."type"} property. + */ +export type ChannelDataType = Type extends ChannelType + ? Extract + : 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 | '' = '', +> extends Structure, 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 = {}; + + /** + * @param data - The raw data received from the API for the channel + */ + public constructor(data: Partialize, Omitted>) { + super(data as ChannelDataType); + } + + /** + * {@inheritDoc Structure.[kPatch]} + * + * @internal + */ + public override [kPatch](data: Partial>) { + 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; + } +} diff --git a/packages/structures/src/channels/DMChannel.ts b/packages/structures/src/channels/DMChannel.ts new file mode 100644 index 000000000..c679b2caa --- /dev/null +++ b/packages/structures/src/channels/DMChannel.ts @@ -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 + extends MixinTypes< + Channel, + [DMChannelMixin, TextChannelMixin, ChannelPinMixin] + > {} + +/** + * Sample Implementation of a structure for dm channels, usable by direct end consumers. + */ +export class DMChannel extends Channel { + public constructor(data: Partialize) { + super(data); + this.optimizeData(data); + } +} + +Mixin(DMChannel, [DMChannelMixin, TextChannelMixin, ChannelPinMixin]); diff --git a/packages/structures/src/channels/ForumChannel.ts b/packages/structures/src/channels/ForumChannel.ts new file mode 100644 index 000000000..6dce1db5a --- /dev/null +++ b/packages/structures/src/channels/ForumChannel.ts @@ -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 + extends MixinTypes< + Channel, + [ + ChannelParentMixin, + ChannelPermissionMixin, + ChannelTopicMixin, + ThreadOnlyChannelMixin, + ] + > {} + +/** + * Sample Implementation of a structure for forum channels, usable by direct end consumers. + */ +export class ForumChannel extends Channel< + ChannelType.GuildForum, + Omitted +> { + public constructor(data: Partialize) { + 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]); diff --git a/packages/structures/src/channels/ForumTag.ts b/packages/structures/src/channels/ForumTag.ts new file mode 100644 index 000000000..feee01f82 --- /dev/null +++ b/packages/structures/src/channels/ForumTag.ts @@ -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 extends Structure { + public constructor(data: Partialize) { + 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}>`; + } +} diff --git a/packages/structures/src/channels/GroupDMChannel.ts b/packages/structures/src/channels/GroupDMChannel.ts new file mode 100644 index 000000000..ecdb05bab --- /dev/null +++ b/packages/structures/src/channels/GroupDMChannel.ts @@ -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 + extends MixinTypes< + Channel, + [ + DMChannelMixin, + TextChannelMixin, + ChannelOwnerMixin, + GroupDMMixin, + ] + > {} + +/** + * Sample Implementation of a structure for group dm channels, usable by direct end consumers. + */ +export class GroupDMChannel extends Channel< + ChannelType.GroupDM, + Omitted +> { + public constructor(data: Partialize) { + super(data); + this.optimizeData(data); + } +} + +Mixin(GroupDMChannel, [DMChannelMixin, TextChannelMixin, ChannelOwnerMixin, GroupDMMixin]); diff --git a/packages/structures/src/channels/MediaChannel.ts b/packages/structures/src/channels/MediaChannel.ts new file mode 100644 index 000000000..96d4add59 --- /dev/null +++ b/packages/structures/src/channels/MediaChannel.ts @@ -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 + extends MixinTypes< + Channel, + [ + ChannelParentMixin, + ChannelPermissionMixin, + ChannelTopicMixin, + ThreadOnlyChannelMixin, + ] + > {} + +/** + * Sample Implementation of a structure for media channels, usable by direct end consumers. + */ +export class MediaChannel extends Channel< + ChannelType.GuildMedia, + Omitted +> { + public constructor(data: Partialize) { + super(data); + this.optimizeData(data); + } +} + +Mixin(MediaChannel, [ChannelParentMixin, ChannelPermissionMixin, ChannelTopicMixin, ThreadOnlyChannelMixin]); diff --git a/packages/structures/src/channels/PermissionOverwrite.ts b/packages/structures/src/channels/PermissionOverwrite.ts new file mode 100644 index 000000000..3d2aee147 --- /dev/null +++ b/packages/structures/src/channels/PermissionOverwrite.ts @@ -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 extends Structure< + APIOverwrite, + Omitted +> { + protected [kAllow]: bigint | null = null; + + protected [kDeny]: bigint | null = null; + + public constructor(data: Partialize) { + 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 = { + set allow(_: string) {}, + set deny(_: string) {}, + }; + + /** + * {@inheritDoc Structure.optimizeData} + */ + protected override optimizeData(data: Partial) { + 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; + } +} diff --git a/packages/structures/src/channels/PrivateThreadChannel.ts b/packages/structures/src/channels/PrivateThreadChannel.ts new file mode 100644 index 000000000..577c0b805 --- /dev/null +++ b/packages/structures/src/channels/PrivateThreadChannel.ts @@ -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 + extends MixinTypes< + Channel, + [ + TextChannelMixin, + ChannelOwnerMixin, + ChannelParentMixin, + ChannelPinMixin, + ChannelSlowmodeMixin, + ThreadChannelMixin, + ] + > {} + +/** + * Sample Implementation of a structure for private thread channels, usable by direct end consumers. + */ +export class PrivateThreadChannel extends Channel< + ChannelType.PrivateThread, + Omitted +> { + public constructor(data: Partialize) { + super(data); + this.optimizeData(data); + } +} + +Mixin(PrivateThreadChannel, [ + TextChannelMixin, + ChannelOwnerMixin, + ChannelParentMixin, + ChannelPinMixin, + ChannelSlowmodeMixin, + ThreadChannelMixin, +]); diff --git a/packages/structures/src/channels/PublicThreadChannel.ts b/packages/structures/src/channels/PublicThreadChannel.ts new file mode 100644 index 000000000..f2600eb55 --- /dev/null +++ b/packages/structures/src/channels/PublicThreadChannel.ts @@ -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 + extends MixinTypes< + Channel, + [ + TextChannelMixin, + ChannelOwnerMixin, + ChannelParentMixin, + ChannelPinMixin, + ChannelSlowmodeMixin, + ThreadChannelMixin, + AppliedTagsMixin, + ] + > {} + +/** + * Sample Implementation of a structure for public thread channels, usable by direct end consumers. + */ +export class PublicThreadChannel extends Channel< + ChannelType.PublicThread, + Omitted +> { + public constructor(data: Partialize) { + super(data); + this.optimizeData(data); + } +} + +Mixin(PublicThreadChannel, [ + TextChannelMixin, + ChannelOwnerMixin, + ChannelParentMixin, + ChannelPinMixin, + ChannelSlowmodeMixin, + ThreadChannelMixin, + AppliedTagsMixin, +]); diff --git a/packages/structures/src/channels/StageChannel.ts b/packages/structures/src/channels/StageChannel.ts new file mode 100644 index 000000000..a49fbcbc3 --- /dev/null +++ b/packages/structures/src/channels/StageChannel.ts @@ -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 + extends MixinTypes< + Channel, + [ + ChannelParentMixin, + ChannelPermissionMixin, + ChannelSlowmodeMixin, + ChannelWebhookMixin, + VoiceChannelMixin, + ] + > {} + +export class StageChannel extends Channel< + ChannelType.GuildStageVoice, + Omitted +> { + public constructor(data: Partialize) { + super(data); + this.optimizeData(data); + } +} + +Mixin(StageChannel, [ + ChannelParentMixin, + ChannelPermissionMixin, + ChannelSlowmodeMixin, + ChannelWebhookMixin, + VoiceChannelMixin, +]); diff --git a/packages/structures/src/channels/TextChannel.ts b/packages/structures/src/channels/TextChannel.ts new file mode 100644 index 000000000..75d0b7c39 --- /dev/null +++ b/packages/structures/src/channels/TextChannel.ts @@ -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 + extends MixinTypes< + Channel, + [ + TextChannelMixin, + ChannelParentMixin, + ChannelPermissionMixin, + ChannelPinMixin, + ChannelSlowmodeMixin, + ChannelTopicMixin, + ] + > {} + +export class TextChannel extends Channel< + ChannelType.GuildText, + Omitted +> { + public constructor(data: Partialize) { + super(data); + this.optimizeData(data); + } +} + +Mixin(TextChannel, [ + TextChannelMixin, + ChannelParentMixin, + ChannelPermissionMixin, + ChannelPinMixin, + ChannelSlowmodeMixin, + ChannelTopicMixin, +]); diff --git a/packages/structures/src/channels/ThreadMetadata.ts b/packages/structures/src/channels/ThreadMetadata.ts new file mode 100644 index 000000000..433c90122 --- /dev/null +++ b/packages/structures/src/channels/ThreadMetadata.ts @@ -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 { + protected [kArchiveTimestamp]: number | null = null; + + protected [kCreatedTimestamp]: number | null = null; + + public constructor(data: Partialize) { + 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 = { + set create_timestamp(_: string) {}, + set archive_timestamp(_: string) {}, + }; + + /** + * {@inheritDoc Structure.optimizeData} + */ + protected override optimizeData(data: Partial) { + 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; + } +} diff --git a/packages/structures/src/channels/VoiceChannel.ts b/packages/structures/src/channels/VoiceChannel.ts new file mode 100644 index 000000000..a14de1320 --- /dev/null +++ b/packages/structures/src/channels/VoiceChannel.ts @@ -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 + extends MixinTypes< + Channel, + [ + ChannelParentMixin, + ChannelPermissionMixin, + ChannelSlowmodeMixin, + ChannelWebhookMixin, + VoiceChannelMixin, + ] + > {} + +export class VoiceChannel extends Channel< + ChannelType.GuildVoice, + Omitted +> { + public constructor(data: Partialize) { + super(data); + this.optimizeData(data); + } +} + +Mixin(VoiceChannel, [ + ChannelParentMixin, + ChannelPermissionMixin, + ChannelSlowmodeMixin, + ChannelWebhookMixin, + VoiceChannelMixin, +]); diff --git a/packages/structures/src/channels/index.ts b/packages/structures/src/channels/index.ts new file mode 100644 index 000000000..b4f8d9bbc --- /dev/null +++ b/packages/structures/src/channels/index.ts @@ -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'; diff --git a/packages/structures/src/channels/mixins/AppliedTagsMixin.ts b/packages/structures/src/channels/mixins/AppliedTagsMixin.ts new file mode 100644 index 000000000..14fcf91f0 --- /dev/null +++ b/packages/structures/src/channels/mixins/AppliedTagsMixin.ts @@ -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 {} + +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; + } +} diff --git a/packages/structures/src/channels/mixins/ChannelOwnerMixin.ts b/packages/structures/src/channels/mixins/ChannelOwnerMixin.ts new file mode 100644 index 000000000..bc3c07032 --- /dev/null +++ b/packages/structures/src/channels/mixins/ChannelOwnerMixin.ts @@ -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 extends Channel {} + +export class ChannelOwnerMixin { + /** + * The id of the creator of the group DM or thread + */ + public get ownerId() { + return this[kData].owner_id; + } +} diff --git a/packages/structures/src/channels/mixins/ChannelParentMixin.ts b/packages/structures/src/channels/mixins/ChannelParentMixin.ts new file mode 100644 index 000000000..aa1a98456 --- /dev/null +++ b/packages/structures/src/channels/mixins/ChannelParentMixin.ts @@ -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, +> extends GuildChannelMixin { + /** + * 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; + } +} diff --git a/packages/structures/src/channels/mixins/ChannelPermissionMixin.ts b/packages/structures/src/channels/mixins/ChannelPermissionMixin.ts new file mode 100644 index 000000000..ada4415d4 --- /dev/null +++ b/packages/structures/src/channels/mixins/ChannelPermissionMixin.ts @@ -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 = Exclude< + GuildChannelType, + ChannelType.GuildDirectory | ThreadChannelType + >, +> extends Channel {} + +/** + * @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 = 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; + } +} diff --git a/packages/structures/src/channels/mixins/ChannelPinMixin.ts b/packages/structures/src/channels/mixins/ChannelPinMixin.ts new file mode 100644 index 000000000..d82051c14 --- /dev/null +++ b/packages/structures/src/channels/mixins/ChannelPinMixin.ts @@ -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 {} + +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 + > = { + set last_pin_timestamp(_: string) {}, + }; + + public [kMixinConstruct]() { + this[kLastPinTimestamp] ??= null; + } + + /** + * {@inheritDoc Structure.optimizeData} + */ + protected optimizeData(data: Partial>) { + 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>) { + data.last_pin_timestamp = this[kLastPinTimestamp] ? new Date(this[kLastPinTimestamp]).toISOString() : null; + } +} diff --git a/packages/structures/src/channels/mixins/ChannelSlowmodeMixin.ts b/packages/structures/src/channels/mixins/ChannelSlowmodeMixin.ts new file mode 100644 index 000000000..688a38039 --- /dev/null +++ b/packages/structures/src/channels/mixins/ChannelSlowmodeMixin.ts @@ -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 extends TextChannelMixin { + /** + * The rate limit per user (slowmode) of this channel. + */ + public get rateLimitPerUser() { + return this[kData].rate_limit_per_user; + } +} diff --git a/packages/structures/src/channels/mixins/ChannelTopicMixin.ts b/packages/structures/src/channels/mixins/ChannelTopicMixin.ts new file mode 100644 index 000000000..b28072fbe --- /dev/null +++ b/packages/structures/src/channels/mixins/ChannelTopicMixin.ts @@ -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 {} + +export class ChannelTopicMixin< + Type extends ChannelType.GuildAnnouncement | ChannelType.GuildForum | ChannelType.GuildMedia | ChannelType.GuildText, +> extends ChannelWebhookMixin { + /** + * 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; + } +} diff --git a/packages/structures/src/channels/mixins/ChannelWebhookMixin.ts b/packages/structures/src/channels/mixins/ChannelWebhookMixin.ts new file mode 100644 index 000000000..31f191329 --- /dev/null +++ b/packages/structures/src/channels/mixins/ChannelWebhookMixin.ts @@ -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 = + | ChannelType.GuildForum + | ChannelType.GuildMedia + | Exclude, +> extends Channel {} + +export class ChannelWebhookMixin< + Type extends ChannelType.GuildForum | ChannelType.GuildMedia | Exclude = + | ChannelType.GuildForum + | ChannelType.GuildMedia + | Exclude, +> { + /** + * Indicates whether this channel can have webhooks + */ + public isWebhookCapable(): this is ChannelWebhookMixin & this { + return true; + } +} diff --git a/packages/structures/src/channels/mixins/DMChannelMixin.ts b/packages/structures/src/channels/mixins/DMChannelMixin.ts new file mode 100644 index 000000000..3f88f6e69 --- /dev/null +++ b/packages/structures/src/channels/mixins/DMChannelMixin.ts @@ -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 {} + +/** + * @remarks has recipients, an array of sub-structures {@link User} that extending mixins should add to their DataTemplate and _optimizeData + */ +export class DMChannelMixin { + /** + * 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; + } +} diff --git a/packages/structures/src/channels/mixins/GroupDMMixin.ts b/packages/structures/src/channels/mixins/GroupDMMixin.ts new file mode 100644 index 000000000..ea98c95f3 --- /dev/null +++ b/packages/structures/src/channels/mixins/GroupDMMixin.ts @@ -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 {} + +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; + } +} diff --git a/packages/structures/src/channels/mixins/GuildChannelMixin.ts b/packages/structures/src/channels/mixins/GuildChannelMixin.ts new file mode 100644 index 000000000..fd42a1958 --- /dev/null +++ b/packages/structures/src/channels/mixins/GuildChannelMixin.ts @@ -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 extends Channel {} + +export class GuildChannelMixin { + /** + * 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; + } +} diff --git a/packages/structures/src/channels/mixins/TextChannelMixin.ts b/packages/structures/src/channels/mixins/TextChannelMixin.ts new file mode 100644 index 000000000..c9552171e --- /dev/null +++ b/packages/structures/src/channels/mixins/TextChannelMixin.ts @@ -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 extends Channel {} + +export class TextChannelMixin { + /** + * 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; + } +} diff --git a/packages/structures/src/channels/mixins/ThreadChannelMixin.ts b/packages/structures/src/channels/mixins/ThreadChannelMixin.ts new file mode 100644 index 000000000..ce8f17909 --- /dev/null +++ b/packages/structures/src/channels/mixins/ThreadChannelMixin.ts @@ -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 extends Channel {} + +/** + * @remarks has a sub-structure {@link ThreadMetadata} that extending mixins should add to their DataTemplate and _optimizeData + */ +export class ThreadChannelMixin { + /** + * 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; + } +} diff --git a/packages/structures/src/channels/mixins/ThreadOnlyChannelMixin.ts b/packages/structures/src/channels/mixins/ThreadOnlyChannelMixin.ts new file mode 100644 index 000000000..bbb3f9e78 --- /dev/null +++ b/packages/structures/src/channels/mixins/ThreadOnlyChannelMixin.ts @@ -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 {} + +/** + * @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; + } +} diff --git a/packages/structures/src/channels/mixins/VoiceChannelMixin.ts b/packages/structures/src/channels/mixins/VoiceChannelMixin.ts new file mode 100644 index 000000000..1b15c6874 --- /dev/null +++ b/packages/structures/src/channels/mixins/VoiceChannelMixin.ts @@ -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 {} + +export class VoiceChannelMixin< + Type extends ChannelType.GuildStageVoice | ChannelType.GuildVoice = + | ChannelType.GuildStageVoice + | ChannelType.GuildVoice, +> extends TextChannelMixin { + /** + * 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; + } +} diff --git a/packages/structures/src/channels/mixins/index.ts b/packages/structures/src/channels/mixins/index.ts new file mode 100644 index 000000000..0ab073b54 --- /dev/null +++ b/packages/structures/src/channels/mixins/index.ts @@ -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'; diff --git a/packages/structures/src/index.ts b/packages/structures/src/index.ts new file mode 100644 index 000000000..5a2e6b407 --- /dev/null +++ b/packages/structures/src/index.ts @@ -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'; diff --git a/packages/structures/src/invites/Invite.ts b/packages/structures/src/invites/Invite.ts new file mode 100644 index 000000000..93e8c5cc5 --- /dev/null +++ b/packages/structures/src/invites/Invite.ts @@ -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> {} + +/** + * 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 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 = { + 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) { + super(data); + this.optimizeData(data); + } + + /** + * {@inheritDoc Structure.[kPatch]} + * + * @internal + */ + public override [kPatch](data: Partial) { + super[kPatch](data); + return this; + } + + /** + * {@inheritDoc Structure.optimizeData} + * + * @internal + */ + protected override optimizeData(data: Partial) { + 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/` 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/` 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(); + } +} diff --git a/packages/structures/src/invites/index.ts b/packages/structures/src/invites/index.ts new file mode 100644 index 000000000..e0a172f76 --- /dev/null +++ b/packages/structures/src/invites/index.ts @@ -0,0 +1 @@ +export * from './Invite.js'; diff --git a/packages/structures/src/users/AvatarDecorationData.ts b/packages/structures/src/users/AvatarDecorationData.ts new file mode 100644 index 000000000..f006d47b3 --- /dev/null +++ b/packages/structures/src/users/AvatarDecorationData.ts @@ -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 extends Structure< + APIAvatarDecorationData, + Omitted +> { + /** + * The template used for removing data from the raw data stored for each Connection + */ + public static override readonly DataTemplate: Partial = {}; + + /** + * @param data - The raw data received from the API for the connection + */ + public constructor(data: Partialize) { + super(data); + } + + /** + * The id of the 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; + } +} diff --git a/packages/structures/src/users/Connection.ts b/packages/structures/src/users/Connection.ts new file mode 100644 index 000000000..afe2ad42e --- /dev/null +++ b/packages/structures/src/users/Connection.ts @@ -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 extends Structure { + /** + * The template used for removing data from the raw data stored for each Connection + */ + public static override readonly DataTemplate: Partial = {}; + + /** + * @param data - The raw data received from the API for the connection + */ + public constructor(data: Partialize) { + super(data); + } + + /** + * {@inheritDoc Structure.[kPatch]} + * + * @internal + */ + public override [kPatch](data: Partial) { + 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; + } +} diff --git a/packages/structures/src/users/User.ts b/packages/structures/src/users/User.ts new file mode 100644 index 000000000..49aa22faa --- /dev/null +++ b/packages/structures/src/users/User.ts @@ -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 extends Structure { + /** + * The template used for removing data from the raw data stored for each User + */ + public static override readonly DataTemplate: Partial = {}; + + /** + * @param data - The raw data received from the API for the user + */ + public constructor(data: Partialize) { + super(data); + } + + /** + * {@inheritDoc Structure.[kPatch]} + * + * @internal + */ + public override [kPatch](data: Partial) { + 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')}`; + } +} diff --git a/packages/structures/src/users/index.ts b/packages/structures/src/users/index.ts new file mode 100644 index 000000000..0093c11aa --- /dev/null +++ b/packages/structures/src/users/index.ts @@ -0,0 +1,3 @@ +export * from './AvatarDecorationData.js'; +export * from './User.js'; +export * from './Connection.js'; diff --git a/packages/structures/src/utils/optimization.ts b/packages/structures/src/utils/optimization.ts new file mode 100644 index 000000000..c92e58574 --- /dev/null +++ b/packages/structures/src/utils/optimization.ts @@ -0,0 +1,10 @@ +export function extendTemplate>( + superTemplate: SuperTemplate, + additions: Record, +): Record & SuperTemplate { + return Object.defineProperties(additions, Object.getOwnPropertyDescriptors(superTemplate)) as Record< + string, + unknown + > & + SuperTemplate; +} diff --git a/packages/structures/src/utils/symbols.ts b/packages/structures/src/utils/symbols.ts new file mode 100644 index 000000000..8fc66805a --- /dev/null +++ b/packages/structures/src/utils/symbols.ts @@ -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'); diff --git a/packages/structures/src/utils/type-guards.ts b/packages/structures/src/utils/type-guards.ts new file mode 100644 index 000000000..789258380 --- /dev/null +++ b/packages/structures/src/utils/type-guards.ts @@ -0,0 +1,3 @@ +export function isIdSet(id: unknown): id is bigint | string { + return typeof id === 'string' || typeof id === 'bigint'; +} diff --git a/packages/structures/src/utils/types.ts b/packages/structures/src/utils/types.ts new file mode 100644 index 000000000..ca30a14e8 --- /dev/null +++ b/packages/structures/src/utils/types.ts @@ -0,0 +1,36 @@ +export type ReplaceOmittedWithUnknown = { + [Key in keyof Data]: Key extends Omitted ? unknown : Data[Key]; +}; + +export type CollapseUnion = Type extends infer Union ? { [Key in keyof Union]: Union[Key] } : never; + +export type OptionalPropertyNames = { + [Key in keyof Type]-?: {} extends { [Prop in Key]: Type[Key] } ? Key : never; +}[keyof Type]; + +export type MergePrototype = Pick> & + Pick>> & + Pick, keyof Class1>> & { + [Prop in OptionalPropertyNames & keyof Class1]: Class1[Prop] | Exclude; + }; + +export type MergePrototypes = ClassArray extends [infer Class1] + ? Class1 + : ClassArray extends [infer Class1, ...infer Rest] + ? MergePrototype> + : never; + +export interface RecursiveReadonlyArray extends ReadonlyArray> {} + +export type EnumLike = Record; + +export type If = Check extends Value ? (Value extends Check ? True : False) : False; + +export type NonAbstract any> = Type extends abstract new ( + ...args: infer Args +) => infer Instance + ? Pick & (new (...args: Args) => Instance) + : never; + +export type Partialize = Omit & + Partial>>; diff --git a/packages/structures/tsconfig.docs.json b/packages/structures/tsconfig.docs.json new file mode 100644 index 000000000..36eb35148 --- /dev/null +++ b/packages/structures/tsconfig.docs.json @@ -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"] +} diff --git a/packages/structures/tsconfig.eslint.json b/packages/structures/tsconfig.eslint.json new file mode 100644 index 000000000..163783a48 --- /dev/null +++ b/packages/structures/tsconfig.eslint.json @@ -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"] +} diff --git a/packages/structures/tsconfig.json b/packages/structures/tsconfig.json new file mode 100644 index 000000000..5fab20844 --- /dev/null +++ b/packages/structures/tsconfig.json @@ -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 + } +} diff --git a/packages/structures/tsconfig.test.json b/packages/structures/tsconfig.test.json new file mode 100644 index 000000000..11e8d6d40 --- /dev/null +++ b/packages/structures/tsconfig.test.json @@ -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"] +} diff --git a/packages/structures/tsup.config.ts b/packages/structures/tsup.config.ts new file mode 100644 index 000000000..afd45736d --- /dev/null +++ b/packages/structures/tsup.config.ts @@ -0,0 +1,6 @@ +import { esbuildPluginVersionInjector } from 'esbuild-plugin-version-injector'; +import { createTsupConfig } from '../../tsup.config.js'; + +export default createTsupConfig({ + esbuildPlugins: [esbuildPluginVersionInjector()], +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1f07e5637..a9eea1ab3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1544,6 +1544,73 @@ importers: specifier: ~5.8.3 version: 5.8.3 + packages/structures: + dependencies: + '@discordjs/formatters': + specifier: workspace:^ + version: link:../formatters + '@sapphire/snowflake': + specifier: ^3.5.5 + version: 3.5.5 + discord-api-types: + specifier: ^0.38.15 + version: 0.38.15 + devDependencies: + '@discordjs/api-extractor': + specifier: workspace:^ + version: link:../api-extractor + '@discordjs/scripts': + specifier: workspace:^ + version: link:../scripts + '@favware/cliff-jumper': + specifier: ^4.1.0 + version: 4.1.0 + '@types/node': + specifier: ^22.15.2 + version: 22.15.26 + '@vitest/coverage-v8': + specifier: ^3.1.1 + version: 3.1.4(vitest@3.1.4(@edge-runtime/vm@3.2.0)(@types/debug@4.1.12)(@types/node@22.15.26)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.39.0)(tsx@4.19.2)(yaml@2.8.0)) + cpy-cli: + specifier: ^5.0.0 + version: 5.0.0 + cross-env: + specifier: ^7.0.3 + version: 7.0.3 + esbuild-plugin-version-injector: + specifier: ^1.2.1 + version: 1.2.1 + eslint: + specifier: ^9.25.1 + version: 9.27.0(jiti@2.4.2) + eslint-config-neon: + specifier: ^0.2.7 + version: 0.2.7(@typescript-eslint/types@8.33.0)(@typescript-eslint/utils@8.33.0(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3))(eslint-plugin-import@2.31.0)(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3) + eslint-formatter-compact: + specifier: ^8.40.0 + version: 8.40.0 + eslint-formatter-pretty: + specifier: ^6.0.1 + version: 6.0.1 + prettier: + specifier: ^3.5.3 + version: 3.5.3 + tsd: + specifier: ^0.31.2 + version: 0.31.2 + tsup: + specifier: ^8.4.0 + version: 8.5.0(@microsoft/api-extractor@7.52.3(@types/node@22.15.26))(jiti@2.4.2)(postcss@8.5.4)(tsx@4.19.2)(typescript@5.8.3)(yaml@2.8.0) + turbo: + specifier: ^2.5.2 + version: 2.5.3 + typescript: + specifier: ~5.8.3 + version: 5.8.3 + vitest: + specifier: ^3.1.1 + version: 3.1.4(@edge-runtime/vm@3.2.0)(@types/debug@4.1.12)(@types/node@22.15.26)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.39.0)(tsx@4.19.2)(yaml@2.8.0) + packages/ui: dependencies: '@react-icons/all-files': @@ -6464,9 +6531,6 @@ packages: '@types/estree-jsx@1.0.5': resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} - '@types/estree@1.0.6': - resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==} - '@types/estree@1.0.7': resolution: {integrity: sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==} @@ -16759,7 +16823,7 @@ snapshots: proc-log: 4.2.0 promise-inflight: 1.0.1 promise-retry: 2.0.1 - semver: 7.7.2 + semver: 7.6.3 which: 4.0.0 transitivePeerDependencies: - bluebird @@ -16781,7 +16845,7 @@ snapshots: json-parse-even-better-errors: 3.0.2 normalize-package-data: 6.0.2 proc-log: 4.2.0 - semver: 7.7.2 + semver: 7.6.3 transitivePeerDependencies: - bluebird @@ -20156,8 +20220,6 @@ snapshots: dependencies: '@types/estree': 1.0.7 - '@types/estree@1.0.6': {} - '@types/estree@1.0.7': {} '@types/express-serve-static-core@4.19.6': @@ -20737,7 +20799,7 @@ snapshots: '@typescript-eslint/utils@7.18.0(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3)': dependencies: - '@eslint-community/eslint-utils': 4.4.1(eslint@9.27.0(jiti@2.4.2)) + '@eslint-community/eslint-utils': 4.7.0(eslint@9.27.0(jiti@2.4.2)) '@typescript-eslint/scope-manager': 7.18.0 '@typescript-eslint/types': 7.18.0 '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.8.3) @@ -24333,7 +24395,7 @@ snapshots: estree-walker@3.0.3: dependencies: - '@types/estree': 1.0.6 + '@types/estree': 1.0.7 esutils@2.0.3: {} @@ -27248,7 +27310,7 @@ snapshots: npm-install-checks@6.3.0: dependencies: - semver: 7.7.2 + semver: 7.6.3 npm-normalize-package-bin@3.0.1: {} @@ -27256,7 +27318,7 @@ snapshots: dependencies: hosted-git-info: 7.0.2 proc-log: 4.2.0 - semver: 7.7.2 + semver: 7.6.3 validate-npm-package-name: 5.0.1 npm-package-arg@12.0.1: @@ -27271,7 +27333,7 @@ snapshots: npm-install-checks: 6.3.0 npm-normalize-package-bin: 3.0.1 npm-package-arg: 11.0.3 - semver: 7.7.2 + semver: 7.6.3 npm-registry-fetch@18.0.2: dependencies: