├── .cliff-jumperrc.yml ├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .gitattributes ├── .github ├── CODEOWNERS ├── CODE_OF_CONDUCT.md ├── hooks │ ├── commit-msg │ └── pre-commit ├── renovate.json └── workflows │ ├── auto-deprecate.yml │ ├── codeql-analysis.yml │ ├── continuous-delivery.yml │ ├── continuous-integration.yml │ ├── deprecate-on-merge.yml │ ├── documentation.yml │ ├── labelsync.yml │ └── publish.yml ├── .gitignore ├── .npm-deprecaterc.yml ├── .prettierignore ├── .rollup-type-bundlerrc.yml ├── .typedoc-json-parserrc.yml ├── .vscode ├── extensions.json └── settings.json ├── .yarn ├── plugins │ └── @yarnpkg │ │ └── plugin-git-hooks.cjs └── releases │ └── yarn-4.9.1.cjs ├── .yarnrc.yml ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── cliff.toml ├── package.json ├── sonar-project.properties ├── src ├── arguments │ ├── CoreBoolean.ts │ ├── CoreChannel.ts │ ├── CoreDMChannel.ts │ ├── CoreDate.ts │ ├── CoreEmoji.ts │ ├── CoreEnum.ts │ ├── CoreFloat.ts │ ├── CoreGuild.ts │ ├── CoreGuildCategoryChannel.ts │ ├── CoreGuildChannel.ts │ ├── CoreGuildNewsChannel.ts │ ├── CoreGuildNewsThreadChannel.ts │ ├── CoreGuildPrivateThreadChannel.ts │ ├── CoreGuildPublicThreadChannel.ts │ ├── CoreGuildStageVoiceChannel.ts │ ├── CoreGuildTextChannel.ts │ ├── CoreGuildThreadChannel.ts │ ├── CoreGuildVoiceChannel.ts │ ├── CoreHyperlink.ts │ ├── CoreInteger.ts │ ├── CoreMember.ts │ ├── CoreMessage.ts │ ├── CoreNumber.ts │ ├── CorePartialDMChannel.ts │ ├── CoreRole.ts │ ├── CoreString.ts │ ├── CoreUser.ts │ └── _load.ts ├── index.ts ├── lib │ ├── SapphireClient.ts │ ├── errors │ │ ├── ArgumentError.ts │ │ ├── Identifiers.ts │ │ ├── PreconditionError.ts │ │ └── UserError.ts │ ├── parsers │ │ └── Args.ts │ ├── plugins │ │ ├── Plugin.ts │ │ ├── PluginManager.ts │ │ └── symbols.ts │ ├── precondition-resolvers │ │ ├── clientPermissions.ts │ │ ├── cooldown.ts │ │ ├── index.ts │ │ ├── nsfw.ts │ │ ├── runIn.ts │ │ └── userPermissions.ts │ ├── resolvers │ │ ├── boolean.ts │ │ ├── channel.ts │ │ ├── date.ts │ │ ├── dmChannel.ts │ │ ├── emoji.ts │ │ ├── enum.ts │ │ ├── float.ts │ │ ├── guild.ts │ │ ├── guildCategoryChannel.ts │ │ ├── guildChannel.ts │ │ ├── guildNewsChannel.ts │ │ ├── guildNewsThreadChannel.ts │ │ ├── guildPrivateThreadChannel.ts │ │ ├── guildPublicThreadChannel.ts │ │ ├── guildStageVoiceChannel.ts │ │ ├── guildTextChannel.ts │ │ ├── guildThreadChannel.ts │ │ ├── guildVoiceChannel.ts │ │ ├── hyperlink.ts │ │ ├── index.ts │ │ ├── integer.ts │ │ ├── member.ts │ │ ├── message.ts │ │ ├── number.ts │ │ ├── partialDMChannel.ts │ │ ├── role.ts │ │ ├── string.ts │ │ └── user.ts │ ├── structures │ │ ├── Argument.ts │ │ ├── ArgumentStore.ts │ │ ├── Command.ts │ │ ├── CommandStore.ts │ │ ├── InteractionHandler.ts │ │ ├── InteractionHandlerStore.ts │ │ ├── Listener.ts │ │ ├── ListenerLoaderStrategy.ts │ │ ├── ListenerStore.ts │ │ ├── Precondition.ts │ │ └── PreconditionStore.ts │ ├── types │ │ ├── ArgumentContexts.ts │ │ ├── CommandTypes.ts │ │ ├── Enums.ts │ │ └── Events.ts │ └── utils │ │ ├── application-commands │ │ ├── ApplicationCommandRegistries.ts │ │ ├── ApplicationCommandRegistry.ts │ │ ├── compute-differences │ │ │ ├── _shared.ts │ │ │ ├── contexts.ts │ │ │ ├── default_member_permissions.ts │ │ │ ├── description.ts │ │ │ ├── dm_permission.ts │ │ │ ├── integration_types.ts │ │ │ ├── localizations.ts │ │ │ ├── name.ts │ │ │ ├── option │ │ │ │ ├── autocomplete.ts │ │ │ │ ├── channelTypes.ts │ │ │ │ ├── minMaxLength.ts │ │ │ │ ├── minMaxValue.ts │ │ │ │ ├── required.ts │ │ │ │ └── type.ts │ │ │ └── options.ts │ │ ├── computeDifferences.ts │ │ ├── getNeededParameters.ts │ │ ├── normalizeInputs.ts │ │ ├── registriesErrors.ts │ │ └── registriesLog.ts │ │ ├── logger │ │ ├── ILogger.ts │ │ └── Logger.ts │ │ ├── preconditions │ │ ├── IPreconditionContainer.ts │ │ ├── PreconditionContainerArray.ts │ │ ├── PreconditionContainerSingle.ts │ │ ├── conditions │ │ │ ├── IPreconditionCondition.ts │ │ │ ├── PreconditionConditionAnd.ts │ │ │ └── PreconditionConditionOr.ts │ │ └── containers │ │ │ ├── ClientPermissionsPrecondition.ts │ │ │ └── UserPermissionsPrecondition.ts │ │ ├── resolvers │ │ └── resolveGuildChannelPredicate.ts │ │ └── strategies │ │ └── FlagUnorderedStrategy.ts ├── listeners │ ├── CoreInteractionCreate.ts │ ├── CoreReady.ts │ ├── _load.ts │ └── application-commands │ │ ├── CorePossibleAutocompleteInteraction.ts │ │ ├── chat-input │ │ ├── CoreChatInputCommandAccepted.ts │ │ ├── CorePossibleChatInputCommand.ts │ │ └── CorePreChatInputCommandRun.ts │ │ └── context-menu │ │ ├── CoreContextMenuCommandAccepted.ts │ │ ├── CorePossibleContextMenuCommand.ts │ │ └── CorePreContextMenuCommandRun.ts ├── optional-listeners │ ├── application-command-registries-listeners │ │ ├── CoreApplicationCommandRegistriesInitialising.ts │ │ ├── CoreApplicationCommandRegistriesRegistered.ts │ │ └── _load.ts │ ├── error-listeners │ │ ├── CoreChatInputCommandError.ts │ │ ├── CoreCommandApplicationCommandRegistryError.ts │ │ ├── CoreCommandAutocompleteInteractionError.ts │ │ ├── CoreContextMenuCommandError.ts │ │ ├── CoreInteractionHandlerError.ts │ │ ├── CoreInteractionHandlerParseError.ts │ │ ├── CoreListenerError.ts │ │ ├── CoreMessageCommandError.ts │ │ └── _load.ts │ └── message-command-listeners │ │ ├── CoreMessageCommandAccepted.ts │ │ ├── CoreMessageCommandTyping.ts │ │ ├── CoreMessageCreate.ts │ │ ├── CorePreMessageCommandRun.ts │ │ ├── CorePreMessageParser.ts │ │ ├── CorePrefixedMessage.ts │ │ └── _load.ts ├── preconditions │ ├── ClientPermissions.ts │ ├── Cooldown.ts │ ├── DMOnly.ts │ ├── Enabled.ts │ ├── GuildNewsOnly.ts │ ├── GuildNewsThreadOnly.ts │ ├── GuildOnly.ts │ ├── GuildPrivateThreadOnly.ts │ ├── GuildPublicThreadOnly.ts │ ├── GuildTextOnly.ts │ ├── GuildThreadOnly.ts │ ├── GuildVoiceOnly.ts │ ├── NSFW.ts │ ├── RunIn.ts │ ├── UserPermissions.ts │ └── _load.ts └── tsconfig.json ├── tests ├── Flags.test.ts ├── application-commands │ └── computeDifferences.test.ts ├── precondition-resolvers │ ├── clientPermissions.test.ts │ ├── cooldown.test.ts │ ├── nsfw.test.ts │ ├── runIn.test.ts │ └── userPermissions.test.ts ├── resolvers │ ├── boolean.test.ts │ ├── date.test.ts │ ├── emoji.test.ts │ ├── enum.test.ts │ ├── float.test.ts │ ├── hyperlink.test.ts │ ├── integer.test.ts │ ├── number.test.ts │ └── string.test.ts └── tsconfig.json ├── tsconfig.base.json ├── tsconfig.eslint.json ├── tsup.config.ts ├── typedoc.json ├── vitest.config.ts └── yarn.lock /.cliff-jumperrc.yml: -------------------------------------------------------------------------------- 1 | name: framework 2 | packagePath: . 3 | org: sapphire 4 | monoRepo: false 5 | commitMessageTemplate: 'chore(release): release {{new-version}}' 6 | tagTemplate: v{{new-version}} 7 | identifierBase: false 8 | pushTag: true 9 | githubRelease: true 10 | githubReleaseLatest: true 11 | gitRepo: sapphiredev/framework 12 | gitHostVariant: github 13 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | trim_trailing_whitespace = true 8 | 9 | [*.{js,ts}] 10 | indent_size = 4 11 | indent_style = tab 12 | block_comment_start = /* 13 | block_comment = * 14 | block_comment_end = */ 15 | 16 | [*.{yml,yaml}] 17 | indent_size = 2 18 | indent_style = space 19 | 20 | [*.{md,rmd,mkd,mkdn,mdwn,mdown,markdown,litcoffee}] 21 | tab_width = 4 22 | trim_trailing_whitespace = false 23 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@sapphire", 3 | "plugins": ["deprecation"], 4 | "rules": { 5 | "deprecation/deprecation": "warn" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text eol=lf -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | /src/ @kyranet @favna @vladfrangu 2 | /tests/ @kyranet @favna @vladfrangu 3 | LICENSE.md @kyranet @favna @vladfrangu 4 | 5 | /scripts/ @favna 6 | /.github/ @favna 7 | /.vscode/ @favna 8 | .npm-deprecaterc.yml @favna 9 | jest.config.mjs @favna 10 | package.json @favna 11 | README.md @favna 12 | -------------------------------------------------------------------------------- /.github/hooks/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | yarn commitlint --edit $1 -------------------------------------------------------------------------------- /.github/hooks/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | yarn lint-staged -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": ["github>sapphiredev/.github:sapphire-renovate"], 4 | "npm": { 5 | "packageRules": [ 6 | { 7 | "matchPackagePatterns": ["tsup"], 8 | "enabled": false 9 | } 10 | ] 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /.github/workflows/auto-deprecate.yml: -------------------------------------------------------------------------------- 1 | name: NPM Auto Deprecate 2 | 3 | on: 4 | schedule: 5 | - cron: '0 0 * * *' 6 | 7 | jobs: 8 | auto-deprecate: 9 | name: NPM Auto Deprecate 10 | uses: sapphiredev/.github/.github/workflows/reusable-yarn-job.yml@main 11 | with: 12 | script-name: npm-deprecate 13 | secrets: 14 | NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }} 15 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: Code scanning 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | schedule: 11 | - cron: '30 1 * * 0' 12 | 13 | jobs: 14 | codeql: 15 | name: Analysis 16 | uses: sapphiredev/.github/.github/workflows/reusable-codeql.yml@main 17 | -------------------------------------------------------------------------------- /.github/workflows/continuous-delivery.yml: -------------------------------------------------------------------------------- 1 | name: Continuous Delivery 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | prNumber: 7 | description: The number of the PR that is being deployed 8 | required: false 9 | type: string 10 | ref: 11 | description: The branch that is being deployed. Should be a branch on the given repository 12 | required: false 13 | default: main 14 | type: string 15 | repository: 16 | description: The {owner}/{repository} that is being deployed. 17 | required: false 18 | default: sapphiredev/framework 19 | type: string 20 | push: 21 | branches: 22 | - main 23 | 24 | jobs: 25 | Publish: 26 | name: Publish Next to npm 27 | uses: sapphiredev/.github/.github/workflows/reusable-continuous-delivery.yml@main 28 | with: 29 | pr-number: ${{ github.event.inputs.prNumber }} 30 | ref: ${{ github.event.inputs.ref }} 31 | repository: ${{ github.event.inputs.repository }} 32 | secrets: 33 | NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }} 34 | -------------------------------------------------------------------------------- /.github/workflows/continuous-integration.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | linting: 11 | name: Linting 12 | uses: sapphiredev/.github/.github/workflows/reusable-lint.yml@main 13 | 14 | docs: 15 | name: Docgen 16 | if: github.event_name == 'pull_request' 17 | uses: sapphiredev/.github/.github/workflows/reusable-yarn-job.yml@main 18 | with: 19 | script-name: docs 20 | 21 | build: 22 | name: Building 23 | uses: sapphiredev/.github/.github/workflows/reusable-build.yml@main 24 | 25 | test: 26 | name: Tests 27 | uses: sapphiredev/.github/.github/workflows/reusable-tests.yml@main 28 | with: 29 | enable-sonar: true 30 | secrets: 31 | SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} 32 | 33 | typecheck: 34 | name: Typecheck 35 | uses: sapphiredev/.github/.github/workflows/reusable-typecheck.yml@main 36 | -------------------------------------------------------------------------------- /.github/workflows/deprecate-on-merge.yml: -------------------------------------------------------------------------------- 1 | name: NPM Deprecate PR versions On Merge 2 | 3 | on: 4 | pull_request: 5 | types: 6 | - closed 7 | 8 | jobs: 9 | deprecate-on-merge: 10 | name: NPM Deprecate PR versions On Merge 11 | uses: sapphiredev/.github/.github/workflows/reusable-yarn-job.yml@main 12 | with: 13 | script-name: npm-deprecate --name "*pr-${{ github.event.number }}*" -d -v 14 | secrets: 15 | NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }} 16 | -------------------------------------------------------------------------------- /.github/workflows/documentation.yml: -------------------------------------------------------------------------------- 1 | name: Documentation 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | tags: 8 | - 'v*' 9 | 10 | jobs: 11 | docgen: 12 | uses: sapphiredev/.github/.github/workflows/reusable-documentation.yml@main 13 | with: 14 | project-name: framework 15 | secrets: 16 | SKYRA_TOKEN: ${{ secrets.SKYRA_TOKEN }} 17 | -------------------------------------------------------------------------------- /.github/workflows/labelsync.yml: -------------------------------------------------------------------------------- 1 | name: Automatic Label Sync 2 | 3 | on: 4 | schedule: 5 | - cron: '0 0 * * *' 6 | workflow_dispatch: 7 | 8 | jobs: 9 | label_sync: 10 | uses: sapphiredev/.github/.github/workflows/reusable-labelsync.yml@main 11 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | PublishPackage: 8 | name: Publish @sapphire/framework 9 | uses: sapphiredev/.github/.github/workflows/reusable-publish.yml@main 10 | with: 11 | project-name: '@sapphire/framework' 12 | secrets: 13 | NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }} 14 | SKYRA_TOKEN: ${{ secrets.SKYRA_TOKEN }} 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore a blackhole and the folder for development 2 | node_modules/ 3 | .vs/ 4 | .idea/ 5 | *.iml 6 | coverage/ 7 | docs/ 8 | .tsup/ 9 | sonar-report.xml 10 | 11 | # Yarn files 12 | .yarn/install-state.gz 13 | .yarn/build-state.yml 14 | 15 | # Ignore tsc dist folder 16 | dist/ 17 | 18 | # Ignore heapsnapshot and log files 19 | *.heapsnapshot 20 | *.log 21 | 22 | # Ignore package locks 23 | package-lock.json 24 | 25 | # Ignore the GH cli downloaded by workflows 26 | gh 27 | 28 | # Ignore the "wiki" folder so we can checkout the wiki inside the same folder 29 | wiki/ 30 | 31 | # Ignore build artifacts 32 | *.tgz 33 | -------------------------------------------------------------------------------- /.npm-deprecaterc.yml: -------------------------------------------------------------------------------- 1 | name: '*next*' 2 | package: 3 | - '@sapphire/framework' 4 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | CHANGELOG.md 2 | .yarn 3 | -------------------------------------------------------------------------------- /.rollup-type-bundlerrc.yml: -------------------------------------------------------------------------------- 1 | external: 2 | - node:url 3 | - node:events 4 | onlyBundle: true 5 | -------------------------------------------------------------------------------- /.typedoc-json-parserrc.yml: -------------------------------------------------------------------------------- 1 | json: 'docs/api.json' 2 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["bierner.github-markdown-preview", "dbaeumer.vscode-eslint", "esbenp.prettier-vscode"] 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.eol": "\n", 3 | "sonarlint.connectedMode.project": { 4 | "connectionId": "sapphiredev", 5 | "projectKey": "sapphiredev_framework" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | compressionLevel: mixed 2 | 3 | enableGlobalCache: true 4 | 5 | gitHooksPath: .github/hooks 6 | 7 | nodeLinker: node-modules 8 | 9 | plugins: 10 | - path: .yarn/plugins/@yarnpkg/plugin-git-hooks.cjs 11 | spec: 'https://raw.githubusercontent.com/trufflehq/yarn-plugin-git-hooks/main/bundles/%40yarnpkg/plugin-git-hooks.js' 12 | 13 | yarnPath: .yarn/releases/yarn-4.9.1.cjs 14 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # The MIT License (MIT) 2 | 3 | Copyright © `2020` `The Sapphire Community and its contributors` 4 | 5 | Permission is hereby granted, free of charge, to any person 6 | obtaining a copy of this software and associated documentation 7 | files (the “Software”), to deal in the Software without 8 | restriction, including without limitation the rights to use, 9 | copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the 11 | Software is furnished to do so, subject to the following 12 | conditions: 13 | 14 | The above copyright notice and this permission notice shall be 15 | included in all copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, 18 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 19 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 20 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 21 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 22 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 23 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 24 | OTHER DEALINGS IN THE SOFTWARE. 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | ![Sapphire Logo](https://raw.githubusercontent.com/sapphiredev/assets/main/banners/SapphireCommunity.png) 4 | 5 | # Sapphire 6 | 7 | **A pleasant Discord Bot framework** 8 | 9 | [![GitHub](https://img.shields.io/github/license/sapphiredev/framework)](https://github.com/sapphiredev/framework/blob/main/LICENSE.md) 10 | [![npm](https://img.shields.io/npm/v/@sapphire/framework?color=crimson&logo=npm&style=flat-square)](https://www.npmjs.com/package/@sapphire/framework) 11 | 12 | [![Support Server](https://discord.com/api/guilds/737141877803057244/embed.png?style=banner2)](https://sapphirejs.dev/discord) 13 | 14 |
15 | 16 | --- 17 | 18 | ## Description 19 | 20 | Sapphire is a Discord bot framework built on top of [discord.js] for advanced and amazing bots. 21 | 22 |
23 | 24 | | [**Click here for the documentation and guides**](https://www.sapphirejs.dev/) | 25 | | ------------------------------------------------------------------------------ | 26 | 27 |
28 | 29 | ## Features 30 | 31 | - Written in TypeScript 32 | - Command Handler, Arguments, Pre-conditions and Listeners Store 33 | - Completely Modular and Extendable 34 | - Advanced Plugins Support 35 | - Supports many [plugins](https://github.com/sapphiredev/plugins) 36 | - Full TypeScript & JavaScript support 37 | 38 | ## Installation 39 | 40 | `@sapphire/framework` depends on the following packages. Be sure to install these along with this package! 41 | 42 | - [`discord.js`](https://www.npmjs.com/package/discord.js) 43 | 44 | You can use the following command to install this package, or replace `npm install` with your package manager of choice. 45 | 46 | ```sh 47 | npm install @sapphire/framework discord.js@14.x 48 | ``` 49 | 50 | --- 51 | 52 | ## Buy us some doughnuts 53 | 54 | Sapphire Community is and always will be open source, even if we don't get donations. That being said, we know there are amazing people who may still want to donate just to show their appreciation. Thank you very much in advance! 55 | 56 | We accept donations through Open Collective, Ko-fi, Paypal, Patreon and GitHub Sponsorships. You can use the buttons below to donate through your method of choice. 57 | 58 | | Donate With | Address | 59 | | :-------------: | :-------------------------------------------------: | 60 | | Open Collective | [Click Here](https://sapphirejs.dev/opencollective) | 61 | | Ko-fi | [Click Here](https://sapphirejs.dev/kofi) | 62 | | Patreon | [Click Here](https://sapphirejs.dev/patreon) | 63 | | PayPal | [Click Here](https://sapphirejs.dev/paypal) | 64 | 65 | ## Contributors 66 | 67 | Please make sure to read the [Contributing Guide][contributing] before making a pull request. 68 | 69 | Thank you to all the people who already contributed to Sapphire! 70 | 71 | 72 | 73 | 74 | 75 | [contributing]: https://github.com/sapphiredev/.github/blob/main/.github/CONTRIBUTING.md 76 | [discord.js]: https://github.com/discordjs/discord.js 77 | -------------------------------------------------------------------------------- /cliff.toml: -------------------------------------------------------------------------------- 1 | [changelog] 2 | header = """ 3 | # Changelog 4 | 5 | All notable changes to this project will be documented in this file.\n 6 | """ 7 | body = """ 8 | {%- macro remote_url() -%} 9 | https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }} 10 | {%- endmacro -%} 11 | {% if version %}\ 12 | # [{{ version | trim_start_matches(pat="v") }}]\ 13 | {% if previous %}\ 14 | {% if previous.version %}\ 15 | ({{ self::remote_url() }}/compare/{{ previous.version }}...{{ version }})\ 16 | {% else %}\ 17 | ({{ self::remote_url() }}/tree/{{ version }})\ 18 | {% endif %}\ 19 | {% endif %} \ 20 | - ({{ timestamp | date(format="%Y-%m-%d") }}) 21 | {% else %}\ 22 | # [unreleased] 23 | {% endif %}\ 24 | {% for group, commits in commits | group_by(attribute="group") %} 25 | ## {{ group | upper_first }} 26 | {% for commit in commits %} 27 | - {% if commit.scope %}\ 28 | **{{commit.scope}}:** \ 29 | {% endif %}\ 30 | {{ commit.message | upper_first }} ([{{ commit.id | truncate(length=7, end="") }}]({{ self::remote_url() }}/commit/{{ commit.id }}))\ 31 | {% if commit.github.pr_number %} (\ 32 | [#{{ commit.github.pr_number }}]({{ self::remote_url() }}/pull/{{ commit.github.pr_number }}) by @{{ commit.github.username }}) \ 33 | {%- endif %}\ 34 | {% if commit.breaking %}\ 35 | {% for breakingChange in commit.footers %}\ 36 | \n{% raw %} {% endraw %}- 💥 **{{ breakingChange.token }}{{ breakingChange.separator }}** {{ breakingChange.value }}\ 37 | {% endfor %}\ 38 | {% endif %}\ 39 | {% endfor %} 40 | {% endfor %}\n 41 | """ 42 | trim = true 43 | footer = "" 44 | 45 | [git] 46 | conventional_commits = true 47 | filter_unconventional = true 48 | commit_parsers = [ 49 | { message = "^feat", group = "🚀 Features" }, 50 | { message = "^fix", group = "🐛 Bug Fixes" }, 51 | { message = "^docs", group = "📝 Documentation" }, 52 | { message = "^perf", group = "🏃 Performance" }, 53 | { message = "^refactor", group = "🏠 Refactor" }, 54 | { message = "^typings", group = "⌨️ Typings" }, 55 | { message = "^types", group = "⌨️ Typings" }, 56 | { message = ".*deprecated", body = ".*deprecated", group = "🚨 Deprecation" }, 57 | { message = "^revert", skip = true }, 58 | { message = "^style", group = "🪞 Styling" }, 59 | { message = "^test", group = "🧪 Testing" }, 60 | { message = "^chore", skip = true }, 61 | { message = "^ci", skip = true }, 62 | { message = "^build", skip = true }, 63 | { body = ".*security", group = "🛡️ Security" }, 64 | ] 65 | commit_preprocessors = [ 66 | # remove issue numbers from commits 67 | { pattern = '\s\((\w+\s)?#([0-9]+)\)', replace = "" }, 68 | ] 69 | filter_commits = true 70 | tag_pattern = "v[0-9]*" 71 | ignore_tags = "" 72 | topo_order = false 73 | sort_commits = "newest" 74 | 75 | [remote.github] 76 | owner = "sapphiredev" 77 | repo = "framework" 78 | -------------------------------------------------------------------------------- /sonar-project.properties: -------------------------------------------------------------------------------- 1 | sonar.projectKey=sapphiredev_framework 2 | sonar.organization=sapphiredev 3 | sonar.javascript.lcov.reportPaths=./coverage/lcov.info 4 | sonar.pullrequest.github.summary_comment=false 5 | -------------------------------------------------------------------------------- /src/arguments/CoreBoolean.ts: -------------------------------------------------------------------------------- 1 | import { container } from '@sapphire/pieces'; 2 | import { resolveBoolean } from '../lib/resolvers/boolean'; 3 | import { Argument } from '../lib/structures/Argument'; 4 | import type { BooleanArgumentContext } from '../lib/types/ArgumentContexts'; 5 | 6 | export class CoreArgument extends Argument { 7 | public constructor(context: Argument.LoaderContext) { 8 | super(context, { name: 'boolean' }); 9 | } 10 | 11 | public run(parameter: string, context: BooleanArgumentContext): Argument.Result { 12 | const resolved = resolveBoolean(parameter, { truths: context.truths, falses: context.falses }); 13 | return resolved.mapErrInto((identifier) => 14 | this.error({ 15 | parameter, 16 | identifier, 17 | message: 'The argument did not resolve to a boolean.', 18 | context 19 | }) 20 | ); 21 | } 22 | } 23 | 24 | void container.stores.loadPiece({ 25 | name: 'boolean', 26 | piece: CoreArgument, 27 | store: 'arguments' 28 | }); 29 | -------------------------------------------------------------------------------- /src/arguments/CoreChannel.ts: -------------------------------------------------------------------------------- 1 | import type { ChannelTypes } from '@sapphire/discord.js-utilities'; 2 | import { container } from '@sapphire/pieces'; 3 | import { resolveChannel } from '../lib/resolvers/channel'; 4 | import { Argument } from '../lib/structures/Argument'; 5 | 6 | export class CoreArgument extends Argument { 7 | public constructor(context: Argument.LoaderContext) { 8 | super(context, { name: 'channel' }); 9 | } 10 | 11 | public run(parameter: string, context: Argument.Context): Argument.Result { 12 | const resolved = resolveChannel(parameter, context.message); 13 | return resolved.mapErrInto((identifier) => 14 | this.error({ 15 | parameter, 16 | identifier, 17 | message: 'The argument did not resolve to a channel.', 18 | context 19 | }) 20 | ); 21 | } 22 | } 23 | 24 | void container.stores.loadPiece({ 25 | name: 'channel', 26 | piece: CoreArgument, 27 | store: 'arguments' 28 | }); 29 | -------------------------------------------------------------------------------- /src/arguments/CoreDMChannel.ts: -------------------------------------------------------------------------------- 1 | import { container } from '@sapphire/pieces'; 2 | import type { DMChannel } from 'discord.js'; 3 | import { resolveDMChannel } from '../lib/resolvers/dmChannel'; 4 | import { Argument } from '../lib/structures/Argument'; 5 | 6 | export class CoreArgument extends Argument { 7 | public constructor(context: Argument.LoaderContext) { 8 | super(context, { name: 'dmChannel' }); 9 | } 10 | 11 | public run(parameter: string, context: Argument.Context): Argument.Result { 12 | const resolved = resolveDMChannel(parameter, context.message); 13 | return resolved.mapErrInto((identifier) => 14 | this.error({ 15 | parameter, 16 | identifier, 17 | message: 'The argument did not resolve to a DM channel.', 18 | context 19 | }) 20 | ); 21 | } 22 | } 23 | 24 | void container.stores.loadPiece({ 25 | name: 'dmChannel', 26 | piece: CoreArgument, 27 | store: 'arguments' 28 | }); 29 | -------------------------------------------------------------------------------- /src/arguments/CoreDate.ts: -------------------------------------------------------------------------------- 1 | import { container } from '@sapphire/pieces'; 2 | import { Identifiers } from '../lib/errors/Identifiers'; 3 | import { resolveDate } from '../lib/resolvers/date'; 4 | import { Argument } from '../lib/structures/Argument'; 5 | 6 | export class CoreArgument extends Argument { 7 | private readonly messages = { 8 | [Identifiers.ArgumentDateTooEarly]: ({ minimum }: Argument.Context) => `The given date must be after ${new Date(minimum!).toISOString()}.`, 9 | [Identifiers.ArgumentDateTooFar]: ({ maximum }: Argument.Context) => `The given date must be before ${new Date(maximum!).toISOString()}.`, 10 | [Identifiers.ArgumentDateError]: () => 'The argument did not resolve to a date.' 11 | } as const; 12 | 13 | public constructor(context: Argument.LoaderContext) { 14 | super(context, { name: 'date' }); 15 | } 16 | 17 | public run(parameter: string, context: Argument.Context): Argument.Result { 18 | const resolved = resolveDate(parameter, { minimum: context.minimum, maximum: context.maximum }); 19 | return resolved.mapErrInto((identifier) => 20 | this.error({ 21 | parameter, 22 | identifier, 23 | message: this.messages[identifier](context), 24 | context 25 | }) 26 | ); 27 | } 28 | } 29 | 30 | void container.stores.loadPiece({ 31 | name: 'date', 32 | piece: CoreArgument, 33 | store: 'arguments' 34 | }); 35 | -------------------------------------------------------------------------------- /src/arguments/CoreEmoji.ts: -------------------------------------------------------------------------------- 1 | import { container } from '@sapphire/pieces'; 2 | import { resolveEmoji, type EmojiObject } from '../lib/resolvers/emoji'; 3 | import { Argument } from '../lib/structures/Argument'; 4 | 5 | export class CoreArgument extends Argument { 6 | public constructor(context: Argument.LoaderContext) { 7 | super(context, { name: 'emoji' }); 8 | } 9 | 10 | public run(parameter: string, context: Argument.Context): Argument.Result { 11 | const resolved = resolveEmoji(parameter); 12 | return resolved.mapErrInto((identifier) => 13 | this.error({ 14 | parameter, 15 | identifier, 16 | message: 'The argument did not resolve to an emoji.', 17 | context 18 | }) 19 | ); 20 | } 21 | } 22 | 23 | void container.stores.loadPiece({ 24 | name: 'emoji', 25 | piece: CoreArgument, 26 | store: 'arguments' 27 | }); 28 | -------------------------------------------------------------------------------- /src/arguments/CoreEnum.ts: -------------------------------------------------------------------------------- 1 | import { container } from '@sapphire/pieces'; 2 | import { resolveEnum } from '../lib/resolvers/enum'; 3 | import { Argument } from '../lib/structures/Argument'; 4 | import type { EnumArgumentContext } from '../lib/types/ArgumentContexts'; 5 | 6 | export class CoreArgument extends Argument { 7 | public constructor(context: Argument.LoaderContext) { 8 | super(context, { name: 'enum' }); 9 | } 10 | 11 | public run(parameter: string, context: EnumArgumentContext): Argument.Result { 12 | const resolved = resolveEnum(parameter, { enum: context.enum, caseInsensitive: context.caseInsensitive }); 13 | return resolved.mapErrInto((identifier) => 14 | this.error({ 15 | parameter, 16 | identifier, 17 | message: `The argument must have one of the following values: ${context.enum?.join(', ')}`, 18 | context 19 | }) 20 | ); 21 | } 22 | } 23 | 24 | void container.stores.loadPiece({ 25 | name: 'enum', 26 | piece: CoreArgument, 27 | store: 'arguments' 28 | }); 29 | -------------------------------------------------------------------------------- /src/arguments/CoreFloat.ts: -------------------------------------------------------------------------------- 1 | import { container } from '@sapphire/pieces'; 2 | import { Identifiers } from '../lib/errors/Identifiers'; 3 | import { resolveFloat } from '../lib/resolvers/float'; 4 | import { Argument } from '../lib/structures/Argument'; 5 | 6 | export class CoreArgument extends Argument { 7 | private readonly messages = { 8 | [Identifiers.ArgumentFloatTooSmall]: ({ minimum }: Argument.Context) => `The given number must be greater than ${minimum}.`, 9 | [Identifiers.ArgumentFloatTooLarge]: ({ maximum }: Argument.Context) => `The given number must be less than ${maximum}.`, 10 | [Identifiers.ArgumentFloatError]: () => 'The argument did not resolve to a valid decimal.' 11 | } as const; 12 | 13 | public constructor(context: Argument.LoaderContext) { 14 | super(context, { name: 'float' }); 15 | } 16 | 17 | public run(parameter: string, context: Argument.Context): Argument.Result { 18 | const resolved = resolveFloat(parameter, { minimum: context.minimum, maximum: context.maximum }); 19 | return resolved.mapErrInto((identifier) => 20 | this.error({ 21 | parameter, 22 | identifier, 23 | message: this.messages[identifier](context), 24 | context 25 | }) 26 | ); 27 | } 28 | } 29 | 30 | void container.stores.loadPiece({ 31 | name: 'float', 32 | piece: CoreArgument, 33 | store: 'arguments' 34 | }); 35 | -------------------------------------------------------------------------------- /src/arguments/CoreGuild.ts: -------------------------------------------------------------------------------- 1 | import { container } from '@sapphire/pieces'; 2 | import type { Guild } from 'discord.js'; 3 | import { resolveGuild } from '../lib/resolvers/guild'; 4 | import { Argument } from '../lib/structures/Argument'; 5 | 6 | export class CoreArgument extends Argument { 7 | public constructor(context: Argument.LoaderContext) { 8 | super(context, { name: 'guild' }); 9 | } 10 | 11 | public async run(parameter: string, context: Argument.Context): Argument.AsyncResult { 12 | const resolved = await resolveGuild(parameter); 13 | return resolved.mapErrInto((identifier) => 14 | this.error({ 15 | parameter, 16 | identifier, 17 | message: 'The given argument did not resolve to a Discord guild.', 18 | context 19 | }) 20 | ); 21 | } 22 | } 23 | 24 | void container.stores.loadPiece({ 25 | name: 'guild', 26 | piece: CoreArgument, 27 | store: 'arguments' 28 | }); 29 | -------------------------------------------------------------------------------- /src/arguments/CoreGuildCategoryChannel.ts: -------------------------------------------------------------------------------- 1 | import { container } from '@sapphire/pieces'; 2 | import type { CategoryChannel } from 'discord.js'; 3 | import { Identifiers } from '../lib/errors/Identifiers'; 4 | import { resolveGuildCategoryChannel } from '../lib/resolvers/guildCategoryChannel'; 5 | import { Argument } from '../lib/structures/Argument'; 6 | 7 | export class CoreArgument extends Argument { 8 | public constructor(context: Argument.LoaderContext) { 9 | super(context, { name: 'guildCategoryChannel' }); 10 | } 11 | 12 | public run(parameter: string, context: Argument.Context): Argument.Result { 13 | const { guild } = context.message; 14 | if (!guild) { 15 | return this.error({ 16 | parameter, 17 | identifier: Identifiers.ArgumentGuildChannelMissingGuildError, 18 | message: 'This command can only be used in a server.', 19 | context 20 | }); 21 | } 22 | 23 | const resolved = resolveGuildCategoryChannel(parameter, guild); 24 | return resolved.mapErrInto((identifier) => 25 | this.error({ 26 | parameter, 27 | identifier, 28 | message: 'The argument did not resolve to a valid server category channel.', 29 | context: { ...context, guild } 30 | }) 31 | ); 32 | } 33 | } 34 | 35 | void container.stores.loadPiece({ 36 | name: 'guildCategoryChannel', 37 | piece: CoreArgument, 38 | store: 'arguments' 39 | }); 40 | -------------------------------------------------------------------------------- /src/arguments/CoreGuildChannel.ts: -------------------------------------------------------------------------------- 1 | import type { GuildBasedChannelTypes } from '@sapphire/discord.js-utilities'; 2 | import { container } from '@sapphire/pieces'; 3 | import { Identifiers } from '../lib/errors/Identifiers'; 4 | import { resolveGuildChannel } from '../lib/resolvers/guildChannel'; 5 | import { Argument } from '../lib/structures/Argument'; 6 | 7 | export class CoreArgument extends Argument { 8 | public constructor(context: Argument.LoaderContext) { 9 | super(context, { name: 'guildChannel' }); 10 | } 11 | 12 | public run(parameter: string, context: Argument.Context): Argument.Result { 13 | const { guild } = context.message; 14 | if (!guild) { 15 | return this.error({ 16 | parameter, 17 | identifier: Identifiers.ArgumentGuildChannelMissingGuildError, 18 | message: 'This command can only be used in a server.', 19 | context 20 | }); 21 | } 22 | 23 | const resolved = resolveGuildChannel(parameter, guild); 24 | return resolved.mapErrInto((identifier) => 25 | this.error({ 26 | parameter, 27 | identifier, 28 | message: 'The argument did not resolve to a valid server channel.', 29 | context: { ...context, guild } 30 | }) 31 | ); 32 | } 33 | } 34 | 35 | void container.stores.loadPiece({ 36 | name: 'guildChannel', 37 | piece: CoreArgument, 38 | store: 'arguments' 39 | }); 40 | -------------------------------------------------------------------------------- /src/arguments/CoreGuildNewsChannel.ts: -------------------------------------------------------------------------------- 1 | import { container } from '@sapphire/pieces'; 2 | import type { NewsChannel } from 'discord.js'; 3 | import { Identifiers } from '../lib/errors/Identifiers'; 4 | import { resolveGuildNewsChannel } from '../lib/resolvers/guildNewsChannel'; 5 | import { Argument } from '../lib/structures/Argument'; 6 | 7 | export class CoreArgument extends Argument { 8 | public constructor(context: Argument.LoaderContext) { 9 | super(context, { name: 'guildNewsChannel' }); 10 | } 11 | 12 | public run(parameter: string, context: Argument.Context): Argument.Result { 13 | const { guild } = context.message; 14 | if (!guild) { 15 | return this.error({ 16 | parameter, 17 | identifier: Identifiers.ArgumentGuildChannelMissingGuildError, 18 | message: 'This command can only be used in a server.', 19 | context 20 | }); 21 | } 22 | 23 | const resolved = resolveGuildNewsChannel(parameter, guild); 24 | return resolved.mapErrInto((identifier) => 25 | this.error({ 26 | parameter, 27 | identifier, 28 | message: 'The given argument did not resolve to a valid announcements channel.', 29 | context: { ...context, guild } 30 | }) 31 | ); 32 | } 33 | } 34 | 35 | void container.stores.loadPiece({ 36 | name: 'guildNewsChannel', 37 | piece: CoreArgument, 38 | store: 'arguments' 39 | }); 40 | -------------------------------------------------------------------------------- /src/arguments/CoreGuildNewsThreadChannel.ts: -------------------------------------------------------------------------------- 1 | import { container } from '@sapphire/pieces'; 2 | import type { ThreadChannel } from 'discord.js'; 3 | import { Identifiers } from '../lib/errors/Identifiers'; 4 | import { resolveGuildNewsThreadChannel } from '../lib/resolvers/guildNewsThreadChannel'; 5 | import { Argument } from '../lib/structures/Argument'; 6 | 7 | export class CoreArgument extends Argument { 8 | public constructor(context: Argument.LoaderContext) { 9 | super(context, { name: 'guildNewsThreadChannel' }); 10 | } 11 | 12 | public run(parameter: string, context: Argument.Context): Argument.Result { 13 | const { guild } = context.message; 14 | if (!guild) { 15 | return this.error({ 16 | parameter, 17 | identifier: Identifiers.ArgumentGuildChannelMissingGuildError, 18 | message: 'This command can only be used in a server.', 19 | context 20 | }); 21 | } 22 | 23 | const resolved = resolveGuildNewsThreadChannel(parameter, guild); 24 | return resolved.mapErrInto((identifier) => 25 | this.error({ 26 | parameter, 27 | identifier, 28 | message: 'The given argument did not resolve to a valid announcements thread.', 29 | context: { ...context, guild } 30 | }) 31 | ); 32 | } 33 | } 34 | 35 | void container.stores.loadPiece({ 36 | name: 'guildNewsThreadChannel', 37 | piece: CoreArgument, 38 | store: 'arguments' 39 | }); 40 | -------------------------------------------------------------------------------- /src/arguments/CoreGuildPrivateThreadChannel.ts: -------------------------------------------------------------------------------- 1 | import { container } from '@sapphire/pieces'; 2 | import type { ThreadChannel } from 'discord.js'; 3 | import { Identifiers } from '../lib/errors/Identifiers'; 4 | import { resolveGuildPrivateThreadChannel } from '../lib/resolvers/guildPrivateThreadChannel'; 5 | import { Argument } from '../lib/structures/Argument'; 6 | 7 | export class CoreArgument extends Argument { 8 | public constructor(context: Argument.LoaderContext) { 9 | super(context, { name: 'guildPrivateThreadChannel' }); 10 | } 11 | 12 | public run(parameter: string, context: Argument.Context): Argument.Result { 13 | const { guild } = context.message; 14 | if (!guild) { 15 | return this.error({ 16 | parameter, 17 | identifier: Identifiers.ArgumentGuildChannelMissingGuildError, 18 | message: 'This command can only be used in a server.', 19 | context 20 | }); 21 | } 22 | 23 | const resolved = resolveGuildPrivateThreadChannel(parameter, guild); 24 | return resolved.mapErrInto((identifier) => 25 | this.error({ 26 | parameter, 27 | identifier, 28 | message: 'The given argument did not resolve to a valid private thread.', 29 | context: { ...context, guild } 30 | }) 31 | ); 32 | } 33 | } 34 | 35 | void container.stores.loadPiece({ 36 | name: 'guildPrivateThreadChannel', 37 | piece: CoreArgument, 38 | store: 'arguments' 39 | }); 40 | -------------------------------------------------------------------------------- /src/arguments/CoreGuildPublicThreadChannel.ts: -------------------------------------------------------------------------------- 1 | import { container } from '@sapphire/pieces'; 2 | import type { ThreadChannel } from 'discord.js'; 3 | import { Identifiers } from '../lib/errors/Identifiers'; 4 | import { resolveGuildPublicThreadChannel } from '../lib/resolvers/guildPublicThreadChannel'; 5 | import { Argument } from '../lib/structures/Argument'; 6 | 7 | export class CoreArgument extends Argument { 8 | public constructor(context: Argument.LoaderContext) { 9 | super(context, { name: 'guildPublicThreadChannel' }); 10 | } 11 | 12 | public run(parameter: string, context: Argument.Context): Argument.Result { 13 | const { guild } = context.message; 14 | if (!guild) { 15 | return this.error({ 16 | parameter, 17 | identifier: Identifiers.ArgumentGuildChannelMissingGuildError, 18 | message: 'This command can only be used in a server.', 19 | context 20 | }); 21 | } 22 | 23 | const resolved = resolveGuildPublicThreadChannel(parameter, guild); 24 | return resolved.mapErrInto((identifier) => 25 | this.error({ 26 | parameter, 27 | identifier, 28 | message: 'The given argument did not resolve to a valid public thread.', 29 | context: { ...context, guild } 30 | }) 31 | ); 32 | } 33 | } 34 | 35 | void container.stores.loadPiece({ 36 | name: 'guildPublicThreadChannel', 37 | piece: CoreArgument, 38 | store: 'arguments' 39 | }); 40 | -------------------------------------------------------------------------------- /src/arguments/CoreGuildStageVoiceChannel.ts: -------------------------------------------------------------------------------- 1 | import { container } from '@sapphire/pieces'; 2 | import type { StageChannel } from 'discord.js'; 3 | import { Identifiers } from '../lib/errors/Identifiers'; 4 | import { resolveGuildStageVoiceChannel } from '../lib/resolvers/guildStageVoiceChannel'; 5 | import { Argument } from '../lib/structures/Argument'; 6 | 7 | export class CoreArgument extends Argument { 8 | public constructor(context: Argument.LoaderContext) { 9 | super(context, { name: 'guildStageVoiceChannel' }); 10 | } 11 | 12 | public run(parameter: string, context: Argument.Context): Argument.Result { 13 | const { guild } = context.message; 14 | if (!guild) { 15 | return this.error({ 16 | parameter, 17 | identifier: Identifiers.ArgumentGuildChannelMissingGuildError, 18 | message: 'This command can only be used in a server.', 19 | context 20 | }); 21 | } 22 | 23 | const resolved = resolveGuildStageVoiceChannel(parameter, guild); 24 | return resolved.mapErrInto((identifier) => 25 | this.error({ 26 | parameter, 27 | identifier, 28 | message: 'The given argument did not resolve to a valid stage voice channel.', 29 | context: { ...context, guild } 30 | }) 31 | ); 32 | } 33 | } 34 | 35 | void container.stores.loadPiece({ 36 | name: 'guildStageVoiceChannel', 37 | piece: CoreArgument, 38 | store: 'arguments' 39 | }); 40 | -------------------------------------------------------------------------------- /src/arguments/CoreGuildTextChannel.ts: -------------------------------------------------------------------------------- 1 | import { container } from '@sapphire/pieces'; 2 | import type { TextChannel } from 'discord.js'; 3 | import { Identifiers } from '../lib/errors/Identifiers'; 4 | import { resolveGuildTextChannel } from '../lib/resolvers/guildTextChannel'; 5 | import { Argument } from '../lib/structures/Argument'; 6 | 7 | export class CoreArgument extends Argument { 8 | public constructor(context: Argument.LoaderContext) { 9 | super(context, { name: 'guildTextChannel' }); 10 | } 11 | 12 | public run(parameter: string, context: Argument.Context): Argument.Result { 13 | const { guild } = context.message; 14 | if (!guild) { 15 | return this.error({ 16 | parameter, 17 | identifier: Identifiers.ArgumentGuildChannelMissingGuildError, 18 | message: 'This command can only be used in a server.', 19 | context 20 | }); 21 | } 22 | 23 | const resolved = resolveGuildTextChannel(parameter, guild); 24 | return resolved.mapErrInto((identifier) => 25 | this.error({ 26 | parameter, 27 | identifier, 28 | message: 'The given argument did not resolve to a valid text channel.', 29 | context: { ...context, guild } 30 | }) 31 | ); 32 | } 33 | } 34 | 35 | void container.stores.loadPiece({ 36 | name: 'guildTextChannel', 37 | piece: CoreArgument, 38 | store: 'arguments' 39 | }); 40 | -------------------------------------------------------------------------------- /src/arguments/CoreGuildThreadChannel.ts: -------------------------------------------------------------------------------- 1 | import { container } from '@sapphire/pieces'; 2 | import type { ThreadChannel } from 'discord.js'; 3 | import { Identifiers } from '../lib/errors/Identifiers'; 4 | import { resolveGuildThreadChannel } from '../lib/resolvers/guildThreadChannel'; 5 | import { Argument } from '../lib/structures/Argument'; 6 | 7 | export class CoreArgument extends Argument { 8 | public constructor(context: Argument.LoaderContext) { 9 | super(context, { name: 'guildThreadChannel' }); 10 | } 11 | 12 | public run(parameter: string, context: Argument.Context): Argument.Result { 13 | const { guild } = context.message; 14 | if (!guild) { 15 | return this.error({ 16 | parameter, 17 | identifier: Identifiers.ArgumentGuildChannelMissingGuildError, 18 | message: 'This command can only be used in a server.', 19 | context 20 | }); 21 | } 22 | 23 | const resolved = resolveGuildThreadChannel(parameter, guild); 24 | return resolved.mapErrInto((identifier) => 25 | this.error({ 26 | parameter, 27 | identifier, 28 | message: 'The given argument did not resolve to a valid thread.', 29 | context: { ...context, guild } 30 | }) 31 | ); 32 | } 33 | } 34 | 35 | void container.stores.loadPiece({ 36 | name: 'guildThreadChannel', 37 | piece: CoreArgument, 38 | store: 'arguments' 39 | }); 40 | -------------------------------------------------------------------------------- /src/arguments/CoreGuildVoiceChannel.ts: -------------------------------------------------------------------------------- 1 | import { container } from '@sapphire/pieces'; 2 | import type { VoiceChannel } from 'discord.js'; 3 | import { Identifiers } from '../lib/errors/Identifiers'; 4 | import { resolveGuildVoiceChannel } from '../lib/resolvers/guildVoiceChannel'; 5 | import { Argument } from '../lib/structures/Argument'; 6 | 7 | export class CoreArgument extends Argument { 8 | public constructor(context: Argument.LoaderContext) { 9 | super(context, { name: 'guildVoiceChannel' }); 10 | } 11 | 12 | public run(parameter: string, context: Argument.Context): Argument.Result { 13 | const { guild } = context.message; 14 | if (!guild) { 15 | return this.error({ 16 | parameter, 17 | identifier: Identifiers.ArgumentGuildChannelMissingGuildError, 18 | message: 'This command can only be used in a server.', 19 | context 20 | }); 21 | } 22 | 23 | const resolved = resolveGuildVoiceChannel(parameter, guild); 24 | return resolved.mapErrInto((identifier) => 25 | this.error({ 26 | parameter, 27 | identifier, 28 | message: 'The given argument did not resolve to a valid voice channel.', 29 | context: { ...context, guild } 30 | }) 31 | ); 32 | } 33 | } 34 | 35 | void container.stores.loadPiece({ 36 | name: 'guildVoiceChannel', 37 | piece: CoreArgument, 38 | store: 'arguments' 39 | }); 40 | -------------------------------------------------------------------------------- /src/arguments/CoreHyperlink.ts: -------------------------------------------------------------------------------- 1 | import { container } from '@sapphire/pieces'; 2 | import type { URL } from 'node:url'; 3 | import { resolveHyperlink } from '../lib/resolvers/hyperlink'; 4 | import { Argument } from '../lib/structures/Argument'; 5 | 6 | export class CoreArgument extends Argument { 7 | public constructor(context: Argument.LoaderContext) { 8 | super(context, { name: 'hyperlink', aliases: ['url'] }); 9 | } 10 | 11 | public run(parameter: string, context: Argument.Context): Argument.Result { 12 | const resolved = resolveHyperlink(parameter); 13 | return resolved.mapErrInto((identifier) => 14 | this.error({ 15 | parameter, 16 | identifier, 17 | message: 'The argument did not resolve to a valid URL.', 18 | context 19 | }) 20 | ); 21 | } 22 | } 23 | 24 | void container.stores.loadPiece({ 25 | name: 'hyperlink', 26 | piece: CoreArgument, 27 | store: 'arguments' 28 | }); 29 | -------------------------------------------------------------------------------- /src/arguments/CoreInteger.ts: -------------------------------------------------------------------------------- 1 | import { container } from '@sapphire/pieces'; 2 | import { Identifiers } from '../lib/errors/Identifiers'; 3 | import { resolveInteger } from '../lib/resolvers/integer'; 4 | import { Argument } from '../lib/structures/Argument'; 5 | 6 | export class CoreArgument extends Argument { 7 | private readonly messages = { 8 | [Identifiers.ArgumentIntegerTooSmall]: ({ minimum }: Argument.Context) => `The given number must be greater than ${minimum}.`, 9 | [Identifiers.ArgumentIntegerTooLarge]: ({ maximum }: Argument.Context) => `The given number must be less than ${maximum}.`, 10 | [Identifiers.ArgumentIntegerError]: () => 'The argument did not resolve to a valid number.' 11 | } as const; 12 | 13 | public constructor(context: Argument.LoaderContext) { 14 | super(context, { name: 'integer' }); 15 | } 16 | 17 | public run(parameter: string, context: Argument.Context): Argument.Result { 18 | const resolved = resolveInteger(parameter, { minimum: context.minimum, maximum: context.maximum }); 19 | return resolved.mapErrInto((identifier) => 20 | this.error({ 21 | parameter, 22 | identifier, 23 | message: this.messages[identifier](context), 24 | context 25 | }) 26 | ); 27 | } 28 | } 29 | 30 | void container.stores.loadPiece({ 31 | name: 'integer', 32 | piece: CoreArgument, 33 | store: 'arguments' 34 | }); 35 | -------------------------------------------------------------------------------- /src/arguments/CoreMember.ts: -------------------------------------------------------------------------------- 1 | import { container } from '@sapphire/pieces'; 2 | import type { GuildMember } from 'discord.js'; 3 | import { Identifiers } from '../lib/errors/Identifiers'; 4 | import { resolveMember } from '../lib/resolvers/member'; 5 | import { Argument } from '../lib/structures/Argument'; 6 | import type { MemberArgumentContext } from '../lib/types/ArgumentContexts'; 7 | 8 | export class CoreArgument extends Argument { 9 | public constructor(context: Argument.LoaderContext) { 10 | super(context, { name: 'member' }); 11 | } 12 | 13 | public async run(parameter: string, context: MemberArgumentContext): Argument.AsyncResult { 14 | const { guild } = context.message; 15 | 16 | if (!guild) { 17 | return this.error({ 18 | parameter, 19 | identifier: Identifiers.ArgumentMemberMissingGuild, 20 | message: 'This command can only be used in a server.', 21 | context 22 | }); 23 | } 24 | 25 | const resolved = await resolveMember(parameter, guild, context.performFuzzySearch ?? true); 26 | return resolved.mapErrInto((identifier) => 27 | this.error({ 28 | parameter, 29 | identifier, 30 | message: 'The given argument did not resolve to a server member.', 31 | context: { ...context, guild } 32 | }) 33 | ); 34 | } 35 | } 36 | 37 | void container.stores.loadPiece({ 38 | name: 'member', 39 | piece: CoreArgument, 40 | store: 'arguments' 41 | }); 42 | -------------------------------------------------------------------------------- /src/arguments/CoreMessage.ts: -------------------------------------------------------------------------------- 1 | import { container } from '@sapphire/pieces'; 2 | import type { Message } from 'discord.js'; 3 | import { resolveMessage } from '../lib/resolvers/message'; 4 | import { Argument } from '../lib/structures/Argument'; 5 | import type { MessageArgumentContext } from '../lib/types/ArgumentContexts'; 6 | 7 | export class CoreArgument extends Argument { 8 | public constructor(context: Argument.LoaderContext) { 9 | super(context, { name: 'message' }); 10 | } 11 | 12 | public async run(parameter: string, context: MessageArgumentContext): Argument.AsyncResult { 13 | const channel = context.channel ?? context.message.channel; 14 | const resolved = await resolveMessage(parameter, { 15 | messageOrInteraction: context.message, 16 | channel: context.channel, 17 | scan: context.scan ?? false 18 | }); 19 | 20 | return resolved.mapErrInto((identifier) => 21 | this.error({ 22 | parameter, 23 | identifier, 24 | message: 'The given argument did not resolve to a message.', 25 | context: { ...context, channel } 26 | }) 27 | ); 28 | } 29 | } 30 | 31 | void container.stores.loadPiece({ 32 | name: 'message', 33 | piece: CoreArgument, 34 | store: 'arguments' 35 | }); 36 | -------------------------------------------------------------------------------- /src/arguments/CoreNumber.ts: -------------------------------------------------------------------------------- 1 | import { container } from '@sapphire/pieces'; 2 | import { Identifiers } from '../lib/errors/Identifiers'; 3 | import { resolveNumber } from '../lib/resolvers/number'; 4 | import { Argument } from '../lib/structures/Argument'; 5 | 6 | export class CoreArgument extends Argument { 7 | private readonly messages = { 8 | [Identifiers.ArgumentNumberTooSmall]: ({ minimum }: Argument.Context) => `The given number must be greater than ${minimum}.`, 9 | [Identifiers.ArgumentNumberTooLarge]: ({ maximum }: Argument.Context) => `The given number must be less than ${maximum}.`, 10 | [Identifiers.ArgumentNumberError]: () => 'The argument did not resolve to a valid number.' 11 | } as const; 12 | 13 | public constructor(context: Argument.LoaderContext) { 14 | super(context, { name: 'number' }); 15 | } 16 | 17 | public run(parameter: string, context: Argument.Context): Argument.Result { 18 | const resolved = resolveNumber(parameter, { minimum: context.minimum, maximum: context.maximum }); 19 | return resolved.mapErrInto((identifier) => 20 | this.error({ 21 | parameter, 22 | identifier, 23 | message: this.messages[identifier](context), 24 | context 25 | }) 26 | ); 27 | } 28 | } 29 | 30 | void container.stores.loadPiece({ 31 | name: 'number', 32 | piece: CoreArgument, 33 | store: 'arguments' 34 | }); 35 | -------------------------------------------------------------------------------- /src/arguments/CorePartialDMChannel.ts: -------------------------------------------------------------------------------- 1 | import { container } from '@sapphire/pieces'; 2 | import type { DMChannel, PartialDMChannel } from 'discord.js'; 3 | import { resolvePartialDMChannel } from '../lib/resolvers/partialDMChannel'; 4 | import { Argument } from '../lib/structures/Argument'; 5 | 6 | export class CoreArgument extends Argument { 7 | public constructor(context: Argument.LoaderContext) { 8 | super(context, { name: 'partialDMChannel' }); 9 | } 10 | 11 | public run(parameter: string, context: Argument.Context): Argument.Result { 12 | const resolved = resolvePartialDMChannel(parameter, context.message); 13 | return resolved.mapErrInto((identifier) => 14 | this.error({ 15 | parameter, 16 | identifier, 17 | message: 'The argument did not resolve to a Partial DM channel.', 18 | context 19 | }) 20 | ); 21 | } 22 | } 23 | 24 | void container.stores.loadPiece({ 25 | name: 'partialDMChannel', 26 | piece: CoreArgument, 27 | store: 'arguments' 28 | }); 29 | -------------------------------------------------------------------------------- /src/arguments/CoreRole.ts: -------------------------------------------------------------------------------- 1 | import { container } from '@sapphire/pieces'; 2 | import type { Role } from 'discord.js'; 3 | import { Identifiers } from '../lib/errors/Identifiers'; 4 | import { resolveRole } from '../lib/resolvers/role'; 5 | import { Argument } from '../lib/structures/Argument'; 6 | 7 | export class CoreArgument extends Argument { 8 | public constructor(context: Argument.LoaderContext) { 9 | super(context, { name: 'role' }); 10 | } 11 | 12 | public async run(parameter: string, context: Argument.Context): Argument.AsyncResult { 13 | const { guild } = context.message; 14 | if (!guild) { 15 | return this.error({ 16 | parameter, 17 | identifier: Identifiers.ArgumentRoleMissingGuild, 18 | message: 'This command can only be used in a server.', 19 | context 20 | }); 21 | } 22 | 23 | const resolved = await resolveRole(parameter, guild); 24 | return resolved.mapErrInto((identifier) => 25 | this.error({ 26 | parameter, 27 | identifier, 28 | message: 'The given argument did not resolve to a role.', 29 | context: { ...context, guild } 30 | }) 31 | ); 32 | } 33 | } 34 | 35 | void container.stores.loadPiece({ 36 | name: 'role', 37 | piece: CoreArgument, 38 | store: 'arguments' 39 | }); 40 | -------------------------------------------------------------------------------- /src/arguments/CoreString.ts: -------------------------------------------------------------------------------- 1 | import { container } from '@sapphire/pieces'; 2 | import { Identifiers } from '../lib/errors/Identifiers'; 3 | import { resolveString } from '../lib/resolvers/string'; 4 | import { Argument } from '../lib/structures/Argument'; 5 | 6 | export class CoreArgument extends Argument { 7 | private readonly messages = { 8 | [Identifiers.ArgumentStringTooShort]: ({ minimum }: Argument.Context) => `The argument must be longer than ${minimum} characters.`, 9 | [Identifiers.ArgumentStringTooLong]: ({ maximum }: Argument.Context) => `The argument must be shorter than ${maximum} characters.` 10 | } as const; 11 | 12 | public constructor(context: Argument.LoaderContext) { 13 | super(context, { name: 'string' }); 14 | } 15 | 16 | public run(parameter: string, context: Argument.Context): Argument.Result { 17 | const resolved = resolveString(parameter, { minimum: context?.minimum, maximum: context?.maximum }); 18 | return resolved.mapErrInto((identifier) => 19 | this.error({ 20 | parameter, 21 | identifier, 22 | message: this.messages[identifier](context), 23 | context 24 | }) 25 | ); 26 | } 27 | } 28 | 29 | void container.stores.loadPiece({ 30 | name: 'string', 31 | piece: CoreArgument, 32 | store: 'arguments' 33 | }); 34 | -------------------------------------------------------------------------------- /src/arguments/CoreUser.ts: -------------------------------------------------------------------------------- 1 | import { container } from '@sapphire/pieces'; 2 | import type { User } from 'discord.js'; 3 | import { resolveUser } from '../lib/resolvers/user'; 4 | import { Argument } from '../lib/structures/Argument'; 5 | 6 | export class CoreArgument extends Argument { 7 | public constructor(context: Argument.LoaderContext) { 8 | super(context, { name: 'user' }); 9 | } 10 | 11 | public async run(parameter: string, context: Argument.Context): Argument.AsyncResult { 12 | const resolved = await resolveUser(parameter); 13 | return resolved.mapErrInto((identifier) => 14 | this.error({ 15 | parameter, 16 | identifier, 17 | message: 'The given argument did not resolve to a Discord user.', 18 | context 19 | }) 20 | ); 21 | } 22 | } 23 | 24 | void container.stores.loadPiece({ 25 | name: 'user', 26 | piece: CoreArgument, 27 | store: 'arguments' 28 | }); 29 | -------------------------------------------------------------------------------- /src/arguments/_load.ts: -------------------------------------------------------------------------------- 1 | import './CoreBoolean'; 2 | import './CoreChannel'; 3 | import './CoreDate'; 4 | import './CoreDMChannel'; 5 | import './CoreEmoji'; 6 | import './CoreEnum'; 7 | import './CoreFloat'; 8 | import './CoreGuild'; 9 | import './CoreGuildCategoryChannel'; 10 | import './CoreGuildChannel'; 11 | import './CoreGuildNewsChannel'; 12 | import './CoreGuildNewsThreadChannel'; 13 | import './CoreGuildPrivateThreadChannel'; 14 | import './CoreGuildPublicThreadChannel'; 15 | import './CoreGuildStageVoiceChannel'; 16 | import './CoreGuildTextChannel'; 17 | import './CoreGuildThreadChannel'; 18 | import './CoreGuildVoiceChannel'; 19 | import './CoreHyperlink'; 20 | import './CoreInteger'; 21 | import './CoreMember'; 22 | import './CoreMessage'; 23 | import './CoreNumber'; 24 | import './CorePartialDMChannel'; 25 | import './CoreRole'; 26 | import './CoreString'; 27 | import './CoreUser'; 28 | -------------------------------------------------------------------------------- /src/lib/errors/ArgumentError.ts: -------------------------------------------------------------------------------- 1 | import type { IArgument } from '../structures/Argument'; 2 | import { UserError } from './UserError'; 3 | 4 | /** 5 | * Errors thrown by the argument parser 6 | * @since 1.0.0 7 | * @property name This will be `'ArgumentError'` and can be used to distinguish the type of error when any error gets thrown 8 | */ 9 | export class ArgumentError extends UserError { 10 | public readonly argument: IArgument; 11 | public readonly parameter: string; 12 | 13 | public constructor(options: ArgumentError.Options) { 14 | super({ ...options, identifier: options.identifier ?? options.argument.name }); 15 | this.argument = options.argument; 16 | this.parameter = options.parameter; 17 | } 18 | 19 | // eslint-disable-next-line @typescript-eslint/class-literal-property-style 20 | public override get name(): string { 21 | return 'ArgumentError'; 22 | } 23 | } 24 | 25 | export namespace ArgumentError { 26 | /** 27 | * The options for {@link ArgumentError}. 28 | * @since 1.0.0 29 | */ 30 | export interface Options extends Omit { 31 | /** 32 | * The argument that caused the error. 33 | * @since 1.0.0 34 | */ 35 | argument: IArgument; 36 | 37 | /** 38 | * The parameter that failed to be parsed. 39 | * @since 1.0.0 40 | */ 41 | parameter: string; 42 | 43 | /** 44 | * The identifier. 45 | * @since 1.0.0 46 | * @default argument.name 47 | */ 48 | identifier?: string; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/lib/errors/PreconditionError.ts: -------------------------------------------------------------------------------- 1 | import type { Precondition } from '../structures/Precondition'; 2 | import { UserError } from './UserError'; 3 | 4 | /** 5 | * Errors thrown by preconditions 6 | * @property name This will be `'PreconditionError'` and can be used to distinguish the type of error when any error gets thrown 7 | */ 8 | export class PreconditionError extends UserError { 9 | public readonly precondition: Precondition; 10 | 11 | public constructor(options: PreconditionError.Options) { 12 | super({ ...options, identifier: options.identifier ?? options.precondition.name }); 13 | this.precondition = options.precondition; 14 | } 15 | 16 | // eslint-disable-next-line @typescript-eslint/class-literal-property-style 17 | public override get name(): string { 18 | return 'PreconditionError'; 19 | } 20 | } 21 | 22 | export namespace PreconditionError { 23 | /** 24 | * The options for {@link PreconditionError}. 25 | * @since 1.0.0 26 | */ 27 | export interface Options extends Omit { 28 | /** 29 | * The precondition that caused the error. 30 | * @since 1.0.0 31 | */ 32 | precondition: Precondition; 33 | 34 | /** 35 | * The identifier. 36 | * @since 1.0.0 37 | * @default precondition.name 38 | */ 39 | identifier?: string; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/lib/errors/UserError.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * The UserError class to be emitted in the pieces. 3 | * @property name This will be `'UserError'` and can be used to distinguish the type of error when any error gets thrown 4 | * @example 5 | * ```typescript 6 | * throw new UserError({ 7 | * identifier: 'AddArgumentError', 8 | * message: 'You must write two numbers, but the second one did not match.', 9 | * context: { received: 2, expected: 3 } 10 | * }); 11 | * ``` 12 | */ 13 | export class UserError extends Error { 14 | /** 15 | * An identifier, useful to localize emitted errors. 16 | */ 17 | public readonly identifier: string; 18 | 19 | /** 20 | * User-provided context. 21 | */ 22 | public readonly context: unknown; 23 | 24 | /** 25 | * Constructs an UserError. 26 | * @param options The UserError options 27 | */ 28 | public constructor(options: UserError.Options) { 29 | super(options.message); 30 | this.identifier = options.identifier; 31 | this.context = options.context ?? null; 32 | } 33 | 34 | // eslint-disable-next-line @typescript-eslint/class-literal-property-style 35 | public override get name(): string { 36 | return 'UserError'; 37 | } 38 | } 39 | 40 | export namespace UserError { 41 | /** 42 | * The options for {@link UserError}. 43 | * @since 1.0.0 44 | */ 45 | export interface Options { 46 | /** 47 | * The identifier for this error. 48 | * @since 1.0.0 49 | */ 50 | identifier: string; 51 | 52 | /** 53 | * The message to be passed to the Error constructor. 54 | * @since 1.0.0 55 | */ 56 | message?: string; 57 | 58 | /** 59 | * The extra context to provide more information about this error. 60 | * @since 1.0.0 61 | * @default null 62 | */ 63 | context?: unknown; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/lib/plugins/Plugin.ts: -------------------------------------------------------------------------------- 1 | import type { Awaitable } from '@sapphire/utilities'; 2 | import type { ClientOptions } from 'discord.js'; 3 | import type { SapphireClient } from '../SapphireClient'; 4 | import { postInitialization, postLogin, preGenericsInitialization, preInitialization, preLogin } from './symbols'; 5 | 6 | // eslint-disable-next-line @typescript-eslint/no-extraneous-class 7 | export abstract class Plugin { 8 | public static [preGenericsInitialization]?: (this: SapphireClient, options: ClientOptions) => void; 9 | public static [preInitialization]?: (this: SapphireClient, options: ClientOptions) => void; 10 | public static [postInitialization]?: (this: SapphireClient, options: ClientOptions) => void; 11 | public static [preLogin]?: (this: SapphireClient, options: ClientOptions) => Awaitable; 12 | public static [postLogin]?: (this: SapphireClient, options: ClientOptions) => Awaitable; 13 | } 14 | -------------------------------------------------------------------------------- /src/lib/plugins/symbols.ts: -------------------------------------------------------------------------------- 1 | export const preGenericsInitialization: unique symbol = Symbol('SapphireFrameworkPluginsPreGenericsInitialization'); 2 | export const preInitialization: unique symbol = Symbol('SapphireFrameworkPluginsPreInitialization'); 3 | export const postInitialization: unique symbol = Symbol('SapphireFrameworkPluginsPostInitialization'); 4 | 5 | export const preLogin: unique symbol = Symbol('SapphireFrameworkPluginsPreLogin'); 6 | export const postLogin: unique symbol = Symbol('SapphireFrameworkPluginsPostLogin'); 7 | -------------------------------------------------------------------------------- /src/lib/precondition-resolvers/clientPermissions.ts: -------------------------------------------------------------------------------- 1 | import { PermissionsBitField, type PermissionResolvable } from 'discord.js'; 2 | import { CommandPreConditions } from '../types/Enums'; 3 | import type { PreconditionContainerArray } from '../utils/preconditions/PreconditionContainerArray'; 4 | 5 | /** 6 | * Appends the `ClientPermissions` precondition when {@link Command.Options.requiredClientPermissions} resolves to a 7 | * non-zero bitfield. 8 | * @param requiredClientPermissions The required client permissions. 9 | * @param preconditionContainerArray The precondition container array to append the precondition to. 10 | */ 11 | export function parseConstructorPreConditionsRequiredClientPermissions( 12 | requiredClientPermissions: PermissionResolvable | undefined, 13 | preconditionContainerArray: PreconditionContainerArray 14 | ) { 15 | const permissions = new PermissionsBitField(requiredClientPermissions); 16 | if (permissions.bitfield !== 0n) { 17 | preconditionContainerArray.append({ name: CommandPreConditions.ClientPermissions, context: { permissions } }); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/lib/precondition-resolvers/cooldown.ts: -------------------------------------------------------------------------------- 1 | import { container } from '@sapphire/pieces'; 2 | import type { Command } from '../structures/Command'; 3 | import { BucketScope, CommandPreConditions } from '../types/Enums'; 4 | import { type PreconditionContainerArray } from '../utils/preconditions/PreconditionContainerArray'; 5 | 6 | /** 7 | * Appends the `Cooldown` precondition when {@link Command.Options.cooldownLimit} and 8 | * {@link Command.Options.cooldownDelay} are both non-zero. 9 | * 10 | * @param command The command to parse cooldowns for. 11 | * @param cooldownLimit The cooldown limit to use. 12 | * @param cooldownDelay The cooldown delay to use. 13 | * @param cooldownScope The cooldown scope to use. 14 | * @param cooldownFilteredUsers The cooldown filtered users to use. 15 | * @param preconditionContainerArray The precondition container array to append the precondition to. 16 | */ 17 | export function parseConstructorPreConditionsCooldown( 18 | command: Command, 19 | cooldownLimit: number | undefined, 20 | cooldownDelay: number | undefined, 21 | cooldownScope: BucketScope | undefined, 22 | cooldownFilteredUsers: string[] | undefined, 23 | preconditionContainerArray: PreconditionContainerArray 24 | ) { 25 | const { defaultCooldown } = container.client.options; 26 | 27 | // We will check for whether the command is filtered from the defaults, but we will allow overridden values to 28 | // be set. If an overridden value is passed, it will have priority. Otherwise, it will default to 0 if filtered 29 | // (causing the precondition to not be registered) or the default value with a fallback to a single-use cooldown. 30 | const filtered = defaultCooldown?.filteredCommands?.includes(command.name) ?? false; 31 | const limit = cooldownLimit ?? (filtered ? 0 : (defaultCooldown?.limit ?? 1)); 32 | const delay = cooldownDelay ?? (filtered ? 0 : (defaultCooldown?.delay ?? 0)); 33 | 34 | if (limit && delay) { 35 | const scope = cooldownScope ?? defaultCooldown?.scope ?? BucketScope.User; 36 | const filteredUsers = cooldownFilteredUsers ?? defaultCooldown?.filteredUsers; 37 | preconditionContainerArray.append({ 38 | name: CommandPreConditions.Cooldown, 39 | context: { scope, limit, delay, filteredUsers } 40 | }); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/lib/precondition-resolvers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './clientPermissions'; 2 | export * from './cooldown'; 3 | export * from './nsfw'; 4 | export * from './runIn'; 5 | export * from './userPermissions'; 6 | -------------------------------------------------------------------------------- /src/lib/precondition-resolvers/nsfw.ts: -------------------------------------------------------------------------------- 1 | import { CommandPreConditions } from '../types/Enums'; 2 | import type { PreconditionContainerArray } from '../utils/preconditions/PreconditionContainerArray'; 3 | 4 | /** 5 | * Appends the `NSFW` precondition if {@link SubcommandMappingMethod.nsfw} is set to true. 6 | * @param nsfw Whether this command is NSFW or not. 7 | * @param preconditionContainerArray The precondition container array to append the precondition to. 8 | */ 9 | export function parseConstructorPreConditionsNsfw(nsfw: boolean | undefined, preconditionContainerArray: PreconditionContainerArray) { 10 | if (nsfw) preconditionContainerArray.append(CommandPreConditions.NotSafeForWork); 11 | } 12 | -------------------------------------------------------------------------------- /src/lib/precondition-resolvers/runIn.ts: -------------------------------------------------------------------------------- 1 | import { isNullish } from '@sapphire/utilities'; 2 | import type { ChannelType } from 'discord.js'; 3 | import { Command } from '../structures/Command'; 4 | import type { CommandRunInUnion, CommandSpecificRunIn } from '../types/CommandTypes'; 5 | import { CommandPreConditions } from '../types/Enums'; 6 | import type { PreconditionContainerArray } from '../utils/preconditions/PreconditionContainerArray'; 7 | 8 | /** 9 | * Appends the `RunIn` precondition based on the values passed, defaulting to `null`, which doesn't add a 10 | * precondition. 11 | * @param runIn The command's `runIn` option field from the constructor. 12 | * @param resolveConstructorPreConditionsRunType The function to resolve the run type from the constructor. 13 | * @param preconditionContainerArray The precondition container array to append the precondition to. 14 | */ 15 | export function parseConstructorPreConditionsRunIn( 16 | runIn: CommandRunInUnion | CommandSpecificRunIn, 17 | resolveConstructorPreConditionsRunType: (types: CommandRunInUnion) => readonly ChannelType[] | null, 18 | preconditionContainerArray: PreconditionContainerArray 19 | ) { 20 | // Early return if there's no runIn option: 21 | if (isNullish(runIn)) return; 22 | 23 | if (Command.runInTypeIsSpecificsObject(runIn)) { 24 | const messageRunTypes = resolveConstructorPreConditionsRunType(runIn.messageRun); 25 | const chatInputRunTypes = resolveConstructorPreConditionsRunType(runIn.chatInputRun); 26 | const contextMenuRunTypes = resolveConstructorPreConditionsRunType(runIn.contextMenuRun); 27 | 28 | if (messageRunTypes !== null || chatInputRunTypes !== null || contextMenuRunTypes !== null) { 29 | preconditionContainerArray.append({ 30 | name: CommandPreConditions.RunIn, 31 | context: { 32 | types: { 33 | messageRun: messageRunTypes ?? [], 34 | chatInputRun: chatInputRunTypes ?? [], 35 | contextMenuRun: contextMenuRunTypes ?? [] 36 | } 37 | } 38 | }); 39 | } 40 | } else { 41 | const types = resolveConstructorPreConditionsRunType(runIn); 42 | if (types !== null) { 43 | preconditionContainerArray.append({ name: CommandPreConditions.RunIn, context: { types } }); 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/lib/precondition-resolvers/userPermissions.ts: -------------------------------------------------------------------------------- 1 | import { PermissionsBitField, type PermissionResolvable } from 'discord.js'; 2 | import { CommandPreConditions } from '../types/Enums'; 3 | import type { PreconditionContainerArray } from '../utils/preconditions/PreconditionContainerArray'; 4 | 5 | /** 6 | * Appends the `UserPermissions` precondition when {@link Command.Options.requiredUserPermissions} resolves to a 7 | * non-zero bitfield. 8 | * @param requiredUserPermissions The required user permissions. 9 | * @param preconditionContainerArray The precondition container array to append the precondition to. 10 | */ 11 | export function parseConstructorPreConditionsRequiredUserPermissions( 12 | requiredUserPermissions: PermissionResolvable | undefined, 13 | preconditionContainerArray: PreconditionContainerArray 14 | ) { 15 | const permissions = new PermissionsBitField(requiredUserPermissions); 16 | if (permissions.bitfield !== 0n) { 17 | preconditionContainerArray.append({ name: CommandPreConditions.UserPermissions, context: { permissions } }); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/lib/resolvers/boolean.ts: -------------------------------------------------------------------------------- 1 | import { Result } from '@sapphire/result'; 2 | import { Identifiers } from '../errors/Identifiers'; 3 | 4 | const baseTruths = ['1', 'true', '+', 't', 'yes', 'y'] as const; 5 | const baseFalses = ['0', 'false', '-', 'f', 'no', 'n'] as const; 6 | 7 | export function resolveBoolean( 8 | parameter: string, 9 | customs?: { truths?: readonly string[]; falses?: readonly string[] } 10 | ): Result { 11 | const boolean = parameter.toLowerCase(); 12 | 13 | if ([...baseTruths, ...(customs?.truths ?? [])].includes(boolean)) { 14 | return Result.ok(true); 15 | } 16 | 17 | if ([...baseFalses, ...(customs?.falses ?? [])].includes(boolean)) { 18 | return Result.ok(false); 19 | } 20 | 21 | return Result.err(Identifiers.ArgumentBooleanError); 22 | } 23 | -------------------------------------------------------------------------------- /src/lib/resolvers/channel.ts: -------------------------------------------------------------------------------- 1 | import { ChannelMentionRegex, type ChannelTypes } from '@sapphire/discord.js-utilities'; 2 | import { container } from '@sapphire/pieces'; 3 | import { Result } from '@sapphire/result'; 4 | import type { CommandInteraction, Message, Snowflake } from 'discord.js'; 5 | import { Identifiers } from '../errors/Identifiers'; 6 | 7 | export function resolveChannel( 8 | parameter: string, 9 | messageOrInteraction: Message | CommandInteraction 10 | ): Result { 11 | const channelId = (ChannelMentionRegex.exec(parameter)?.[1] ?? parameter) as Snowflake; 12 | const channel = (messageOrInteraction.guild ? messageOrInteraction.guild.channels : container.client.channels).cache.get(channelId); 13 | 14 | if (channel) { 15 | return Result.ok(channel as ChannelTypes); 16 | } 17 | 18 | return Result.err(Identifiers.ArgumentChannelError); 19 | } 20 | -------------------------------------------------------------------------------- /src/lib/resolvers/date.ts: -------------------------------------------------------------------------------- 1 | import { Result } from '@sapphire/result'; 2 | import { Identifiers } from '../errors/Identifiers'; 3 | 4 | export function resolveDate( 5 | parameter: string, 6 | options?: { minimum?: number; maximum?: number } 7 | ): Result { 8 | const parsed = new Date(parameter); 9 | 10 | const time = parsed.getTime(); 11 | 12 | if (Number.isNaN(time)) { 13 | return Result.err(Identifiers.ArgumentDateError); 14 | } 15 | 16 | if (typeof options?.minimum === 'number' && time < options.minimum) { 17 | return Result.err(Identifiers.ArgumentDateTooEarly); 18 | } 19 | 20 | if (typeof options?.maximum === 'number' && time > options.maximum) { 21 | return Result.err(Identifiers.ArgumentDateTooFar); 22 | } 23 | 24 | return Result.ok(parsed); 25 | } 26 | -------------------------------------------------------------------------------- /src/lib/resolvers/dmChannel.ts: -------------------------------------------------------------------------------- 1 | import { isDMChannel } from '@sapphire/discord.js-utilities'; 2 | import { Result } from '@sapphire/result'; 3 | import type { CommandInteraction, DMChannel, Message } from 'discord.js'; 4 | import { Identifiers } from '../errors/Identifiers'; 5 | import { resolveChannel } from './channel'; 6 | 7 | export function resolveDMChannel( 8 | parameter: string, 9 | messageOrInteraction: Message | CommandInteraction 10 | ): Result { 11 | const result = resolveChannel(parameter, messageOrInteraction); 12 | return result.mapInto((value) => { 13 | if (isDMChannel(value) && !value.partial) { 14 | return Result.ok(value); 15 | } 16 | 17 | return Result.err(Identifiers.ArgumentDMChannelError); 18 | }); 19 | } 20 | -------------------------------------------------------------------------------- /src/lib/resolvers/emoji.ts: -------------------------------------------------------------------------------- 1 | import { EmojiRegex, createTwemojiRegex } from '@sapphire/discord-utilities'; 2 | import { Result } from '@sapphire/result'; 3 | import { parseEmoji } from 'discord.js'; 4 | import { Identifiers } from '../errors/Identifiers'; 5 | 6 | const TwemojiRegex = createTwemojiRegex(); 7 | 8 | export function resolveEmoji(parameter: string): Result { 9 | const twemoji = TwemojiRegex.exec(parameter)?.[0] ?? null; 10 | 11 | TwemojiRegex.lastIndex = 0; 12 | 13 | if (twemoji) { 14 | return Result.ok({ 15 | name: twemoji, 16 | id: null 17 | }); 18 | } 19 | 20 | const emojiId = EmojiRegex.test(parameter); 21 | 22 | if (emojiId) { 23 | const resolved = parseEmoji(parameter) as EmojiObject | null; 24 | 25 | if (resolved) { 26 | return Result.ok(resolved); 27 | } 28 | } 29 | 30 | return Result.err(Identifiers.ArgumentEmojiError); 31 | } 32 | 33 | export interface EmojiObject { 34 | name: string | null; 35 | id: string | null; 36 | animated?: boolean; 37 | } 38 | -------------------------------------------------------------------------------- /src/lib/resolvers/enum.ts: -------------------------------------------------------------------------------- 1 | import { Result } from '@sapphire/result'; 2 | import { Identifiers } from '../errors/Identifiers'; 3 | 4 | export function resolveEnum( 5 | parameter: string, 6 | options?: { enum?: string[]; caseInsensitive?: boolean } 7 | ): Result { 8 | if (!options?.enum?.length) { 9 | return Result.err(Identifiers.ArgumentEnumEmptyError); 10 | } 11 | 12 | if (!options.caseInsensitive && !options.enum.includes(parameter)) { 13 | return Result.err(Identifiers.ArgumentEnumError); 14 | } 15 | 16 | if (options.caseInsensitive && !options.enum.some((v) => v.toLowerCase() === parameter.toLowerCase())) { 17 | return Result.err(Identifiers.ArgumentEnumError); 18 | } 19 | 20 | return Result.ok(parameter); 21 | } 22 | -------------------------------------------------------------------------------- /src/lib/resolvers/float.ts: -------------------------------------------------------------------------------- 1 | import { Result } from '@sapphire/result'; 2 | import { Identifiers } from '../errors/Identifiers'; 3 | 4 | export function resolveFloat( 5 | parameter: string, 6 | options?: { minimum?: number; maximum?: number } 7 | ): Result { 8 | const parsed = Number(parameter); 9 | 10 | if (Number.isNaN(parsed)) { 11 | return Result.err(Identifiers.ArgumentFloatError); 12 | } 13 | 14 | if (typeof options?.minimum === 'number' && parsed < options.minimum) { 15 | return Result.err(Identifiers.ArgumentFloatTooSmall); 16 | } 17 | 18 | if (typeof options?.maximum === 'number' && parsed > options.maximum) { 19 | return Result.err(Identifiers.ArgumentFloatTooLarge); 20 | } 21 | 22 | return Result.ok(parsed); 23 | } 24 | -------------------------------------------------------------------------------- /src/lib/resolvers/guild.ts: -------------------------------------------------------------------------------- 1 | import { SnowflakeRegex } from '@sapphire/discord-utilities'; 2 | import { container } from '@sapphire/pieces'; 3 | import { Result } from '@sapphire/result'; 4 | import type { Guild } from 'discord.js'; 5 | import { Identifiers } from '../errors/Identifiers'; 6 | 7 | export async function resolveGuild(parameter: string): Promise> { 8 | const guildId = SnowflakeRegex.exec(parameter)?.groups?.id; 9 | const guild = guildId ? await container.client.guilds.fetch(guildId).catch(() => null) : null; 10 | 11 | if (guild) { 12 | return Result.ok(guild); 13 | } 14 | 15 | return Result.err(Identifiers.ArgumentGuildError); 16 | } 17 | -------------------------------------------------------------------------------- /src/lib/resolvers/guildCategoryChannel.ts: -------------------------------------------------------------------------------- 1 | import { isCategoryChannel } from '@sapphire/discord.js-utilities'; 2 | import type { Result } from '@sapphire/result'; 3 | import type { CategoryChannel, Guild } from 'discord.js'; 4 | import { Identifiers } from '../errors/Identifiers'; 5 | import { resolveGuildChannelPredicate } from '../utils/resolvers/resolveGuildChannelPredicate'; 6 | 7 | export function resolveGuildCategoryChannel( 8 | parameter: string, 9 | guild: Guild 10 | ): Result { 11 | return resolveGuildChannelPredicate(parameter, guild, isCategoryChannel, Identifiers.ArgumentGuildCategoryChannelError); 12 | } 13 | -------------------------------------------------------------------------------- /src/lib/resolvers/guildChannel.ts: -------------------------------------------------------------------------------- 1 | import { ChannelMentionRegex, SnowflakeRegex } from '@sapphire/discord-utilities'; 2 | import type { GuildBasedChannelTypes } from '@sapphire/discord.js-utilities'; 3 | import { Result } from '@sapphire/result'; 4 | import type { Guild, Snowflake } from 'discord.js'; 5 | import { Identifiers } from '../errors/Identifiers'; 6 | 7 | export function resolveGuildChannel(parameter: string, guild: Guild): Result { 8 | const channel = resolveById(parameter, guild) ?? resolveByQuery(parameter, guild); 9 | 10 | if (channel) { 11 | return Result.ok(channel); 12 | } 13 | 14 | return Result.err(Identifiers.ArgumentGuildChannelError); 15 | } 16 | 17 | function resolveById(argument: string, guild: Guild): GuildBasedChannelTypes | null { 18 | const channelId = ChannelMentionRegex.exec(argument) ?? SnowflakeRegex.exec(argument); 19 | return channelId ? ((guild.channels.cache.get(channelId[1] as Snowflake) as GuildBasedChannelTypes) ?? null) : null; 20 | } 21 | 22 | function resolveByQuery(argument: string, guild: Guild): GuildBasedChannelTypes | null { 23 | const lowerCaseArgument = argument.toLowerCase(); 24 | return (guild.channels.cache.find((channel) => channel.name.toLowerCase() === lowerCaseArgument) as GuildBasedChannelTypes) ?? null; 25 | } 26 | -------------------------------------------------------------------------------- /src/lib/resolvers/guildNewsChannel.ts: -------------------------------------------------------------------------------- 1 | import { isNewsChannel } from '@sapphire/discord.js-utilities'; 2 | import type { Result } from '@sapphire/result'; 3 | import type { Guild, NewsChannel } from 'discord.js'; 4 | import { Identifiers } from '../errors/Identifiers'; 5 | import { resolveGuildChannelPredicate } from '../utils/resolvers/resolveGuildChannelPredicate'; 6 | 7 | export function resolveGuildNewsChannel( 8 | parameter: string, 9 | guild: Guild 10 | ): Result { 11 | return resolveGuildChannelPredicate(parameter, guild, isNewsChannel, Identifiers.ArgumentGuildNewsChannelError); 12 | } 13 | -------------------------------------------------------------------------------- /src/lib/resolvers/guildNewsThreadChannel.ts: -------------------------------------------------------------------------------- 1 | import { isNewsThreadChannel } from '@sapphire/discord.js-utilities'; 2 | import type { Result } from '@sapphire/result'; 3 | import type { Guild, ThreadChannel } from 'discord.js'; 4 | import { Identifiers } from '../errors/Identifiers'; 5 | import { resolveGuildChannelPredicate } from '../utils/resolvers/resolveGuildChannelPredicate'; 6 | 7 | export function resolveGuildNewsThreadChannel( 8 | parameter: string, 9 | guild: Guild 10 | ): Result< 11 | ThreadChannel, 12 | Identifiers.ArgumentGuildChannelError | Identifiers.ArgumentGuildThreadChannelError | Identifiers.ArgumentGuildNewsThreadChannelError 13 | > { 14 | return resolveGuildChannelPredicate(parameter, guild, isNewsThreadChannel, Identifiers.ArgumentGuildNewsThreadChannelError); 15 | } 16 | -------------------------------------------------------------------------------- /src/lib/resolvers/guildPrivateThreadChannel.ts: -------------------------------------------------------------------------------- 1 | import { isPrivateThreadChannel } from '@sapphire/discord.js-utilities'; 2 | import type { Result } from '@sapphire/result'; 3 | import type { Guild, ThreadChannel } from 'discord.js'; 4 | import { Identifiers } from '../errors/Identifiers'; 5 | import { resolveGuildChannelPredicate } from '../utils/resolvers/resolveGuildChannelPredicate'; 6 | 7 | export function resolveGuildPrivateThreadChannel( 8 | parameter: string, 9 | guild: Guild 10 | ): Result< 11 | ThreadChannel, 12 | Identifiers.ArgumentGuildChannelError | Identifiers.ArgumentGuildThreadChannelError | Identifiers.ArgumentGuildPrivateThreadChannelError 13 | > { 14 | return resolveGuildChannelPredicate(parameter, guild, isPrivateThreadChannel, Identifiers.ArgumentGuildPrivateThreadChannelError); 15 | } 16 | -------------------------------------------------------------------------------- /src/lib/resolvers/guildPublicThreadChannel.ts: -------------------------------------------------------------------------------- 1 | import { isPublicThreadChannel } from '@sapphire/discord.js-utilities'; 2 | import type { Result } from '@sapphire/result'; 3 | import type { Guild, ThreadChannel } from 'discord.js'; 4 | import { Identifiers } from '../errors/Identifiers'; 5 | import { resolveGuildChannelPredicate } from '../utils/resolvers/resolveGuildChannelPredicate'; 6 | 7 | export function resolveGuildPublicThreadChannel( 8 | parameter: string, 9 | guild: Guild 10 | ): Result< 11 | ThreadChannel, 12 | Identifiers.ArgumentGuildChannelError | Identifiers.ArgumentGuildThreadChannelError | Identifiers.ArgumentGuildPublicThreadChannelError 13 | > { 14 | return resolveGuildChannelPredicate(parameter, guild, isPublicThreadChannel, Identifiers.ArgumentGuildPublicThreadChannelError); 15 | } 16 | -------------------------------------------------------------------------------- /src/lib/resolvers/guildStageVoiceChannel.ts: -------------------------------------------------------------------------------- 1 | import { isStageChannel } from '@sapphire/discord.js-utilities'; 2 | import type { Result } from '@sapphire/result'; 3 | import type { Guild, StageChannel } from 'discord.js'; 4 | import { Identifiers } from '../errors/Identifiers'; 5 | import { resolveGuildChannelPredicate } from '../utils/resolvers/resolveGuildChannelPredicate'; 6 | 7 | export function resolveGuildStageVoiceChannel( 8 | parameter: string, 9 | guild: Guild 10 | ): Result { 11 | return resolveGuildChannelPredicate(parameter, guild, isStageChannel, Identifiers.ArgumentGuildStageVoiceChannelError); 12 | } 13 | -------------------------------------------------------------------------------- /src/lib/resolvers/guildTextChannel.ts: -------------------------------------------------------------------------------- 1 | import { isTextChannel } from '@sapphire/discord.js-utilities'; 2 | import type { Result } from '@sapphire/result'; 3 | import type { Guild, TextChannel } from 'discord.js'; 4 | import { Identifiers } from '../errors/Identifiers'; 5 | import { resolveGuildChannelPredicate } from '../utils/resolvers/resolveGuildChannelPredicate'; 6 | 7 | export function resolveGuildTextChannel( 8 | parameter: string, 9 | guild: Guild 10 | ): Result { 11 | return resolveGuildChannelPredicate(parameter, guild, isTextChannel, Identifiers.ArgumentGuildTextChannelError); 12 | } 13 | -------------------------------------------------------------------------------- /src/lib/resolvers/guildThreadChannel.ts: -------------------------------------------------------------------------------- 1 | import { isThreadChannel } from '@sapphire/discord.js-utilities'; 2 | import type { Result } from '@sapphire/result'; 3 | import type { Guild, ThreadChannel } from 'discord.js'; 4 | import { Identifiers } from '../errors/Identifiers'; 5 | import { resolveGuildChannelPredicate } from '../utils/resolvers/resolveGuildChannelPredicate'; 6 | 7 | export function resolveGuildThreadChannel( 8 | parameter: string, 9 | guild: Guild 10 | ): Result { 11 | return resolveGuildChannelPredicate(parameter, guild, isThreadChannel, Identifiers.ArgumentGuildThreadChannelError); 12 | } 13 | -------------------------------------------------------------------------------- /src/lib/resolvers/guildVoiceChannel.ts: -------------------------------------------------------------------------------- 1 | import { isVoiceChannel } from '@sapphire/discord.js-utilities'; 2 | import type { Result } from '@sapphire/result'; 3 | import type { Guild, VoiceChannel } from 'discord.js'; 4 | import { Identifiers } from '../errors/Identifiers'; 5 | import { resolveGuildChannelPredicate } from '../utils/resolvers/resolveGuildChannelPredicate'; 6 | 7 | export function resolveGuildVoiceChannel( 8 | parameter: string, 9 | guild: Guild 10 | ): Result { 11 | return resolveGuildChannelPredicate(parameter, guild, isVoiceChannel, Identifiers.ArgumentGuildVoiceChannelError); 12 | } 13 | -------------------------------------------------------------------------------- /src/lib/resolvers/hyperlink.ts: -------------------------------------------------------------------------------- 1 | import { Result } from '@sapphire/result'; 2 | import { URL } from 'node:url'; 3 | import { Identifiers } from '../errors/Identifiers'; 4 | 5 | export function resolveHyperlink(parameter: string): Result { 6 | const result = Result.from(() => new URL(parameter)); 7 | return result.mapErr(() => Identifiers.ArgumentHyperlinkError) as Result; 8 | } 9 | -------------------------------------------------------------------------------- /src/lib/resolvers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './boolean'; 2 | export * from './channel'; 3 | export * from './date'; 4 | export * from './dmChannel'; 5 | export { resolveEmoji } from './emoji'; 6 | export * from './enum'; 7 | export * from './float'; 8 | export * from './guild'; 9 | export * from './guildCategoryChannel'; 10 | export * from './guildChannel'; 11 | export * from './guildNewsChannel'; 12 | export * from './guildNewsThreadChannel'; 13 | export * from './guildPrivateThreadChannel'; 14 | export * from './guildPublicThreadChannel'; 15 | export * from './guildStageVoiceChannel'; 16 | export * from './guildTextChannel'; 17 | export * from './guildThreadChannel'; 18 | export * from './guildVoiceChannel'; 19 | export * from './hyperlink'; 20 | export * from './integer'; 21 | export * from './member'; 22 | export { resolveMessage } from './message'; 23 | export * from './number'; 24 | export * from './partialDMChannel'; 25 | export * from './role'; 26 | export * from './string'; 27 | export * from './user'; 28 | -------------------------------------------------------------------------------- /src/lib/resolvers/integer.ts: -------------------------------------------------------------------------------- 1 | import { Result } from '@sapphire/result'; 2 | import { Identifiers } from '../errors/Identifiers'; 3 | 4 | export function resolveInteger( 5 | parameter: string, 6 | options?: { minimum?: number; maximum?: number } 7 | ): Result { 8 | const parsed = Number(parameter); 9 | 10 | if (!Number.isInteger(parsed)) { 11 | return Result.err(Identifiers.ArgumentIntegerError); 12 | } 13 | 14 | if (typeof options?.minimum === 'number' && parsed < options.minimum) { 15 | return Result.err(Identifiers.ArgumentIntegerTooSmall); 16 | } 17 | 18 | if (typeof options?.maximum === 'number' && parsed > options.maximum) { 19 | return Result.err(Identifiers.ArgumentIntegerTooLarge); 20 | } 21 | 22 | return Result.ok(parsed); 23 | } 24 | -------------------------------------------------------------------------------- /src/lib/resolvers/member.ts: -------------------------------------------------------------------------------- 1 | import { SnowflakeRegex, UserOrMemberMentionRegex } from '@sapphire/discord-utilities'; 2 | import { Result } from '@sapphire/result'; 3 | import { isNullish } from '@sapphire/utilities'; 4 | import type { Guild, GuildMember, Snowflake } from 'discord.js'; 5 | import { Identifiers } from '../errors/Identifiers'; 6 | 7 | export async function resolveMember( 8 | parameter: string, 9 | guild: Guild, 10 | performFuzzySearch?: boolean 11 | ): Promise> { 12 | let member = await resolveById(parameter, guild); 13 | 14 | if (isNullish(member) && performFuzzySearch) { 15 | member = await resolveByQuery(parameter, guild); 16 | } 17 | 18 | if (member) { 19 | return Result.ok(member); 20 | } 21 | 22 | return Result.err(Identifiers.ArgumentMemberError); 23 | } 24 | 25 | async function resolveById(argument: string, guild: Guild): Promise { 26 | const memberId = UserOrMemberMentionRegex.exec(argument) ?? SnowflakeRegex.exec(argument); 27 | return memberId ? guild.members.fetch(memberId[1] as Snowflake).catch(() => null) : null; 28 | } 29 | 30 | async function resolveByQuery(argument: string, guild: Guild): Promise { 31 | argument = argument.length > 5 && argument.at(-5) === '#' ? argument.slice(0, -5) : argument; 32 | 33 | const members = await guild.members.fetch({ query: argument, limit: 1 }).catch(() => null); 34 | return members?.first() ?? null; 35 | } 36 | -------------------------------------------------------------------------------- /src/lib/resolvers/number.ts: -------------------------------------------------------------------------------- 1 | import { Result } from '@sapphire/result'; 2 | import { Identifiers } from '../errors/Identifiers'; 3 | 4 | export function resolveNumber( 5 | parameter: string, 6 | options?: { minimum?: number; maximum?: number } 7 | ): Result { 8 | const parsed = Number(parameter); 9 | if (Number.isNaN(parsed)) { 10 | return Result.err(Identifiers.ArgumentNumberError); 11 | } 12 | 13 | if (typeof options?.minimum === 'number' && parsed < options.minimum) { 14 | return Result.err(Identifiers.ArgumentNumberTooSmall); 15 | } 16 | 17 | if (typeof options?.maximum === 'number' && parsed > options.maximum) { 18 | return Result.err(Identifiers.ArgumentNumberTooLarge); 19 | } 20 | 21 | return Result.ok(parsed); 22 | } 23 | -------------------------------------------------------------------------------- /src/lib/resolvers/partialDMChannel.ts: -------------------------------------------------------------------------------- 1 | import { isDMChannel } from '@sapphire/discord.js-utilities'; 2 | import { Result } from '@sapphire/result'; 3 | import type { DMChannel, Message, PartialDMChannel } from 'discord.js'; 4 | import { Identifiers } from '../errors/Identifiers'; 5 | import { resolveChannel } from './channel'; 6 | 7 | export function resolvePartialDMChannel( 8 | parameter: string, 9 | message: Message 10 | ): Result { 11 | const result = resolveChannel(parameter, message); 12 | return result.mapInto((channel) => { 13 | if (isDMChannel(channel)) { 14 | return Result.ok(channel); 15 | } 16 | 17 | return Result.err(Identifiers.ArgumentDMChannelError); 18 | }); 19 | } 20 | -------------------------------------------------------------------------------- /src/lib/resolvers/role.ts: -------------------------------------------------------------------------------- 1 | import { RoleMentionRegex, SnowflakeRegex } from '@sapphire/discord-utilities'; 2 | import { Result } from '@sapphire/result'; 3 | import type { Guild, Role, Snowflake } from 'discord.js'; 4 | import { Identifiers } from '../errors/Identifiers'; 5 | 6 | export async function resolveRole(parameter: string, guild: Guild): Promise> { 7 | const role = (await resolveById(parameter, guild)) ?? resolveByQuery(parameter, guild); 8 | 9 | if (role) { 10 | return Result.ok(role); 11 | } 12 | 13 | return Result.err(Identifiers.ArgumentRoleError); 14 | } 15 | 16 | async function resolveById(argument: string, guild: Guild): Promise { 17 | const roleId = RoleMentionRegex.exec(argument) ?? SnowflakeRegex.exec(argument); 18 | return roleId ? guild.roles.fetch(roleId[1] as Snowflake) : null; 19 | } 20 | 21 | function resolveByQuery(argument: string, guild: Guild): Role | null { 22 | const lowerCaseArgument = argument.toLowerCase(); 23 | return guild.roles.cache.find((role) => role.name.toLowerCase() === lowerCaseArgument) ?? null; 24 | } 25 | -------------------------------------------------------------------------------- /src/lib/resolvers/string.ts: -------------------------------------------------------------------------------- 1 | import { Result } from '@sapphire/result'; 2 | import { Identifiers } from '../errors/Identifiers'; 3 | 4 | export function resolveString( 5 | parameter: string, 6 | options?: { minimum?: number; maximum?: number } 7 | ): Result { 8 | if (typeof options?.minimum === 'number' && parameter.length < options.minimum) { 9 | return Result.err(Identifiers.ArgumentStringTooShort); 10 | } 11 | 12 | if (typeof options?.maximum === 'number' && parameter.length > options.maximum) { 13 | return Result.err(Identifiers.ArgumentStringTooLong); 14 | } 15 | 16 | return Result.ok(parameter); 17 | } 18 | -------------------------------------------------------------------------------- /src/lib/resolvers/user.ts: -------------------------------------------------------------------------------- 1 | import { SnowflakeRegex, UserOrMemberMentionRegex } from '@sapphire/discord-utilities'; 2 | import { container } from '@sapphire/pieces'; 3 | import { Result } from '@sapphire/result'; 4 | import type { Snowflake, User } from 'discord.js'; 5 | import { Identifiers } from '../errors/Identifiers'; 6 | 7 | export async function resolveUser(parameter: string): Promise> { 8 | const userId = UserOrMemberMentionRegex.exec(parameter) ?? SnowflakeRegex.exec(parameter); 9 | const user = userId ? await container.client.users.fetch(userId[1] as Snowflake).catch(() => null) : null; 10 | 11 | if (user) { 12 | return Result.ok(user); 13 | } 14 | 15 | return Result.err(Identifiers.ArgumentUserError); 16 | } 17 | -------------------------------------------------------------------------------- /src/lib/structures/ArgumentStore.ts: -------------------------------------------------------------------------------- 1 | import { AliasStore } from '@sapphire/pieces'; 2 | import { Argument } from './Argument'; 3 | 4 | export class ArgumentStore extends AliasStore { 5 | public constructor() { 6 | super(Argument, { name: 'arguments' }); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/lib/structures/ListenerLoaderStrategy.ts: -------------------------------------------------------------------------------- 1 | import { LoaderStrategy } from '@sapphire/pieces'; 2 | import type { Listener } from './Listener'; 3 | import type { ListenerStore } from './ListenerStore'; 4 | 5 | export class ListenerLoaderStrategy extends LoaderStrategy { 6 | public override onLoad(_store: ListenerStore, piece: Listener) { 7 | const listenerCallback = piece['_listener']; 8 | if (listenerCallback) { 9 | const emitter = piece.emitter!; 10 | 11 | // Increment the maximum amount of listeners by one: 12 | const maxListeners = emitter.getMaxListeners(); 13 | if (maxListeners !== 0) emitter.setMaxListeners(maxListeners + 1); 14 | 15 | emitter[piece.once ? 'once' : 'on'](piece.event, listenerCallback); 16 | } 17 | } 18 | 19 | public override onUnload(_store: ListenerStore, piece: Listener) { 20 | const listenerCallback = piece['_listener']; 21 | if (!piece.once && listenerCallback) { 22 | const emitter = piece.emitter!; 23 | 24 | // Increment the maximum amount of listeners by one: 25 | const maxListeners = emitter.getMaxListeners(); 26 | if (maxListeners !== 0) emitter.setMaxListeners(maxListeners - 1); 27 | 28 | emitter.off(piece.event, listenerCallback); 29 | piece['_listener'] = null; 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/lib/structures/ListenerStore.ts: -------------------------------------------------------------------------------- 1 | import { Store } from '@sapphire/pieces'; 2 | import { Listener } from './Listener'; 3 | import { ListenerLoaderStrategy } from './ListenerLoaderStrategy'; 4 | 5 | export class ListenerStore extends Store { 6 | public constructor() { 7 | super(Listener, { name: 'listeners', strategy: new ListenerLoaderStrategy() }); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/lib/types/ArgumentContexts.ts: -------------------------------------------------------------------------------- 1 | import type { MessageResolverOptions } from '../resolvers/message'; 2 | import type { Argument } from '../structures/Argument'; 3 | 4 | /** 5 | * The context for the `'enum'` argument. 6 | * @since 4.2.0 (🌿) 7 | */ 8 | export interface EnumArgumentContext extends Argument.Context { 9 | readonly enum?: string[]; 10 | readonly caseInsensitive?: boolean; 11 | } 12 | 13 | /** 14 | * The context for the `'boolean'` argument. 15 | * @since 4.2.0 (🌿) 16 | */ 17 | export interface BooleanArgumentContext extends Argument.Context { 18 | /** 19 | * The words that resolve to `true`. 20 | * Any words added to this array will be merged with the words: 21 | * ```ts 22 | * ['1', 'true', '+', 't', 'yes', 'y'] 23 | * ``` 24 | */ 25 | readonly truths?: string[]; 26 | /** 27 | * The words that resolve to `false`. 28 | * Any words added to this array will be merged with the words: 29 | * ```ts 30 | * ['0', 'false', '-', 'f', 'no', 'n'] 31 | * ``` 32 | */ 33 | readonly falses?: string[]; 34 | } 35 | 36 | /** 37 | * The context for the `'member'` argument. 38 | * @since 4.2.0 (🌿) 39 | */ 40 | export interface MemberArgumentContext extends Argument.Context { 41 | /** 42 | * Whether to perform a fuzzy search with the given argument. 43 | * This will leverage `FetchMembersOptions.query` to do the fuzzy searching. 44 | * @default true 45 | */ 46 | readonly performFuzzySearch?: boolean; 47 | } 48 | 49 | /** 50 | * The context for the `'message'` argument. 51 | * @since 4.2.0 (🌿) 52 | */ 53 | export type MessageArgumentContext = Omit & Argument.Context; 54 | -------------------------------------------------------------------------------- /src/lib/utils/application-commands/compute-differences/_shared.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ApplicationCommandOptionType, 3 | ApplicationCommandType, 4 | type APIApplicationCommandChannelOption, 5 | type APIApplicationCommandIntegerOption, 6 | type APIApplicationCommandNumberOption, 7 | type APIApplicationCommandOption, 8 | type APIApplicationCommandStringOption, 9 | type APIApplicationCommandSubcommandGroupOption, 10 | type APIApplicationCommandSubcommandOption 11 | } from 'discord-api-types/v10'; 12 | 13 | export const optionTypeToPrettyName = new Map([ 14 | [ApplicationCommandOptionType.Subcommand, 'subcommand'], 15 | [ApplicationCommandOptionType.SubcommandGroup, 'subcommand group'], 16 | [ApplicationCommandOptionType.String, 'string option'], 17 | [ApplicationCommandOptionType.Integer, 'integer option'], 18 | [ApplicationCommandOptionType.Boolean, 'boolean option'], 19 | [ApplicationCommandOptionType.User, 'user option'], 20 | [ApplicationCommandOptionType.Channel, 'channel option'], 21 | [ApplicationCommandOptionType.Role, 'role option'], 22 | [ApplicationCommandOptionType.Mentionable, 'mentionable option'], 23 | [ApplicationCommandOptionType.Number, 'number option'], 24 | [ApplicationCommandOptionType.Attachment, 'attachment option'] 25 | ]); 26 | 27 | export const contextMenuTypes = [ApplicationCommandType.Message, ApplicationCommandType.User]; 28 | export const subcommandTypes = [ApplicationCommandOptionType.SubcommandGroup, ApplicationCommandOptionType.Subcommand]; 29 | 30 | export type APIApplicationCommandSubcommandTypes = APIApplicationCommandSubcommandOption | APIApplicationCommandSubcommandGroupOption; 31 | export type APIApplicationCommandMinAndMaxValueTypes = APIApplicationCommandIntegerOption | APIApplicationCommandNumberOption; 32 | export type APIApplicationCommandChoosableAndAutocompletableTypes = APIApplicationCommandMinAndMaxValueTypes | APIApplicationCommandStringOption; 33 | export type APIApplicationCommandMinMaxLengthTypes = APIApplicationCommandStringOption; 34 | 35 | export function hasMinMaxValueSupport(option: APIApplicationCommandOption): option is APIApplicationCommandMinAndMaxValueTypes { 36 | return [ApplicationCommandOptionType.Integer, ApplicationCommandOptionType.Number].includes(option.type); 37 | } 38 | 39 | export function hasChoicesAndAutocompleteSupport( 40 | option: APIApplicationCommandOption 41 | ): option is APIApplicationCommandChoosableAndAutocompletableTypes { 42 | return [ 43 | ApplicationCommandOptionType.Integer, // 44 | ApplicationCommandOptionType.Number, 45 | ApplicationCommandOptionType.String 46 | ].includes(option.type); 47 | } 48 | 49 | export function hasMinMaxLengthSupport(option: APIApplicationCommandOption): option is APIApplicationCommandMinMaxLengthTypes { 50 | return option.type === ApplicationCommandOptionType.String; 51 | } 52 | 53 | export function hasChannelTypesSupport(option: APIApplicationCommandOption): option is APIApplicationCommandChannelOption { 54 | return option.type === ApplicationCommandOptionType.Channel; 55 | } 56 | 57 | export interface CommandDifference { 58 | key: string; 59 | expected: string; 60 | original: string; 61 | } 62 | -------------------------------------------------------------------------------- /src/lib/utils/application-commands/compute-differences/contexts.ts: -------------------------------------------------------------------------------- 1 | import type { InteractionContextType } from 'discord.js'; 2 | import type { CommandDifference } from './_shared'; 3 | 4 | export function* checkInteractionContextTypes( 5 | existingContexts?: InteractionContextType[], 6 | newContexts?: InteractionContextType[] 7 | ): Generator { 8 | // 0. No existing contexts and now we have contexts 9 | if (!existingContexts && newContexts?.length) { 10 | yield { 11 | key: 'contexts', 12 | original: 'no contexts present', 13 | expected: 'contexts present' 14 | }; 15 | } 16 | // 1. Existing contexts and now we have no contexts 17 | else if (existingContexts?.length && !newContexts?.length) { 18 | yield { 19 | key: 'contexts', 20 | original: 'contexts present', 21 | expected: 'no contexts present' 22 | }; 23 | } 24 | // 2. Maybe changes in order or additions, log 25 | else if (newContexts?.length) { 26 | let index = 0; 27 | 28 | for (const newContext of newContexts) { 29 | const currentIndex = index++; 30 | 31 | if (existingContexts![currentIndex] !== newContext) { 32 | yield { 33 | key: `contexts[${currentIndex}]`, 34 | original: `contexts type ${existingContexts?.[currentIndex]}`, 35 | expected: `contexts type ${newContext}` 36 | }; 37 | } 38 | } 39 | 40 | if (index < existingContexts!.length) { 41 | let type: InteractionContextType; 42 | 43 | while ((type = existingContexts![index]) !== undefined) { 44 | yield { 45 | key: `contexts[${index}]`, 46 | original: `context ${type} present`, 47 | expected: `no context present` 48 | }; 49 | 50 | index++; 51 | } 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/lib/utils/application-commands/compute-differences/default_member_permissions.ts: -------------------------------------------------------------------------------- 1 | import type { CommandDifference } from './_shared'; 2 | 3 | export function* checkDefaultMemberPermissions(oldPermissions?: string | null, newPermissions?: string | null): Generator { 4 | if (oldPermissions !== newPermissions) { 5 | yield { 6 | key: 'defaultMemberPermissions', 7 | original: String(oldPermissions), 8 | expected: String(newPermissions) 9 | }; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/lib/utils/application-commands/compute-differences/description.ts: -------------------------------------------------------------------------------- 1 | import type { CommandDifference } from './_shared'; 2 | 3 | export function* checkDescription({ 4 | oldDescription, 5 | newDescription, 6 | key = 'description' 7 | }: { 8 | oldDescription: string; 9 | newDescription: string; 10 | key?: string; 11 | }): Generator { 12 | if (oldDescription !== newDescription) { 13 | yield { 14 | key, 15 | original: oldDescription, 16 | expected: newDescription 17 | }; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/lib/utils/application-commands/compute-differences/dm_permission.ts: -------------------------------------------------------------------------------- 1 | import type { CommandDifference } from './_shared'; 2 | 3 | export function* checkDMPermission(oldDmPermission?: boolean, newDmPermission?: boolean): Generator { 4 | if ((oldDmPermission ?? true) !== (newDmPermission ?? true)) { 5 | yield { 6 | key: 'dmPermission', 7 | original: String(oldDmPermission ?? true), 8 | expected: String(newDmPermission ?? true) 9 | }; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/lib/utils/application-commands/compute-differences/integration_types.ts: -------------------------------------------------------------------------------- 1 | import type { ApplicationIntegrationType } from 'discord.js'; 2 | import type { CommandDifference } from './_shared'; 3 | 4 | export function* checkIntegrationTypes( 5 | existingIntegrationTypes?: ApplicationIntegrationType[], 6 | newIntegrationTypes?: ApplicationIntegrationType[] 7 | ): Generator { 8 | // 0. No existing integration types and now we have integration types 9 | if (!existingIntegrationTypes?.length && newIntegrationTypes?.length) { 10 | yield { 11 | key: 'integrationTypes', 12 | original: 'no integration types present', 13 | expected: 'integration types present' 14 | }; 15 | } 16 | // 1. Existing integration types and now we have no integration types 17 | else if (existingIntegrationTypes?.length && !newIntegrationTypes?.length) { 18 | yield { 19 | key: 'integrationTypes', 20 | original: 'integration types present', 21 | expected: 'no integration types present' 22 | }; 23 | } 24 | // 2. Maybe changes in order or additions, log 25 | else if (newIntegrationTypes?.length) { 26 | let index = 0; 27 | 28 | for (const newIntegrationType of newIntegrationTypes) { 29 | const currentIndex = index++; 30 | 31 | if (existingIntegrationTypes![currentIndex] !== newIntegrationType) { 32 | yield { 33 | key: `integrationTypes[${currentIndex}]`, 34 | original: `integration type ${existingIntegrationTypes?.[currentIndex]}`, 35 | expected: `integration type ${newIntegrationType}` 36 | }; 37 | } 38 | } 39 | 40 | if (index < existingIntegrationTypes!.length) { 41 | let type: ApplicationIntegrationType; 42 | 43 | while ((type = existingIntegrationTypes![index]) !== undefined) { 44 | yield { 45 | key: `integrationTypes[${index}]`, 46 | original: `integration type ${type} present`, 47 | expected: 'no integration type present' 48 | }; 49 | 50 | index++; 51 | } 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/lib/utils/application-commands/compute-differences/localizations.ts: -------------------------------------------------------------------------------- 1 | import type { LocalizationMap } from 'discord-api-types/v10'; 2 | import type { CommandDifference } from './_shared'; 3 | 4 | export function* checkLocalizations({ 5 | localeMapName, 6 | localePresentMessage, 7 | localeMissingMessage, 8 | originalLocalizedDescriptions, 9 | expectedLocalizedDescriptions 10 | }: { 11 | localeMapName: string; 12 | localePresentMessage: string; 13 | localeMissingMessage: string; 14 | originalLocalizedDescriptions?: LocalizationMap | null; 15 | expectedLocalizedDescriptions?: LocalizationMap | null; 16 | }) { 17 | if (!originalLocalizedDescriptions && expectedLocalizedDescriptions) { 18 | yield { 19 | key: localeMapName, 20 | original: localeMissingMessage, 21 | expected: localePresentMessage 22 | }; 23 | } else if (originalLocalizedDescriptions && !expectedLocalizedDescriptions) { 24 | yield { 25 | key: localeMapName, 26 | original: localePresentMessage, 27 | expected: localeMissingMessage 28 | }; 29 | } else if (originalLocalizedDescriptions && expectedLocalizedDescriptions) { 30 | yield* reportLocalizationMapDifferences(originalLocalizedDescriptions, expectedLocalizedDescriptions, localeMapName); 31 | } 32 | } 33 | 34 | function* reportLocalizationMapDifferences( 35 | originalMap: LocalizationMap, 36 | expectedMap: LocalizationMap, 37 | mapName: string 38 | ): Generator { 39 | const originalLocalizations = new Map(Object.entries(originalMap)); 40 | 41 | for (const [key, value] of Object.entries(expectedMap)) { 42 | const possiblyExistingEntry = originalLocalizations.get(key) as string | undefined; 43 | originalLocalizations.delete(key); 44 | 45 | const wasMissingBefore = typeof possiblyExistingEntry === 'undefined'; 46 | const isResetNow = value === null; 47 | 48 | // Was missing before and now is present 49 | if (wasMissingBefore && !isResetNow) { 50 | yield { 51 | key: `${mapName}.${key}`, 52 | original: 'no localization present', 53 | expected: value 54 | }; 55 | } 56 | // Was present before and now is reset 57 | else if (!wasMissingBefore && isResetNow) { 58 | yield { 59 | key: `${mapName}.${key}`, 60 | original: possiblyExistingEntry, 61 | expected: 'no localization present' 62 | }; 63 | } 64 | // Not equal 65 | // eslint-disable-next-line no-negated-condition 66 | else if (possiblyExistingEntry !== value) { 67 | yield { 68 | key: `${mapName}.${key}`, 69 | original: String(possiblyExistingEntry), 70 | expected: String(value) 71 | }; 72 | } 73 | } 74 | 75 | // Report any remaining localizations 76 | for (const [key, value] of originalLocalizations) { 77 | if (value) { 78 | yield { 79 | key: `${mapName}.${key}`, 80 | original: value, 81 | expected: 'no localization present' 82 | }; 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/lib/utils/application-commands/compute-differences/name.ts: -------------------------------------------------------------------------------- 1 | import type { CommandDifference } from './_shared'; 2 | 3 | export function* checkName({ oldName, newName, key = 'name' }: { oldName: string; newName: string; key?: string }): Generator { 4 | if (oldName !== newName) { 5 | yield { 6 | key, 7 | original: oldName, 8 | expected: newName 9 | }; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/lib/utils/application-commands/compute-differences/option/minMaxLength.ts: -------------------------------------------------------------------------------- 1 | import type { APIApplicationCommandStringOption } from 'discord-api-types/v10'; 2 | import type { CommandDifference } from '../_shared'; 3 | 4 | export function* handleMinMaxLengthOptions({ 5 | currentIndex, 6 | existingOption, 7 | expectedOption, 8 | keyPath 9 | }: { 10 | currentIndex: number; 11 | keyPath: (index: number) => string; 12 | expectedOption: APIApplicationCommandStringOption; 13 | existingOption: APIApplicationCommandStringOption; 14 | }): Generator { 15 | // 0. No min_length and now we have min_length 16 | if (existingOption.min_length === undefined && expectedOption.min_length !== undefined) { 17 | yield { 18 | key: `${keyPath(currentIndex)}.min_length`, 19 | expected: 'min_length present', 20 | original: 'no min_length present' 21 | }; 22 | } 23 | // 1. Have min_length and now we don't 24 | else if (existingOption.min_length !== undefined && expectedOption.min_length === undefined) { 25 | yield { 26 | key: `${keyPath(currentIndex)}.min_length`, 27 | expected: 'no min_length present', 28 | original: 'min_length present' 29 | }; 30 | } 31 | // 2. Equality check 32 | else if (existingOption.min_length !== expectedOption.min_length) { 33 | yield { 34 | key: `${keyPath(currentIndex)}.min_length`, 35 | original: String(existingOption.min_length), 36 | expected: String(expectedOption.min_length) 37 | }; 38 | } 39 | 40 | // 0. No max_length and now we have max_length 41 | if (existingOption.max_length === undefined && expectedOption.max_length !== undefined) { 42 | yield { 43 | key: `${keyPath(currentIndex)}.max_length`, 44 | expected: 'max_length present', 45 | original: 'no max_length present' 46 | }; 47 | } 48 | // 1. Have max_length and now we don't 49 | else if (existingOption.max_length !== undefined && expectedOption.max_length === undefined) { 50 | yield { 51 | key: `${keyPath(currentIndex)}.max_length`, 52 | expected: 'no max_length present', 53 | original: 'max_length present' 54 | }; 55 | } 56 | // 2. Equality check 57 | else if (existingOption.max_length !== expectedOption.max_length) { 58 | yield { 59 | key: `${keyPath(currentIndex)}.max_length`, 60 | original: String(existingOption.max_length), 61 | expected: String(expectedOption.max_length) 62 | }; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/lib/utils/application-commands/compute-differences/option/minMaxValue.ts: -------------------------------------------------------------------------------- 1 | import type { APIApplicationCommandMinAndMaxValueTypes, CommandDifference } from '../_shared'; 2 | 3 | export function* handleMinMaxValueOptions({ 4 | currentIndex, 5 | existingOption, 6 | expectedOption, 7 | keyPath 8 | }: { 9 | currentIndex: number; 10 | keyPath: (index: number) => string; 11 | expectedOption: APIApplicationCommandMinAndMaxValueTypes; 12 | existingOption: APIApplicationCommandMinAndMaxValueTypes; 13 | }): Generator { 14 | // 0. No min_value and now we have min_value 15 | if (existingOption.min_value === undefined && expectedOption.min_value !== undefined) { 16 | yield { 17 | key: `${keyPath(currentIndex)}.min_value`, 18 | expected: 'min_value present', 19 | original: 'no min_value present' 20 | }; 21 | } 22 | // 1. Have min_value and now we don't 23 | else if (existingOption.min_value !== undefined && expectedOption.min_value === undefined) { 24 | yield { 25 | key: `${keyPath(currentIndex)}.min_value`, 26 | expected: 'no min_value present', 27 | original: 'min_value present' 28 | }; 29 | } 30 | // 2. Equality check 31 | else if (existingOption.min_value !== expectedOption.min_value) { 32 | yield { 33 | key: `${keyPath(currentIndex)}.min_value`, 34 | original: String(existingOption.min_value), 35 | expected: String(expectedOption.min_value) 36 | }; 37 | } 38 | 39 | // 0. No max_value and now we have max_value 40 | if (existingOption.max_value === undefined && expectedOption.max_value !== undefined) { 41 | yield { 42 | key: `${keyPath(currentIndex)}.max_value`, 43 | expected: 'max_value present', 44 | original: 'no max_value present' 45 | }; 46 | } 47 | // 1. Have max_value and now we don't 48 | else if (existingOption.max_value !== undefined && expectedOption.max_value === undefined) { 49 | yield { 50 | key: `${keyPath(currentIndex)}.max_value`, 51 | expected: 'no max_value present', 52 | original: 'max_value present' 53 | }; 54 | } 55 | // 2. Equality check 56 | else if (existingOption.max_value !== expectedOption.max_value) { 57 | yield { 58 | key: `${keyPath(currentIndex)}.max_value`, 59 | original: String(existingOption.max_value), 60 | expected: String(expectedOption.max_value) 61 | }; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/lib/utils/application-commands/compute-differences/option/required.ts: -------------------------------------------------------------------------------- 1 | import type { CommandDifference } from '../_shared'; 2 | 3 | export function* checkOptionRequired({ 4 | oldRequired, 5 | newRequired, 6 | key 7 | }: { 8 | oldRequired?: boolean; 9 | newRequired?: boolean; 10 | key: string; 11 | }): Generator { 12 | if ((oldRequired ?? false) !== (newRequired ?? false)) { 13 | yield { 14 | key, 15 | original: String(oldRequired ?? false), 16 | expected: String(newRequired ?? false) 17 | }; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/lib/utils/application-commands/compute-differences/option/type.ts: -------------------------------------------------------------------------------- 1 | import type { ApplicationCommandOptionType } from 'discord-api-types/v10'; 2 | import { optionTypeToPrettyName, type CommandDifference } from '../_shared'; 3 | 4 | export function* checkOptionType({ 5 | key, 6 | expectedType, 7 | originalType 8 | }: { 9 | key: string; 10 | originalType: ApplicationCommandOptionType; 11 | expectedType: ApplicationCommandOptionType; 12 | }): Generator { 13 | const expectedTypeString = 14 | optionTypeToPrettyName.get(expectedType) ?? `unknown (${expectedType}); please contact Sapphire developers about this!`; 15 | 16 | if (originalType !== expectedType) { 17 | yield { 18 | key, 19 | original: optionTypeToPrettyName.get(originalType) ?? `unknown (${originalType}); please contact Sapphire developers about this!`, 20 | expected: expectedTypeString 21 | }; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/lib/utils/application-commands/getNeededParameters.ts: -------------------------------------------------------------------------------- 1 | import { container } from '@sapphire/pieces'; 2 | import type { ApplicationCommand, ApplicationCommandManager, Collection } from 'discord.js'; 3 | 4 | export async function getNeededRegistryParameters(guildIds: Set = new Set()) { 5 | const { client } = container; 6 | 7 | const applicationCommands = client.application!.commands; 8 | const globalCommands = await applicationCommands.fetch({ withLocalizations: true }); 9 | const guildCommands = await fetchGuildCommands(applicationCommands, guildIds); 10 | 11 | return { 12 | applicationCommands, 13 | globalCommands, 14 | guildCommands 15 | }; 16 | } 17 | 18 | async function fetchGuildCommands(commands: ApplicationCommandManager, guildIds: Set) { 19 | const map = new Map>(); 20 | 21 | for (const guildId of guildIds) { 22 | try { 23 | const guildCommands = await commands.fetch({ guildId, withLocalizations: true }); 24 | map.set(guildId, guildCommands); 25 | } catch (err) { 26 | const { preventFailedToFetchLogForGuilds } = container.client.options; 27 | 28 | if (preventFailedToFetchLogForGuilds === true) continue; 29 | 30 | if (Array.isArray(preventFailedToFetchLogForGuilds) && !preventFailedToFetchLogForGuilds?.includes(guildId)) { 31 | const guild = container.client.guilds.resolve(guildId) ?? { name: 'Guild not in cache' }; 32 | container.logger.warn( 33 | `ApplicationCommandRegistries: Failed to fetch guild commands for guild "${guild.name}" (${guildId}).`, 34 | 'Make sure to authorize your application with the "applications.commands" scope in that guild.' 35 | ); 36 | } 37 | } 38 | } 39 | 40 | return map; 41 | } 42 | -------------------------------------------------------------------------------- /src/lib/utils/application-commands/registriesErrors.ts: -------------------------------------------------------------------------------- 1 | import { container } from '@sapphire/pieces'; 2 | import type { Command } from '../../structures/Command'; 3 | import { Events } from '../../types/Events'; 4 | import { bulkOverwriteError } from './registriesLog'; 5 | 6 | /** 7 | * Opinionatedly logs the encountered registry error. 8 | * @param error The emitted error 9 | * @param command The command which had the error 10 | */ 11 | export function emitPerRegistryError(error: unknown, command: Command) { 12 | const { name, location } = command; 13 | const { client, logger } = container; 14 | 15 | if (client.listenerCount(Events.CommandApplicationCommandRegistryError)) { 16 | client.emit(Events.CommandApplicationCommandRegistryError, error, command); 17 | } else { 18 | logger.error( 19 | `Encountered error while handling the command application command registry for command "${name}" at path "${location.full}"`, 20 | error 21 | ); 22 | } 23 | } 24 | 25 | /** 26 | * Opinionatedly logs any bulk overwrite registries error. 27 | * @param error The emitted error 28 | * @param guildId The guild id in which the error was caused 29 | */ 30 | export function emitBulkOverwriteError(error: unknown, guildId: string | null) { 31 | const { client } = container; 32 | 33 | if (client.listenerCount(Events.ApplicationCommandRegistriesBulkOverwriteError)) { 34 | client.emit(Events.ApplicationCommandRegistriesBulkOverwriteError, error, guildId); 35 | } else if (guildId) { 36 | bulkOverwriteError(`Failed to overwrite guild application commands for guild ${guildId}`, error); 37 | } else { 38 | bulkOverwriteError(`Failed to overwrite global application commands`, error); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/lib/utils/application-commands/registriesLog.ts: -------------------------------------------------------------------------------- 1 | import { container } from '@sapphire/pieces'; 2 | 3 | export function bulkOverwriteInfo(message: string, ...other: unknown[]) { 4 | container.logger.info(`ApplicationCommandRegistries(BulkOverwrite) ${message}`, ...other); 5 | } 6 | 7 | export function bulkOverwriteError(message: string, ...other: unknown[]) { 8 | container.logger.error(`ApplicationCommandRegistries(BulkOverwrite) ${message}`, ...other); 9 | } 10 | 11 | export function bulkOverwriteWarn(message: string, ...other: unknown[]) { 12 | container.logger.warn(`ApplicationCommandRegistries(BulkOverwrite) ${message}`, ...other); 13 | } 14 | 15 | export function bulkOverwriteDebug(message: string, ...other: unknown[]) { 16 | container.logger.debug(`ApplicationCommandRegistries(BulkOverwrite) ${message}`, ...other); 17 | } 18 | -------------------------------------------------------------------------------- /src/lib/utils/logger/ILogger.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * The logger levels for the {@link ILogger}. 3 | */ 4 | export enum LogLevel { 5 | /** 6 | * The lowest log level, used when calling {@link ILogger.trace}. 7 | */ 8 | Trace = 10, 9 | 10 | /** 11 | * The debug level, used when calling {@link ILogger.debug}. 12 | */ 13 | Debug = 20, 14 | 15 | /** 16 | * The info level, used when calling {@link ILogger.info}. 17 | */ 18 | Info = 30, 19 | 20 | /** 21 | * The warning level, used when calling {@link ILogger.warn}. 22 | */ 23 | Warn = 40, 24 | 25 | /** 26 | * The error level, used when calling {@link ILogger.error}. 27 | */ 28 | Error = 50, 29 | 30 | /** 31 | * The critical level, used when calling {@link ILogger.fatal}. 32 | */ 33 | Fatal = 60, 34 | 35 | /** 36 | * An unknown or uncategorized level. 37 | */ 38 | None = 100 39 | } 40 | 41 | export interface ILogger { 42 | /** 43 | * Checks whether a level is supported. 44 | * @param level The level to check. 45 | */ 46 | has(level: LogLevel): boolean; 47 | 48 | /** 49 | * Alias of {@link ILogger.write} with {@link LogLevel.Trace} as level. 50 | * @param values The values to log. 51 | */ 52 | trace(...values: readonly unknown[]): void; 53 | 54 | /** 55 | * Alias of {@link ILogger.write} with {@link LogLevel.Debug} as level. 56 | * @param values The values to log. 57 | */ 58 | debug(...values: readonly unknown[]): void; 59 | 60 | /** 61 | * Alias of {@link ILogger.write} with {@link LogLevel.Info} as level. 62 | * @param values The values to log. 63 | */ 64 | info(...values: readonly unknown[]): void; 65 | 66 | /** 67 | * Alias of {@link ILogger.write} with {@link LogLevel.Warn} as level. 68 | * @param values The values to log. 69 | */ 70 | warn(...values: readonly unknown[]): void; 71 | 72 | /** 73 | * Alias of {@link ILogger.write} with {@link LogLevel.Error} as level. 74 | * @param values The values to log. 75 | */ 76 | error(...values: readonly unknown[]): void; 77 | 78 | /** 79 | * Alias of {@link ILogger.write} with {@link LogLevel.Fatal} as level. 80 | * @param values The values to log. 81 | */ 82 | fatal(...values: readonly unknown[]): void; 83 | 84 | /** 85 | * Writes the log message given a level and the value(s). 86 | * @param level The log level. 87 | * @param values The values to log. 88 | */ 89 | write(level: LogLevel, ...values: readonly unknown[]): void; 90 | } 91 | -------------------------------------------------------------------------------- /src/lib/utils/logger/Logger.ts: -------------------------------------------------------------------------------- 1 | import { LogLevel, type ILogger } from './ILogger'; 2 | 3 | export class Logger implements ILogger { 4 | public level: LogLevel; 5 | 6 | public constructor(level: LogLevel) { 7 | this.level = level; 8 | } 9 | 10 | public has(level: LogLevel): boolean { 11 | return level >= this.level; 12 | } 13 | 14 | public trace(...values: readonly unknown[]): void { 15 | this.write(LogLevel.Trace, ...values); 16 | } 17 | 18 | public debug(...values: readonly unknown[]): void { 19 | this.write(LogLevel.Debug, ...values); 20 | } 21 | 22 | public info(...values: readonly unknown[]): void { 23 | this.write(LogLevel.Info, ...values); 24 | } 25 | 26 | public warn(...values: readonly unknown[]): void { 27 | this.write(LogLevel.Warn, ...values); 28 | } 29 | 30 | public error(...values: readonly unknown[]): void { 31 | this.write(LogLevel.Error, ...values); 32 | } 33 | 34 | public fatal(...values: readonly unknown[]): void { 35 | this.write(LogLevel.Fatal, ...values); 36 | } 37 | 38 | public write(level: LogLevel, ...values: readonly unknown[]): void { 39 | if (!this.has(level)) return; 40 | const method = Logger.levels.get(level); 41 | if (typeof method === 'string') console[method](`[${method.toUpperCase()}]`, ...values); 42 | } 43 | 44 | protected static readonly levels = new Map([ 45 | [LogLevel.Trace, 'trace'], 46 | [LogLevel.Debug, 'debug'], 47 | [LogLevel.Info, 'info'], 48 | [LogLevel.Warn, 'warn'], 49 | [LogLevel.Error, 'error'], 50 | [LogLevel.Fatal, 'error'] 51 | ]); 52 | } 53 | 54 | export type LogMethods = 'trace' | 'debug' | 'info' | 'warn' | 'error'; 55 | -------------------------------------------------------------------------------- /src/lib/utils/preconditions/IPreconditionContainer.ts: -------------------------------------------------------------------------------- 1 | import type { Result } from '@sapphire/result'; 2 | import type { Awaitable } from '@sapphire/utilities'; 3 | import type { ChatInputCommandInteraction, ContextMenuCommandInteraction, Message } from 'discord.js'; 4 | import type { UserError } from '../../errors/UserError'; 5 | import type { Command } from '../../structures/Command'; 6 | import type { PreconditionContext } from '../../structures/Precondition'; 7 | 8 | /** 9 | * Defines the result's value for a PreconditionContainer. 10 | * @since 1.0.0 11 | */ 12 | export type PreconditionContainerResult = Result; 13 | 14 | /** 15 | * Defines the return type of the generic {@link IPreconditionContainer.messageRun}. 16 | * @since 1.0.0 17 | */ 18 | export type PreconditionContainerReturn = Awaitable; 19 | 20 | /** 21 | * Async-only version of {@link PreconditionContainerReturn}, to be used when the run method is async. 22 | * @since 1.0.0 23 | */ 24 | export type AsyncPreconditionContainerReturn = Promise; 25 | 26 | /** 27 | * An abstracted precondition container to be implemented by classes. 28 | * @since 1.0.0 29 | */ 30 | export interface IPreconditionContainer { 31 | /** 32 | * Runs a precondition container. 33 | * @since 1.0.0 34 | * @param message The message that ran this precondition. 35 | * @param command The command the message invoked. 36 | * @param context The context for the precondition. 37 | */ 38 | messageRun(message: Message, command: Command, context?: PreconditionContext): PreconditionContainerReturn; 39 | /** 40 | * Runs a precondition container. 41 | * @since 3.0.0 42 | * @param interaction The interaction that ran this precondition. 43 | * @param command The command the interaction invoked. 44 | * @param context The context for the precondition. 45 | */ 46 | chatInputRun(interaction: ChatInputCommandInteraction, command: Command, context?: PreconditionContext): PreconditionContainerReturn; 47 | /** 48 | * Runs a precondition container. 49 | * @since 3.0.0 50 | * @param interaction The interaction that ran this precondition. 51 | * @param command The command the interaction invoked. 52 | * @param context The context for the precondition. 53 | */ 54 | contextMenuRun(interaction: ContextMenuCommandInteraction, command: Command, context?: PreconditionContext): PreconditionContainerReturn; 55 | } 56 | -------------------------------------------------------------------------------- /src/lib/utils/preconditions/conditions/PreconditionConditionAnd.ts: -------------------------------------------------------------------------------- 1 | import { Result } from '@sapphire/result'; 2 | import type { IPreconditionCondition } from './IPreconditionCondition'; 3 | 4 | /** 5 | * An {@link IPreconditionCondition} which runs all containers similarly to doing (V0 && V1 [&& V2 [&& V3 ...]]). 6 | * @since 1.0.0 7 | */ 8 | export const PreconditionConditionAnd: IPreconditionCondition = { 9 | async messageSequential(message, command, entries, context) { 10 | for (const child of entries) { 11 | const result = await child.messageRun(message, command, context); 12 | if (result.isErr()) return result; 13 | } 14 | 15 | return Result.ok(); 16 | }, 17 | async messageParallel(message, command, entries, context) { 18 | const results = await Promise.all(entries.map((entry) => entry.messageRun(message, command, context))); 19 | // This is simplified compared to PreconditionContainerAny, because we're looking for the first error. 20 | // However, the base implementation short-circuits with the first Ok. 21 | return results.find((res) => res.isErr()) ?? Result.ok(); 22 | }, 23 | async chatInputSequential(interaction, command, entries, context) { 24 | for (const child of entries) { 25 | const result = await child.chatInputRun(interaction, command, context); 26 | if (result.isErr()) return result; 27 | } 28 | 29 | return Result.ok(); 30 | }, 31 | async chatInputParallel(interaction, command, entries, context) { 32 | const results = await Promise.all(entries.map((entry) => entry.chatInputRun(interaction, command, context))); 33 | // This is simplified compared to PreconditionContainerAny, because we're looking for the first error. 34 | // However, the base implementation short-circuits with the first Ok. 35 | return results.find((res) => res.isErr()) ?? Result.ok(); 36 | }, 37 | async contextMenuSequential(interaction, command, entries, context) { 38 | for (const child of entries) { 39 | const result = await child.contextMenuRun(interaction, command, context); 40 | if (result.isErr()) return result; 41 | } 42 | 43 | return Result.ok(); 44 | }, 45 | async contextMenuParallel(interaction, command, entries, context) { 46 | const results = await Promise.all(entries.map((entry) => entry.contextMenuRun(interaction, command, context))); 47 | // This is simplified compared to PreconditionContainerAny, because we're looking for the first error. 48 | // However, the base implementation short-circuits with the first Ok. 49 | return results.find((res) => res.isErr()) ?? Result.ok(); 50 | } 51 | }; 52 | -------------------------------------------------------------------------------- /src/lib/utils/preconditions/conditions/PreconditionConditionOr.ts: -------------------------------------------------------------------------------- 1 | import { Result } from '@sapphire/result'; 2 | import type { PreconditionContainerResult } from '../IPreconditionContainer'; 3 | import type { IPreconditionCondition } from './IPreconditionCondition'; 4 | 5 | /** 6 | * An {@link IPreconditionCondition} which runs all containers similarly to doing (V0 || V1 [|| V2 [|| V3 ...]]). 7 | * @since 1.0.0 8 | */ 9 | export const PreconditionConditionOr: IPreconditionCondition = { 10 | async messageSequential(message, command, entries, context) { 11 | let error: PreconditionContainerResult | null = null; 12 | for (const child of entries) { 13 | const result = await child.messageRun(message, command, context); 14 | if (result.isOk()) return result; 15 | error = result; 16 | } 17 | 18 | return error ?? Result.ok(); 19 | }, 20 | async messageParallel(message, command, entries, context) { 21 | const results = await Promise.all(entries.map((entry) => entry.messageRun(message, command, context))); 22 | 23 | let error: PreconditionContainerResult | null = null; 24 | for (const result of results) { 25 | if (result.isOk()) return result; 26 | error = result; 27 | } 28 | 29 | return error ?? Result.ok(); 30 | }, 31 | async chatInputSequential(interaction, command, entries, context) { 32 | let error: PreconditionContainerResult | null = null; 33 | for (const child of entries) { 34 | const result = await child.chatInputRun(interaction, command, context); 35 | if (result.isOk()) return result; 36 | error = result; 37 | } 38 | 39 | return error ?? Result.ok(); 40 | }, 41 | async chatInputParallel(interaction, command, entries, context) { 42 | const results = await Promise.all(entries.map((entry) => entry.chatInputRun(interaction, command, context))); 43 | 44 | let error: PreconditionContainerResult | null = null; 45 | for (const result of results) { 46 | if (result.isOk()) return result; 47 | error = result; 48 | } 49 | 50 | return error ?? Result.ok(); 51 | }, 52 | async contextMenuSequential(interaction, command, entries, context) { 53 | let error: PreconditionContainerResult | null = null; 54 | for (const child of entries) { 55 | const result = await child.contextMenuRun(interaction, command, context); 56 | if (result.isOk()) return result; 57 | error = result; 58 | } 59 | 60 | return error ?? Result.ok(); 61 | }, 62 | async contextMenuParallel(interaction, command, entries, context) { 63 | const results = await Promise.all(entries.map((entry) => entry.contextMenuRun(interaction, command, context))); 64 | 65 | let error: PreconditionContainerResult | null = null; 66 | for (const result of results) { 67 | if (result.isOk()) return result; 68 | error = result; 69 | } 70 | 71 | return error ?? Result.ok(); 72 | } 73 | }; 74 | -------------------------------------------------------------------------------- /src/lib/utils/preconditions/containers/ClientPermissionsPrecondition.ts: -------------------------------------------------------------------------------- 1 | import { PermissionsBitField, type PermissionResolvable } from 'discord.js'; 2 | import type { PreconditionSingleResolvableDetails } from '../PreconditionContainerSingle'; 3 | 4 | /** 5 | * Constructs a contextful permissions precondition requirement. 6 | * @since 1.0.0 7 | * @example 8 | * ```typescript 9 | * export class CoreCommand extends Command { 10 | * public constructor(context: Command.Context) { 11 | * super(context, { 12 | * preconditions: [ 13 | * 'GuildOnly', 14 | * new ClientPermissionsPrecondition('ADD_REACTIONS') 15 | * ] 16 | * }); 17 | * } 18 | * 19 | * public messageRun(message: Message, args: Args) { 20 | * // ... 21 | * } 22 | * } 23 | * ``` 24 | */ 25 | export class ClientPermissionsPrecondition implements PreconditionSingleResolvableDetails<'ClientPermissions'> { 26 | public name: 'ClientPermissions'; 27 | public context: { permissions: PermissionsBitField }; 28 | 29 | /** 30 | * Constructs a precondition container entry. 31 | * @param permissions The permissions that will be required by this command. 32 | */ 33 | public constructor(permissions: PermissionResolvable) { 34 | this.name = 'ClientPermissions'; 35 | this.context = { 36 | permissions: new PermissionsBitField(permissions) 37 | }; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/lib/utils/preconditions/containers/UserPermissionsPrecondition.ts: -------------------------------------------------------------------------------- 1 | import { PermissionsBitField, type PermissionResolvable } from 'discord.js'; 2 | import type { PreconditionSingleResolvableDetails } from '../PreconditionContainerSingle'; 3 | 4 | /** 5 | * Constructs a contextful permissions precondition requirement. 6 | * @since 1.0.0 7 | * @example 8 | * ```typescript 9 | * export class CoreCommand extends Command { 10 | * public constructor(context: Command.Context) { 11 | * super(context, { 12 | * preconditions: [ 13 | * 'GuildOnly', 14 | * new UserPermissionsPrecondition('ADD_REACTIONS') 15 | * ] 16 | * }); 17 | * } 18 | * 19 | * public messageRun(message: Message, args: Args) { 20 | * // ... 21 | * } 22 | * } 23 | * ``` 24 | */ 25 | export class UserPermissionsPrecondition implements PreconditionSingleResolvableDetails<'UserPermissions'> { 26 | public name: 'UserPermissions'; 27 | public context: { permissions: PermissionsBitField }; 28 | 29 | /** 30 | * Constructs a precondition container entry. 31 | * @param permissions The permissions that will be required by this command. 32 | */ 33 | public constructor(permissions: PermissionResolvable) { 34 | this.name = 'UserPermissions'; 35 | this.context = { 36 | permissions: new PermissionsBitField(permissions) 37 | }; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/lib/utils/resolvers/resolveGuildChannelPredicate.ts: -------------------------------------------------------------------------------- 1 | import type { ChannelTypes, GuildBasedChannelTypes } from '@sapphire/discord.js-utilities'; 2 | import { Result } from '@sapphire/result'; 3 | import type { Nullish } from '@sapphire/utilities'; 4 | import type { Guild } from 'discord.js'; 5 | import type { Identifiers } from '../../errors/Identifiers'; 6 | import { resolveGuildChannel } from '../../resolvers/guildChannel'; 7 | 8 | export function resolveGuildChannelPredicate( 9 | parameter: string, 10 | guild: Guild, 11 | predicate: (channel: ChannelTypes | Nullish) => channel is TChannel, 12 | error: TError 13 | ): Result { 14 | const result = resolveGuildChannel(parameter, guild); 15 | return result.mapInto((channel) => (predicate(channel) ? Result.ok(channel) : Result.err(error))); 16 | } 17 | -------------------------------------------------------------------------------- /src/lib/utils/strategies/FlagUnorderedStrategy.ts: -------------------------------------------------------------------------------- 1 | import { PrefixedStrategy } from '@sapphire/lexure'; 2 | import { Option } from '@sapphire/result'; 3 | 4 | /** 5 | * The strategy options used in Sapphire. 6 | */ 7 | export interface FlagStrategyOptions { 8 | /** 9 | * The accepted flags. Flags are key-only identifiers that can be placed anywhere in the command. Two different types are accepted: 10 | * * An array of strings, e.g. [`silent`]. 11 | * * A boolean defining whether the strategy should accept all keys (`true`) or none at all (`false`). 12 | * @default [] 13 | */ 14 | flags?: readonly string[] | boolean; 15 | 16 | /** 17 | * The accepted options. Options are key-value identifiers that can be placed anywhere in the command. Two different types are accepted: 18 | * * An array of strings, e.g. [`silent`]. 19 | * * A boolean defining whether the strategy should accept all keys (`true`) or none at all (`false`). 20 | * @default [] 21 | */ 22 | options?: readonly string[] | boolean; 23 | 24 | /** 25 | * The prefixes for both flags and options. 26 | * @default ['--', '-', '—'] 27 | */ 28 | prefixes?: string[]; 29 | 30 | /** 31 | * The flag separators. 32 | * @default ['=', ':'] 33 | */ 34 | separators?: string[]; 35 | } 36 | 37 | const never = () => Option.none; 38 | const always = () => true; 39 | 40 | export class FlagUnorderedStrategy extends PrefixedStrategy { 41 | public readonly flags: readonly string[] | true; 42 | public readonly options: readonly string[] | true; 43 | 44 | public constructor({ flags, options, prefixes = ['--', '-', '—'], separators = ['=', ':'] }: FlagStrategyOptions = {}) { 45 | super(prefixes, separators); 46 | this.flags = flags || []; 47 | this.options = options || []; 48 | 49 | if (this.flags === true) this.allowedFlag = always; 50 | else if (this.flags.length === 0) this.matchFlag = never; 51 | 52 | if (this.options === true) { 53 | this.allowedOption = always; 54 | } else if (this.options.length === 0) { 55 | this.matchOption = never; 56 | } 57 | } 58 | 59 | public override matchFlag(s: string): Option { 60 | const result = super.matchFlag(s); 61 | 62 | // The flag must be an allowed one. 63 | if (result.isSomeAnd((value) => this.allowedFlag(value))) return result; 64 | 65 | // If it did not match a flag, return null. 66 | return Option.none; 67 | } 68 | 69 | public override matchOption(s: string): Option { 70 | const result = super.matchOption(s); 71 | 72 | if (result.isSomeAnd((option) => this.allowedOption(option[0]))) return result; 73 | 74 | return Option.none; 75 | } 76 | 77 | private allowedFlag(s: string) { 78 | return (this.flags as readonly string[]).includes(s); 79 | } 80 | 81 | private allowedOption(s: string) { 82 | return (this.options as readonly string[]).includes(s); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/listeners/CoreInteractionCreate.ts: -------------------------------------------------------------------------------- 1 | import { container } from '@sapphire/pieces'; 2 | import type { Interaction } from 'discord.js'; 3 | import { Listener } from '../lib/structures/Listener'; 4 | import { Events } from '../lib/types/Events'; 5 | 6 | export class CoreListener extends Listener { 7 | public constructor(context: Listener.LoaderContext) { 8 | super(context, { event: Events.InteractionCreate }); 9 | } 10 | 11 | public async run(interaction: Interaction) { 12 | if (interaction.isChatInputCommand()) { 13 | this.container.client.emit(Events.PossibleChatInputCommand, interaction); 14 | } else if (interaction.isContextMenuCommand()) { 15 | this.container.client.emit(Events.PossibleContextMenuCommand, interaction); 16 | } else if (interaction.isAutocomplete()) { 17 | this.container.client.emit(Events.PossibleAutocompleteInteraction, interaction); 18 | } else if (interaction.isMessageComponent() || interaction.isModalSubmit()) { 19 | await this.container.stores.get('interaction-handlers').run(interaction); 20 | } else { 21 | this.container.logger.warn(`[Sapphire ${this.location.name}] Unhandled interaction type: ${(interaction as any).constructor.name}`); 22 | } 23 | } 24 | } 25 | 26 | void container.stores.loadPiece({ 27 | name: 'CoreInteractionCreate', 28 | piece: CoreListener, 29 | store: 'listeners' 30 | }); 31 | -------------------------------------------------------------------------------- /src/listeners/CoreReady.ts: -------------------------------------------------------------------------------- 1 | import { container } from '@sapphire/pieces'; 2 | import { Listener } from '../lib/structures/Listener'; 3 | import { Events } from '../lib/types/Events'; 4 | import { handleRegistryAPICalls } from '../lib/utils/application-commands/ApplicationCommandRegistries'; 5 | 6 | export class CoreListener extends Listener { 7 | public constructor(context: Listener.LoaderContext) { 8 | super(context, { event: Events.ClientReady, once: true }); 9 | } 10 | 11 | public async run() { 12 | this.container.client.id ??= this.container.client.user?.id ?? null; 13 | 14 | await handleRegistryAPICalls(); 15 | } 16 | } 17 | 18 | void container.stores.loadPiece({ 19 | name: 'CoreReady', 20 | piece: CoreListener, 21 | store: 'listeners' 22 | }); 23 | -------------------------------------------------------------------------------- /src/listeners/_load.ts: -------------------------------------------------------------------------------- 1 | import './CoreInteractionCreate'; 2 | import './CoreReady'; 3 | import './application-commands/CorePossibleAutocompleteInteraction'; 4 | import './application-commands/chat-input/CoreChatInputCommandAccepted'; 5 | import './application-commands/chat-input/CorePossibleChatInputCommand'; 6 | import './application-commands/chat-input/CorePreChatInputCommandRun'; 7 | import './application-commands/context-menu/CoreContextMenuCommandAccepted'; 8 | import './application-commands/context-menu/CorePossibleContextMenuCommand'; 9 | import './application-commands/context-menu/CorePreContextMenuCommandRun'; 10 | -------------------------------------------------------------------------------- /src/listeners/application-commands/CorePossibleAutocompleteInteraction.ts: -------------------------------------------------------------------------------- 1 | import { container } from '@sapphire/pieces'; 2 | import type { AutocompleteInteraction } from 'discord.js'; 3 | import { Listener } from '../../lib/structures/Listener'; 4 | import type { AutocompleteCommand } from '../../lib/types/CommandTypes'; 5 | import { Events } from '../../lib/types/Events'; 6 | 7 | export class CoreListener extends Listener { 8 | public constructor(context: Listener.LoaderContext) { 9 | super(context, { event: Events.PossibleAutocompleteInteraction }); 10 | } 11 | 12 | public async run(interaction: AutocompleteInteraction) { 13 | const { stores } = this.container; 14 | 15 | const commandStore = stores.get('commands'); 16 | 17 | // Try resolving in command 18 | const command = commandStore.get(interaction.commandId) ?? commandStore.get(interaction.commandName); 19 | 20 | if (command?.autocompleteRun) { 21 | try { 22 | await command.autocompleteRun(interaction); 23 | this.container.client.emit(Events.CommandAutocompleteInteractionSuccess, { 24 | command: command as AutocompleteCommand, 25 | context: { commandId: interaction.commandId, commandName: interaction.commandName }, 26 | interaction 27 | }); 28 | } catch (err) { 29 | this.container.client.emit(Events.CommandAutocompleteInteractionError, err, { 30 | command: command as AutocompleteCommand, 31 | context: { commandId: interaction.commandId, commandName: interaction.commandName }, 32 | interaction 33 | }); 34 | } 35 | return; 36 | } 37 | 38 | // Unless we ran a command handler, always call interaction handlers with the interaction 39 | await this.container.stores.get('interaction-handlers').run(interaction); 40 | } 41 | } 42 | 43 | void container.stores.loadPiece({ 44 | name: 'CorePossibleAutocompleteInteraction', 45 | piece: CoreListener, 46 | store: 'listeners' 47 | }); 48 | -------------------------------------------------------------------------------- /src/listeners/application-commands/chat-input/CoreChatInputCommandAccepted.ts: -------------------------------------------------------------------------------- 1 | import { container } from '@sapphire/pieces'; 2 | import { Result } from '@sapphire/result'; 3 | import { Stopwatch } from '@sapphire/stopwatch'; 4 | import { Listener } from '../../../lib/structures/Listener'; 5 | import { Events, type ChatInputCommandAcceptedPayload } from '../../../lib/types/Events'; 6 | 7 | export class CoreListener extends Listener { 8 | public constructor(context: Listener.LoaderContext) { 9 | super(context, { event: Events.ChatInputCommandAccepted }); 10 | } 11 | 12 | public async run(payload: ChatInputCommandAcceptedPayload) { 13 | const { command, context, interaction } = payload; 14 | 15 | const result = await Result.fromAsync(async () => { 16 | this.container.client.emit(Events.ChatInputCommandRun, interaction, command, { ...payload }); 17 | 18 | const stopwatch = new Stopwatch(); 19 | const result = await command.chatInputRun(interaction, context); 20 | const { duration } = stopwatch.stop(); 21 | 22 | this.container.client.emit(Events.ChatInputCommandSuccess, { ...payload, result, duration }); 23 | 24 | return duration; 25 | }); 26 | 27 | result.inspectErr((error) => this.container.client.emit(Events.ChatInputCommandError, error, { ...payload, duration: -1 })); 28 | 29 | this.container.client.emit(Events.ChatInputCommandFinish, interaction, command, { 30 | ...payload, 31 | success: result.isOk(), 32 | duration: result.unwrapOr(-1) 33 | }); 34 | } 35 | } 36 | 37 | void container.stores.loadPiece({ 38 | name: 'CoreChatInputCommandAccepted', 39 | piece: CoreListener, 40 | store: 'listeners' 41 | }); 42 | -------------------------------------------------------------------------------- /src/listeners/application-commands/chat-input/CorePossibleChatInputCommand.ts: -------------------------------------------------------------------------------- 1 | import { container } from '@sapphire/pieces'; 2 | import type { ChatInputCommandInteraction } from 'discord.js'; 3 | import { Listener } from '../../../lib/structures/Listener'; 4 | import type { ChatInputCommand } from '../../../lib/types/CommandTypes'; 5 | import { Events } from '../../../lib/types/Events'; 6 | 7 | export class CoreListener extends Listener { 8 | public constructor(context: Listener.LoaderContext) { 9 | super(context, { event: Events.PossibleChatInputCommand }); 10 | } 11 | 12 | public run(interaction: ChatInputCommandInteraction) { 13 | const { client, stores } = this.container; 14 | const commandStore = stores.get('commands'); 15 | 16 | const command = commandStore.get(interaction.commandId) ?? commandStore.get(interaction.commandName); 17 | if (!command) { 18 | client.emit(Events.UnknownChatInputCommand, { 19 | interaction, 20 | context: { commandId: interaction.commandId, commandName: interaction.commandName } 21 | }); 22 | return; 23 | } 24 | 25 | if (!command.chatInputRun) { 26 | client.emit(Events.CommandDoesNotHaveChatInputCommandHandler, { 27 | command, 28 | interaction, 29 | context: { commandId: interaction.commandId, commandName: interaction.commandName } 30 | }); 31 | return; 32 | } 33 | 34 | client.emit(Events.PreChatInputCommandRun, { 35 | command: command as ChatInputCommand, 36 | context: { commandId: interaction.commandId, commandName: interaction.commandName }, 37 | interaction 38 | }); 39 | } 40 | } 41 | 42 | void container.stores.loadPiece({ 43 | name: 'CorePossibleChatInputCommand', 44 | piece: CoreListener, 45 | store: 'listeners' 46 | }); 47 | -------------------------------------------------------------------------------- /src/listeners/application-commands/chat-input/CorePreChatInputCommandRun.ts: -------------------------------------------------------------------------------- 1 | import { container } from '@sapphire/pieces'; 2 | import { Listener } from '../../../lib/structures/Listener'; 3 | import { Events, type PreChatInputCommandRunPayload } from '../../../lib/types/Events'; 4 | 5 | export class CoreListener extends Listener { 6 | public constructor(context: Listener.LoaderContext) { 7 | super(context, { event: Events.PreChatInputCommandRun }); 8 | } 9 | 10 | public async run(payload: PreChatInputCommandRunPayload) { 11 | const { command, interaction } = payload; 12 | 13 | // Run global preconditions: 14 | const globalResult = await this.container.stores.get('preconditions').chatInputRun(interaction, command, payload as any); 15 | if (globalResult.isErr()) { 16 | this.container.client.emit(Events.ChatInputCommandDenied, globalResult.unwrapErr(), payload); 17 | return; 18 | } 19 | 20 | // Run command-specific preconditions: 21 | const localResult = await command.preconditions.chatInputRun(interaction, command, payload as any); 22 | if (localResult.isErr()) { 23 | this.container.client.emit(Events.ChatInputCommandDenied, localResult.unwrapErr(), payload); 24 | return; 25 | } 26 | 27 | this.container.client.emit(Events.ChatInputCommandAccepted, payload); 28 | } 29 | } 30 | 31 | void container.stores.loadPiece({ 32 | name: 'CorePreChatInputCommandRun', 33 | piece: CoreListener, 34 | store: 'listeners' 35 | }); 36 | -------------------------------------------------------------------------------- /src/listeners/application-commands/context-menu/CoreContextMenuCommandAccepted.ts: -------------------------------------------------------------------------------- 1 | import { container } from '@sapphire/pieces'; 2 | import { Result } from '@sapphire/result'; 3 | import { Stopwatch } from '@sapphire/stopwatch'; 4 | import { Listener } from '../../../lib/structures/Listener'; 5 | import { Events, type ContextMenuCommandAcceptedPayload } from '../../../lib/types/Events'; 6 | 7 | export class CoreListener extends Listener { 8 | public constructor(context: Listener.LoaderContext) { 9 | super(context, { event: Events.ContextMenuCommandAccepted }); 10 | } 11 | 12 | public async run(payload: ContextMenuCommandAcceptedPayload) { 13 | const { command, context, interaction } = payload; 14 | 15 | const result = await Result.fromAsync(async () => { 16 | this.container.client.emit(Events.ContextMenuCommandRun, interaction, command, { ...payload }); 17 | 18 | const stopwatch = new Stopwatch(); 19 | const result = await command.contextMenuRun(interaction, context); 20 | const { duration } = stopwatch.stop(); 21 | 22 | this.container.client.emit(Events.ContextMenuCommandSuccess, { ...payload, result, duration }); 23 | 24 | return duration; 25 | }); 26 | 27 | result.inspectErr((error) => this.container.client.emit(Events.ContextMenuCommandError, error, { ...payload, duration: -1 })); 28 | 29 | this.container.client.emit(Events.ContextMenuCommandFinish, interaction, command, { 30 | ...payload, 31 | success: result.isOk(), 32 | duration: result.unwrapOr(-1) 33 | }); 34 | } 35 | } 36 | 37 | void container.stores.loadPiece({ 38 | name: 'CoreContextMenuCommandAccepted', 39 | piece: CoreListener, 40 | store: 'listeners' 41 | }); 42 | -------------------------------------------------------------------------------- /src/listeners/application-commands/context-menu/CorePossibleContextMenuCommand.ts: -------------------------------------------------------------------------------- 1 | import { container } from '@sapphire/pieces'; 2 | import type { ContextMenuCommandInteraction } from 'discord.js'; 3 | import { Listener } from '../../../lib/structures/Listener'; 4 | import type { ContextMenuCommand } from '../../../lib/types/CommandTypes'; 5 | import { Events } from '../../../lib/types/Events'; 6 | 7 | export class CoreListener extends Listener { 8 | public constructor(context: Listener.LoaderContext) { 9 | super(context, { event: Events.PossibleContextMenuCommand }); 10 | } 11 | 12 | public run(interaction: ContextMenuCommandInteraction) { 13 | const { client, stores } = this.container; 14 | const commandStore = stores.get('commands'); 15 | 16 | const command = commandStore.get(interaction.commandId) ?? commandStore.get(interaction.commandName); 17 | if (!command) { 18 | client.emit(Events.UnknownContextMenuCommand, { 19 | interaction, 20 | context: { commandId: interaction.commandId, commandName: interaction.commandName } 21 | }); 22 | return; 23 | } 24 | 25 | if (!command.contextMenuRun) { 26 | client.emit(Events.CommandDoesNotHaveContextMenuCommandHandler, { 27 | command, 28 | interaction, 29 | context: { commandId: interaction.commandId, commandName: interaction.commandName } 30 | }); 31 | return; 32 | } 33 | 34 | client.emit(Events.PreContextMenuCommandRun, { 35 | command: command as ContextMenuCommand, 36 | context: { commandId: interaction.commandId, commandName: interaction.commandName }, 37 | interaction 38 | }); 39 | } 40 | } 41 | 42 | void container.stores.loadPiece({ 43 | name: 'CorePossibleContextMenuCommand', 44 | piece: CoreListener, 45 | store: 'listeners' 46 | }); 47 | -------------------------------------------------------------------------------- /src/listeners/application-commands/context-menu/CorePreContextMenuCommandRun.ts: -------------------------------------------------------------------------------- 1 | import { container } from '@sapphire/pieces'; 2 | import { Listener } from '../../../lib/structures/Listener'; 3 | import { Events, type PreContextMenuCommandRunPayload } from '../../../lib/types/Events'; 4 | 5 | export class CoreListener extends Listener { 6 | public constructor(context: Listener.LoaderContext) { 7 | super(context, { event: Events.PreContextMenuCommandRun }); 8 | } 9 | 10 | public async run(payload: PreContextMenuCommandRunPayload) { 11 | const { command, interaction } = payload; 12 | 13 | // Run global preconditions: 14 | const globalResult = await this.container.stores.get('preconditions').contextMenuRun(interaction, command, payload as any); 15 | if (globalResult.isErr()) { 16 | this.container.client.emit(Events.ContextMenuCommandDenied, globalResult.unwrapErr(), payload); 17 | return; 18 | } 19 | 20 | // Run command-specific preconditions: 21 | const localResult = await command.preconditions.contextMenuRun(interaction, command, payload as any); 22 | if (localResult.isErr()) { 23 | this.container.client.emit(Events.ContextMenuCommandDenied, localResult.unwrapErr(), payload); 24 | return; 25 | } 26 | 27 | this.container.client.emit(Events.ContextMenuCommandAccepted, payload); 28 | } 29 | } 30 | 31 | void container.stores.loadPiece({ 32 | name: 'CorePreContextMenuCommandRun', 33 | piece: CoreListener, 34 | store: 'listeners' 35 | }); 36 | -------------------------------------------------------------------------------- /src/optional-listeners/application-command-registries-listeners/CoreApplicationCommandRegistriesInitialising.ts: -------------------------------------------------------------------------------- 1 | import { Listener } from '../../lib/structures/Listener'; 2 | import { Events } from '../../lib/types/Events'; 3 | 4 | export class CoreListener extends Listener { 5 | public constructor(context: Listener.LoaderContext) { 6 | super(context, { event: Events.ApplicationCommandRegistriesInitialising, once: true }); 7 | } 8 | 9 | public run() { 10 | this.container.logger.info('ApplicationCommandRegistries: Initializing...'); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/optional-listeners/application-command-registries-listeners/CoreApplicationCommandRegistriesRegistered.ts: -------------------------------------------------------------------------------- 1 | import { Listener } from '../../lib/structures/Listener'; 2 | import { Events } from '../../lib/types/Events'; 3 | import type { ApplicationCommandRegistry } from '../../lib/utils/application-commands/ApplicationCommandRegistry'; 4 | 5 | export class CoreListener extends Listener { 6 | public constructor(context: Listener.LoaderContext) { 7 | super(context, { event: Events.ApplicationCommandRegistriesRegistered, once: true }); 8 | } 9 | 10 | public run(_registries: Map, timeTaken: number) { 11 | this.container.logger.info(`ApplicationCommandRegistries: Took ${timeTaken.toLocaleString()}ms to initialize.`); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/optional-listeners/application-command-registries-listeners/_load.ts: -------------------------------------------------------------------------------- 1 | import { container } from '@sapphire/pieces'; 2 | import { CoreListener as CoreApplicationCommandRegistriesInitialising } from './CoreApplicationCommandRegistriesInitialising'; 3 | import { CoreListener as CoreApplicationCommandRegistriesRegistered } from './CoreApplicationCommandRegistriesRegistered'; 4 | 5 | export function loadApplicationCommandRegistriesListeners() { 6 | const store = 'listeners' as const; 7 | void container.stores.loadPiece({ 8 | name: 'CoreApplicationCommandRegistriesInitialising', 9 | piece: CoreApplicationCommandRegistriesInitialising, 10 | store 11 | }); 12 | void container.stores.loadPiece({ 13 | name: 'CoreApplicationCommandRegistriesRegistered', 14 | piece: CoreApplicationCommandRegistriesRegistered, 15 | store 16 | }); 17 | } 18 | -------------------------------------------------------------------------------- /src/optional-listeners/error-listeners/CoreChatInputCommandError.ts: -------------------------------------------------------------------------------- 1 | import { Listener } from '../../lib/structures/Listener'; 2 | import { Events, type ChatInputCommandErrorPayload } from '../../lib/types/Events'; 3 | 4 | export class CoreListener extends Listener { 5 | public constructor(context: Listener.LoaderContext) { 6 | super(context, { event: Events.ChatInputCommandError }); 7 | } 8 | 9 | public run(error: unknown, context: ChatInputCommandErrorPayload) { 10 | const { name, location } = context.command; 11 | this.container.logger.error(`Encountered error on chat input command "${name}" at path "${location.full}"`, error); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/optional-listeners/error-listeners/CoreCommandApplicationCommandRegistryError.ts: -------------------------------------------------------------------------------- 1 | import type { Command } from '../../lib/structures/Command'; 2 | import { Listener } from '../../lib/structures/Listener'; 3 | import { Events } from '../../lib/types/Events'; 4 | 5 | export class CoreListener extends Listener { 6 | public constructor(context: Listener.LoaderContext) { 7 | super(context, { event: Events.CommandApplicationCommandRegistryError }); 8 | } 9 | 10 | public run(error: unknown, command: Command) { 11 | const { name, location } = command; 12 | this.container.logger.error( 13 | `Encountered error while handling the command application command registry for command "${name}" at path "${location.full}"`, 14 | error 15 | ); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/optional-listeners/error-listeners/CoreCommandAutocompleteInteractionError.ts: -------------------------------------------------------------------------------- 1 | import { Listener } from '../../lib/structures/Listener'; 2 | import { Events, type AutocompleteInteractionPayload } from '../../lib/types/Events'; 3 | 4 | export class CoreListener extends Listener { 5 | public constructor(context: Listener.LoaderContext) { 6 | super(context, { event: Events.CommandAutocompleteInteractionError }); 7 | } 8 | 9 | public run(error: unknown, context: AutocompleteInteractionPayload) { 10 | const { name, location } = context.command; 11 | this.container.logger.error( 12 | `Encountered error while handling an autocomplete run method on command "${name}" at path "${location.full}"`, 13 | error 14 | ); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/optional-listeners/error-listeners/CoreContextMenuCommandError.ts: -------------------------------------------------------------------------------- 1 | import { Listener } from '../../lib/structures/Listener'; 2 | import { Events, type ContextMenuCommandErrorPayload } from '../../lib/types/Events'; 3 | 4 | export class CoreListener extends Listener { 5 | public constructor(context: Listener.LoaderContext) { 6 | super(context, { event: Events.ContextMenuCommandError }); 7 | } 8 | 9 | public run(error: unknown, context: ContextMenuCommandErrorPayload) { 10 | const { name, location } = context.command; 11 | this.container.logger.error(`Encountered error on message command "${name}" at path "${location.full}"`, error); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/optional-listeners/error-listeners/CoreInteractionHandlerError.ts: -------------------------------------------------------------------------------- 1 | import { Listener } from '../../lib/structures/Listener'; 2 | import { Events, type InteractionHandlerError } from '../../lib/types/Events'; 3 | 4 | export class CoreListener extends Listener { 5 | public constructor(context: Listener.LoaderContext) { 6 | super(context, { event: Events.InteractionHandlerError }); 7 | } 8 | 9 | public run(error: unknown, context: InteractionHandlerError) { 10 | const { name, location } = context.handler; 11 | this.container.logger.error( 12 | `Encountered error while handling an interaction handler run method for interaction-handler "${name}" at path "${location.full}"`, 13 | error 14 | ); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/optional-listeners/error-listeners/CoreInteractionHandlerParseError.ts: -------------------------------------------------------------------------------- 1 | import { Listener } from '../../lib/structures/Listener'; 2 | import { Events, type InteractionHandlerParseError as InteractionHandlerParseErrorPayload } from '../../lib/types/Events'; 3 | 4 | export class CoreListener extends Listener { 5 | public constructor(context: Listener.LoaderContext) { 6 | super(context, { event: Events.InteractionHandlerParseError }); 7 | } 8 | 9 | public run(error: unknown, context: InteractionHandlerParseErrorPayload) { 10 | const { name, location } = context.handler; 11 | this.container.logger.error( 12 | `Encountered error while handling an interaction handler parse method for interaction-handler "${name}" at path "${location.full}"`, 13 | error 14 | ); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/optional-listeners/error-listeners/CoreListenerError.ts: -------------------------------------------------------------------------------- 1 | import { Listener } from '../../lib/structures/Listener'; 2 | import { Events, type ListenerErrorPayload } from '../../lib/types/Events'; 3 | 4 | export class CoreListener extends Listener { 5 | public constructor(context: Listener.LoaderContext) { 6 | super(context, { event: Events.ListenerError }); 7 | } 8 | 9 | public run(error: unknown, context: ListenerErrorPayload) { 10 | const { name, event, location } = context.piece; 11 | this.container.logger.error(`Encountered error on event listener "${name}" for event "${String(event)}" at path "${location.full}"`, error); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/optional-listeners/error-listeners/CoreMessageCommandError.ts: -------------------------------------------------------------------------------- 1 | import { Listener } from '../../lib/structures/Listener'; 2 | import { Events, type MessageCommandErrorPayload } from '../../lib/types/Events'; 3 | 4 | export class CoreListener extends Listener { 5 | public constructor(context: Listener.LoaderContext) { 6 | super(context, { event: Events.MessageCommandError }); 7 | } 8 | 9 | public run(error: unknown, context: MessageCommandErrorPayload) { 10 | const { name, location } = context.command; 11 | this.container.logger.error(`Encountered error on message command "${name}" at path "${location.full}"`, error); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/optional-listeners/error-listeners/_load.ts: -------------------------------------------------------------------------------- 1 | import { container } from '@sapphire/pieces'; 2 | import { CoreListener as CoreChatInputCommandError } from './CoreChatInputCommandError'; 3 | import { CoreListener as CoreCommandApplicationCommandRegistryError } from './CoreCommandApplicationCommandRegistryError'; 4 | import { CoreListener as CoreCommandAutocompleteInteractionError } from './CoreCommandAutocompleteInteractionError'; 5 | import { CoreListener as CoreContextMenuCommandError } from './CoreContextMenuCommandError'; 6 | import { CoreListener as CoreInteractionHandlerError } from './CoreInteractionHandlerError'; 7 | import { CoreListener as CoreInteractionHandlerParseError } from './CoreInteractionHandlerParseError'; 8 | import { CoreListener as CoreListenerError } from './CoreListenerError'; 9 | import { CoreListener as CoreMessageCommandError } from './CoreMessageCommandError'; 10 | 11 | export function loadErrorListeners() { 12 | const store = 'listeners' as const; 13 | void container.stores.loadPiece({ name: 'CoreChatInputCommandError', piece: CoreChatInputCommandError, store }); 14 | void container.stores.loadPiece({ name: 'CoreCommandApplicationCommandRegistryError', piece: CoreCommandApplicationCommandRegistryError, store }); 15 | void container.stores.loadPiece({ name: 'CoreCommandAutocompleteInteractionError', piece: CoreCommandAutocompleteInteractionError, store }); 16 | void container.stores.loadPiece({ name: 'CoreContextMenuCommandError', piece: CoreContextMenuCommandError, store }); 17 | void container.stores.loadPiece({ name: 'CoreInteractionHandlerError', piece: CoreInteractionHandlerError, store }); 18 | void container.stores.loadPiece({ name: 'CoreInteractionHandlerParseError', piece: CoreInteractionHandlerParseError, store }); 19 | void container.stores.loadPiece({ name: 'CoreListenerError', piece: CoreListenerError, store }); 20 | void container.stores.loadPiece({ name: 'CoreMessageCommandError', piece: CoreMessageCommandError, store }); 21 | } 22 | -------------------------------------------------------------------------------- /src/optional-listeners/message-command-listeners/CoreMessageCommandAccepted.ts: -------------------------------------------------------------------------------- 1 | import { Result } from '@sapphire/result'; 2 | import { Stopwatch } from '@sapphire/stopwatch'; 3 | import { Listener } from '../../lib/structures/Listener'; 4 | import { Events, type MessageCommandAcceptedPayload } from '../../lib/types/Events'; 5 | 6 | export class CoreListener extends Listener { 7 | public constructor(context: Listener.LoaderContext) { 8 | super(context, { event: Events.MessageCommandAccepted }); 9 | } 10 | 11 | public async run(payload: MessageCommandAcceptedPayload) { 12 | const { message, command, parameters, context } = payload; 13 | const args = await command.messagePreParse(message, parameters, context); 14 | 15 | const result = await Result.fromAsync(async () => { 16 | message.client.emit(Events.MessageCommandRun, message, command, { ...payload, args }); 17 | 18 | const stopwatch = new Stopwatch(); 19 | const result = await command.messageRun(message, args, context); 20 | const { duration } = stopwatch.stop(); 21 | 22 | message.client.emit(Events.MessageCommandSuccess, { ...payload, args, result, duration }); 23 | 24 | return duration; 25 | }); 26 | 27 | result.inspectErr((error) => message.client.emit(Events.MessageCommandError, error, { ...payload, args, duration: -1 })); 28 | 29 | message.client.emit(Events.MessageCommandFinish, message, command, { 30 | ...payload, 31 | args, 32 | success: result.isOk(), 33 | duration: result.unwrapOr(-1) 34 | }); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/optional-listeners/message-command-listeners/CoreMessageCommandTyping.ts: -------------------------------------------------------------------------------- 1 | import { isStageChannel } from '@sapphire/discord.js-utilities'; 2 | import { ChannelType, type Message } from 'discord.js'; 3 | import { Listener } from '../../lib/structures/Listener'; 4 | import type { MessageCommand } from '../../lib/types/CommandTypes'; 5 | import { Events, type MessageCommandRunPayload } from '../../lib/types/Events'; 6 | 7 | export class CoreListener extends Listener { 8 | public constructor(context: Listener.LoaderContext) { 9 | super(context, { event: Events.MessageCommandRun }); 10 | this.enabled = this.container.client.options.typing ?? false; 11 | } 12 | 13 | public async run(message: Message, command: MessageCommand, payload: MessageCommandRunPayload) { 14 | if (!command.typing || isStageChannel(message.channel)) { 15 | return; 16 | } 17 | 18 | if (message.channel.type === ChannelType.GroupDM) { 19 | return; 20 | } 21 | 22 | try { 23 | await message.channel.sendTyping(); 24 | } catch (error) { 25 | message.client.emit(Events.MessageCommandTypingError, error as Error, { ...payload, command, message }); 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/optional-listeners/message-command-listeners/CoreMessageCreate.ts: -------------------------------------------------------------------------------- 1 | import type { Message } from 'discord.js'; 2 | import { Listener } from '../../lib/structures/Listener'; 3 | import { Events } from '../../lib/types/Events'; 4 | 5 | export class CoreListener extends Listener { 6 | public constructor(context: Listener.LoaderContext) { 7 | super(context, { event: Events.MessageCreate }); 8 | } 9 | 10 | public run(message: Message) { 11 | // Stop bots and webhooks from running commands. 12 | if (message.author.bot || message.webhookId) return; 13 | 14 | // Run the message parser. 15 | this.container.client.emit(Events.PreMessageParsed, message); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/optional-listeners/message-command-listeners/CorePreMessageCommandRun.ts: -------------------------------------------------------------------------------- 1 | import { Listener } from '../../lib/structures/Listener'; 2 | import { Events, type PreMessageCommandRunPayload } from '../../lib/types/Events'; 3 | 4 | export class CoreListener extends Listener { 5 | public constructor(context: Listener.LoaderContext) { 6 | super(context, { event: Events.PreMessageCommandRun }); 7 | } 8 | 9 | public async run(payload: PreMessageCommandRunPayload) { 10 | const { message, command } = payload; 11 | 12 | // Run global preconditions: 13 | const globalResult = await this.container.stores.get('preconditions').messageRun(message, command, payload as any); 14 | if (globalResult.isErr()) { 15 | message.client.emit(Events.MessageCommandDenied, globalResult.unwrapErr(), payload); 16 | return; 17 | } 18 | 19 | // Run command-specific preconditions: 20 | const localResult = await command.preconditions.messageRun(message, command, payload as any); 21 | if (localResult.isErr()) { 22 | message.client.emit(Events.MessageCommandDenied, localResult.unwrapErr(), payload); 23 | return; 24 | } 25 | 26 | message.client.emit(Events.MessageCommandAccepted, payload); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/optional-listeners/message-command-listeners/CorePrefixedMessage.ts: -------------------------------------------------------------------------------- 1 | import type { Message } from 'discord.js'; 2 | import { Listener } from '../../lib/structures/Listener'; 3 | import type { MessageCommand } from '../../lib/types/CommandTypes'; 4 | import { Events } from '../../lib/types/Events'; 5 | 6 | export class CoreListener extends Listener { 7 | public constructor(context: Listener.LoaderContext) { 8 | super(context, { event: Events.PrefixedMessage }); 9 | } 10 | 11 | public run(message: Message, prefix: string | RegExp) { 12 | const { client, stores } = this.container; 13 | // Retrieve the command name and validate: 14 | const commandPrefix = this.getCommandPrefix(message.content, prefix); 15 | const prefixLess = message.content.slice(commandPrefix.length).trim(); 16 | 17 | // The character that separates the command name from the arguments, this will return -1 when '[p]command' is 18 | // passed, and a non -1 value when '[p]command arg' is passed instead. 19 | const spaceIndex = prefixLess.indexOf(' '); 20 | const commandName = spaceIndex === -1 ? prefixLess : prefixLess.slice(0, spaceIndex); 21 | if (commandName.length === 0) { 22 | client.emit(Events.UnknownMessageCommandName, { message, prefix, commandPrefix }); 23 | return; 24 | } 25 | 26 | // Retrieve the command and validate: 27 | const command = stores.get('commands').get(client.options.caseInsensitiveCommands ? commandName.toLowerCase() : commandName); 28 | if (!command) { 29 | client.emit(Events.UnknownMessageCommand, { message, prefix, commandName, commandPrefix }); 30 | return; 31 | } 32 | 33 | // If the command exists but is missing a message handler, emit a different event (maybe an application command variant exists) 34 | if (!command.messageRun) { 35 | client.emit(Events.CommandDoesNotHaveMessageCommandHandler, { message, prefix, commandPrefix, command }); 36 | return; 37 | } 38 | 39 | // Run the last stage before running the command: 40 | const parameters = spaceIndex === -1 ? '' : prefixLess.substring(spaceIndex + 1).trim(); 41 | client.emit(Events.PreMessageCommandRun, { 42 | message, 43 | command: command as MessageCommand, 44 | parameters, 45 | context: { commandName, commandPrefix, prefix } 46 | }); 47 | } 48 | 49 | private getCommandPrefix(content: string, prefix: string | RegExp): string { 50 | return typeof prefix === 'string' ? prefix : prefix.exec(content)![0]; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/optional-listeners/message-command-listeners/_load.ts: -------------------------------------------------------------------------------- 1 | import { container } from '@sapphire/pieces'; 2 | import { CoreListener as CoreMessageCommandAccepted } from './CoreMessageCommandAccepted'; 3 | import { CoreListener as CoreMessageCommandTyping } from './CoreMessageCommandTyping'; 4 | import { CoreListener as CoreMessageCreate } from './CoreMessageCreate'; 5 | import { CoreListener as CorePreMessageCommandRun } from './CorePreMessageCommandRun'; 6 | import { CoreListener as CorePreMessageParser } from './CorePreMessageParser'; 7 | import { CoreListener as CorePrefixedMessage } from './CorePrefixedMessage'; 8 | 9 | export function loadMessageCommandListeners() { 10 | const store = 'listeners' as const; 11 | void container.stores.loadPiece({ name: 'CoreMessageCommandAccepted', piece: CoreMessageCommandAccepted, store }); 12 | void container.stores.loadPiece({ name: 'CoreMessageCommandTyping', piece: CoreMessageCommandTyping, store }); 13 | void container.stores.loadPiece({ name: 'CoreMessageCreate', piece: CoreMessageCreate, store }); 14 | void container.stores.loadPiece({ name: 'CorePrefixedMessage', piece: CorePrefixedMessage, store }); 15 | void container.stores.loadPiece({ name: 'CorePreMessageCommandRun', piece: CorePreMessageCommandRun, store }); 16 | void container.stores.loadPiece({ name: 'CorePreMessageParser', piece: CorePreMessageParser, store }); 17 | } 18 | -------------------------------------------------------------------------------- /src/preconditions/DMOnly.ts: -------------------------------------------------------------------------------- 1 | import type { ChatInputCommandInteraction, ContextMenuCommandInteraction, Message } from 'discord.js'; 2 | import { Identifiers } from '../lib/errors/Identifiers'; 3 | import { AllFlowsPrecondition } from '../lib/structures/Precondition'; 4 | import { container } from '@sapphire/pieces'; 5 | 6 | export class CorePrecondition extends AllFlowsPrecondition { 7 | public messageRun(message: Message): AllFlowsPrecondition.Result { 8 | return message.guild === null ? this.ok() : this.makeSharedError(); 9 | } 10 | 11 | public chatInputRun(interaction: ChatInputCommandInteraction): AllFlowsPrecondition.Result { 12 | return interaction.guildId === null ? this.ok() : this.makeSharedError(); 13 | } 14 | 15 | public contextMenuRun(interaction: ContextMenuCommandInteraction): AllFlowsPrecondition.Result { 16 | return interaction.guildId === null ? this.ok() : this.makeSharedError(); 17 | } 18 | 19 | private makeSharedError(): AllFlowsPrecondition.Result { 20 | return this.error({ 21 | identifier: Identifiers.PreconditionDMOnly, 22 | message: 'You cannot run this command outside DMs.' 23 | }); 24 | } 25 | } 26 | 27 | void container.stores.loadPiece({ 28 | name: 'DMOnly', 29 | piece: CorePrecondition, 30 | store: 'preconditions' 31 | }); 32 | -------------------------------------------------------------------------------- /src/preconditions/Enabled.ts: -------------------------------------------------------------------------------- 1 | import { container } from '@sapphire/pieces'; 2 | import type { ChatInputCommandInteraction, ContextMenuCommandInteraction, Message } from 'discord.js'; 3 | import { Identifiers } from '../lib/errors/Identifiers'; 4 | import type { Command } from '../lib/structures/Command'; 5 | import { AllFlowsPrecondition } from '../lib/structures/Precondition'; 6 | 7 | export class CorePrecondition extends AllFlowsPrecondition { 8 | public constructor(context: AllFlowsPrecondition.LoaderContext) { 9 | super(context, { position: 10 }); 10 | } 11 | 12 | public messageRun(_: Message, command: Command, context: AllFlowsPrecondition.Context): AllFlowsPrecondition.Result { 13 | return command.enabled 14 | ? this.ok() 15 | : this.error({ identifier: Identifiers.CommandDisabled, message: 'This message command is disabled.', context }); 16 | } 17 | 18 | public chatInputRun(_: ChatInputCommandInteraction, command: Command, context: AllFlowsPrecondition.Context): AllFlowsPrecondition.Result { 19 | return command.enabled 20 | ? this.ok() 21 | : this.error({ identifier: Identifiers.CommandDisabled, message: 'This chat input command is disabled.', context }); 22 | } 23 | 24 | public contextMenuRun(_: ContextMenuCommandInteraction, command: Command, context: AllFlowsPrecondition.Context): AllFlowsPrecondition.Result { 25 | return command.enabled 26 | ? this.ok() 27 | : this.error({ identifier: Identifiers.CommandDisabled, message: 'This context menu command is disabled.', context }); 28 | } 29 | } 30 | 31 | void container.stores.loadPiece({ 32 | name: 'Enabled', 33 | piece: CorePrecondition, 34 | store: 'preconditions' 35 | }); 36 | -------------------------------------------------------------------------------- /src/preconditions/GuildNewsOnly.ts: -------------------------------------------------------------------------------- 1 | import { container } from '@sapphire/pieces'; 2 | import { ChannelType, ChatInputCommandInteraction, ContextMenuCommandInteraction, Message, type TextBasedChannelTypes } from 'discord.js'; 3 | import { Identifiers } from '../lib/errors/Identifiers'; 4 | import { AllFlowsPrecondition } from '../lib/structures/Precondition'; 5 | 6 | export class CorePrecondition extends AllFlowsPrecondition { 7 | private readonly allowedTypes: TextBasedChannelTypes[] = [ChannelType.GuildAnnouncement, ChannelType.AnnouncementThread]; 8 | 9 | public messageRun(message: Message): AllFlowsPrecondition.Result { 10 | return this.allowedTypes.includes(message.channel.type) ? this.ok() : this.makeSharedError(); 11 | } 12 | 13 | public async chatInputRun(interaction: ChatInputCommandInteraction): AllFlowsPrecondition.AsyncResult { 14 | const channel = await this.fetchChannelFromInteraction(interaction); 15 | 16 | return this.allowedTypes.includes(channel.type) ? this.ok() : this.makeSharedError(); 17 | } 18 | 19 | public async contextMenuRun(interaction: ContextMenuCommandInteraction): AllFlowsPrecondition.AsyncResult { 20 | const channel = await this.fetchChannelFromInteraction(interaction); 21 | 22 | return this.allowedTypes.includes(channel.type) ? this.ok() : this.makeSharedError(); 23 | } 24 | 25 | private makeSharedError(): AllFlowsPrecondition.Result { 26 | return this.error({ 27 | identifier: Identifiers.PreconditionGuildNewsOnly, 28 | message: 'You can only run this command in server announcement channels.' 29 | }); 30 | } 31 | } 32 | 33 | void container.stores.loadPiece({ 34 | name: 'GuildNewsOnly', 35 | piece: CorePrecondition, 36 | store: 'preconditions' 37 | }); 38 | -------------------------------------------------------------------------------- /src/preconditions/GuildNewsThreadOnly.ts: -------------------------------------------------------------------------------- 1 | import { container } from '@sapphire/pieces'; 2 | import { ChannelType, ChatInputCommandInteraction, ContextMenuCommandInteraction, Message } from 'discord.js'; 3 | import { Identifiers } from '../lib/errors/Identifiers'; 4 | import { AllFlowsPrecondition } from '../lib/structures/Precondition'; 5 | 6 | export class CorePrecondition extends AllFlowsPrecondition { 7 | public messageRun(message: Message): AllFlowsPrecondition.Result { 8 | return message.thread?.type === ChannelType.AnnouncementThread ? this.ok() : this.makeSharedError(); 9 | } 10 | 11 | public async chatInputRun(interaction: ChatInputCommandInteraction): AllFlowsPrecondition.AsyncResult { 12 | const channel = await this.fetchChannelFromInteraction(interaction); 13 | return channel.type === ChannelType.AnnouncementThread ? this.ok() : this.makeSharedError(); 14 | } 15 | 16 | public async contextMenuRun(interaction: ContextMenuCommandInteraction): AllFlowsPrecondition.AsyncResult { 17 | const channel = await this.fetchChannelFromInteraction(interaction); 18 | return channel.type === ChannelType.AnnouncementThread ? this.ok() : this.makeSharedError(); 19 | } 20 | 21 | private makeSharedError(): AllFlowsPrecondition.Result { 22 | return this.error({ 23 | identifier: Identifiers.PreconditionGuildNewsThreadOnly, 24 | message: 'You can only run this command in server announcement thread channels.' 25 | }); 26 | } 27 | } 28 | 29 | void container.stores.loadPiece({ 30 | name: 'GuildNewsThreadOnly', 31 | piece: CorePrecondition, 32 | store: 'preconditions' 33 | }); 34 | -------------------------------------------------------------------------------- /src/preconditions/GuildOnly.ts: -------------------------------------------------------------------------------- 1 | import { container } from '@sapphire/pieces'; 2 | import type { ChatInputCommandInteraction, ContextMenuCommandInteraction, Message } from 'discord.js'; 3 | import { Identifiers } from '../lib/errors/Identifiers'; 4 | import { AllFlowsPrecondition } from '../lib/structures/Precondition'; 5 | 6 | export class CorePrecondition extends AllFlowsPrecondition { 7 | public messageRun(message: Message): AllFlowsPrecondition.Result { 8 | return message.guildId === null ? this.makeSharedError() : this.ok(); 9 | } 10 | 11 | public chatInputRun(interaction: ChatInputCommandInteraction): AllFlowsPrecondition.Result { 12 | return interaction.guildId === null ? this.makeSharedError() : this.ok(); 13 | } 14 | 15 | public contextMenuRun(interaction: ContextMenuCommandInteraction): AllFlowsPrecondition.Result { 16 | return interaction.guildId === null ? this.makeSharedError() : this.ok(); 17 | } 18 | 19 | private makeSharedError(): AllFlowsPrecondition.Result { 20 | return this.error({ 21 | identifier: Identifiers.PreconditionGuildOnly, 22 | message: 'You cannot run this command in DMs.' 23 | }); 24 | } 25 | } 26 | 27 | void container.stores.loadPiece({ 28 | name: 'GuildOnly', 29 | piece: CorePrecondition, 30 | store: 'preconditions' 31 | }); 32 | -------------------------------------------------------------------------------- /src/preconditions/GuildPrivateThreadOnly.ts: -------------------------------------------------------------------------------- 1 | import { container } from '@sapphire/pieces'; 2 | import { ChannelType, ChatInputCommandInteraction, ContextMenuCommandInteraction, Message } from 'discord.js'; 3 | import { Identifiers } from '../lib/errors/Identifiers'; 4 | import { AllFlowsPrecondition } from '../lib/structures/Precondition'; 5 | 6 | export class CorePrecondition extends AllFlowsPrecondition { 7 | public messageRun(message: Message): AllFlowsPrecondition.Result { 8 | return message.thread?.type === ChannelType.PrivateThread ? this.ok() : this.makeSharedError(); 9 | } 10 | 11 | public async chatInputRun(interaction: ChatInputCommandInteraction): AllFlowsPrecondition.AsyncResult { 12 | const channel = await this.fetchChannelFromInteraction(interaction); 13 | return channel.type === ChannelType.PrivateThread ? this.ok() : this.makeSharedError(); 14 | } 15 | 16 | public async contextMenuRun(interaction: ContextMenuCommandInteraction): AllFlowsPrecondition.AsyncResult { 17 | const channel = await this.fetchChannelFromInteraction(interaction); 18 | return channel.type === ChannelType.PrivateThread ? this.ok() : this.makeSharedError(); 19 | } 20 | 21 | private makeSharedError(): AllFlowsPrecondition.Result { 22 | return this.error({ 23 | identifier: Identifiers.PreconditionGuildPrivateThreadOnly, 24 | message: 'You can only run this command in private server thread channels.' 25 | }); 26 | } 27 | } 28 | 29 | void container.stores.loadPiece({ 30 | name: 'GuildPrivateThreadOnly', 31 | piece: CorePrecondition, 32 | store: 'preconditions' 33 | }); 34 | -------------------------------------------------------------------------------- /src/preconditions/GuildPublicThreadOnly.ts: -------------------------------------------------------------------------------- 1 | import { container } from '@sapphire/pieces'; 2 | import { ChannelType, ChatInputCommandInteraction, ContextMenuCommandInteraction, Message } from 'discord.js'; 3 | import { Identifiers } from '../lib/errors/Identifiers'; 4 | import { AllFlowsPrecondition } from '../lib/structures/Precondition'; 5 | 6 | export class CorePrecondition extends AllFlowsPrecondition { 7 | public messageRun(message: Message): AllFlowsPrecondition.Result { 8 | return message.thread?.type === ChannelType.PublicThread ? this.ok() : this.makeSharedError(); 9 | } 10 | 11 | public async chatInputRun(interaction: ChatInputCommandInteraction): AllFlowsPrecondition.AsyncResult { 12 | const channel = await this.fetchChannelFromInteraction(interaction); 13 | return channel.type === ChannelType.PublicThread ? this.ok() : this.makeSharedError(); 14 | } 15 | 16 | public async contextMenuRun(interaction: ContextMenuCommandInteraction): AllFlowsPrecondition.AsyncResult { 17 | const channel = await this.fetchChannelFromInteraction(interaction); 18 | return channel.type === ChannelType.PublicThread ? this.ok() : this.makeSharedError(); 19 | } 20 | 21 | private makeSharedError(): AllFlowsPrecondition.Result { 22 | return this.error({ 23 | identifier: Identifiers.PreconditionGuildPublicThreadOnly, 24 | message: 'You can only run this command in public server thread channels.' 25 | }); 26 | } 27 | } 28 | 29 | void container.stores.loadPiece({ 30 | name: 'GuildPublicThreadOnly', 31 | piece: CorePrecondition, 32 | store: 'preconditions' 33 | }); 34 | -------------------------------------------------------------------------------- /src/preconditions/GuildTextOnly.ts: -------------------------------------------------------------------------------- 1 | import { container } from '@sapphire/pieces'; 2 | import { ChannelType, ChatInputCommandInteraction, ContextMenuCommandInteraction, Message, type TextBasedChannelTypes } from 'discord.js'; 3 | import { Identifiers } from '../lib/errors/Identifiers'; 4 | import { AllFlowsPrecondition } from '../lib/structures/Precondition'; 5 | 6 | export class CorePrecondition extends AllFlowsPrecondition { 7 | private readonly allowedTypes: TextBasedChannelTypes[] = [ChannelType.GuildText, ChannelType.PublicThread, ChannelType.PrivateThread]; 8 | 9 | public messageRun(message: Message): AllFlowsPrecondition.Result { 10 | return this.allowedTypes.includes(message.channel.type) ? this.ok() : this.makeSharedError(); 11 | } 12 | 13 | public async chatInputRun(interaction: ChatInputCommandInteraction): AllFlowsPrecondition.AsyncResult { 14 | const channel = await this.fetchChannelFromInteraction(interaction); 15 | return this.allowedTypes.includes(channel.type) ? this.ok() : this.makeSharedError(); 16 | } 17 | 18 | public async contextMenuRun(interaction: ContextMenuCommandInteraction): AllFlowsPrecondition.AsyncResult { 19 | const channel = await this.fetchChannelFromInteraction(interaction); 20 | return this.allowedTypes.includes(channel.type) ? this.ok() : this.makeSharedError(); 21 | } 22 | 23 | private makeSharedError(): AllFlowsPrecondition.Result { 24 | return this.error({ 25 | identifier: Identifiers.PreconditionGuildTextOnly, 26 | message: 'You can only run this command in server text channels.' 27 | }); 28 | } 29 | } 30 | 31 | void container.stores.loadPiece({ 32 | name: 'GuildTextOnly', 33 | piece: CorePrecondition, 34 | store: 'preconditions' 35 | }); 36 | -------------------------------------------------------------------------------- /src/preconditions/GuildThreadOnly.ts: -------------------------------------------------------------------------------- 1 | import { container } from '@sapphire/pieces'; 2 | import type { ChatInputCommandInteraction, ContextMenuCommandInteraction, Message } from 'discord.js'; 3 | import { Identifiers } from '../lib/errors/Identifiers'; 4 | import { AllFlowsPrecondition } from '../lib/structures/Precondition'; 5 | 6 | export class CorePrecondition extends AllFlowsPrecondition { 7 | public messageRun(message: Message): AllFlowsPrecondition.Result { 8 | return message.thread ? this.ok() : this.makeSharedError(); 9 | } 10 | 11 | public async chatInputRun(interaction: ChatInputCommandInteraction): AllFlowsPrecondition.AsyncResult { 12 | const channel = await this.fetchChannelFromInteraction(interaction); 13 | return channel.isThread() ? this.ok() : this.makeSharedError(); 14 | } 15 | 16 | public async contextMenuRun(interaction: ContextMenuCommandInteraction): AllFlowsPrecondition.AsyncResult { 17 | const channel = await this.fetchChannelFromInteraction(interaction); 18 | return channel.isThread() ? this.ok() : this.makeSharedError(); 19 | } 20 | 21 | private makeSharedError(): AllFlowsPrecondition.Result { 22 | return this.error({ 23 | identifier: Identifiers.PreconditionThreadOnly, 24 | message: 'You can only run this command in server thread channels.' 25 | }); 26 | } 27 | } 28 | 29 | void container.stores.loadPiece({ 30 | name: 'GuildThreadOnly', 31 | piece: CorePrecondition, 32 | store: 'preconditions' 33 | }); 34 | -------------------------------------------------------------------------------- /src/preconditions/GuildVoiceOnly.ts: -------------------------------------------------------------------------------- 1 | import { isVoiceChannel } from '@sapphire/discord.js-utilities'; 2 | import { container } from '@sapphire/pieces'; 3 | import type { ChatInputCommandInteraction, ContextMenuCommandInteraction, Message } from 'discord.js'; 4 | import { Identifiers } from '../lib/errors/Identifiers'; 5 | import { AllFlowsPrecondition } from '../lib/structures/Precondition'; 6 | 7 | export class CorePrecondition extends AllFlowsPrecondition { 8 | public messageRun(message: Message): AllFlowsPrecondition.Result { 9 | return isVoiceChannel(message.channel) ? this.ok() : this.makeSharedError(); 10 | } 11 | 12 | public async chatInputRun(interaction: ChatInputCommandInteraction): AllFlowsPrecondition.AsyncResult { 13 | const channel = await this.fetchChannelFromInteraction(interaction); 14 | return isVoiceChannel(channel) ? this.ok() : this.makeSharedError(); 15 | } 16 | 17 | public async contextMenuRun(interaction: ContextMenuCommandInteraction): AllFlowsPrecondition.AsyncResult { 18 | const channel = await this.fetchChannelFromInteraction(interaction); 19 | return isVoiceChannel(channel) ? this.ok() : this.makeSharedError(); 20 | } 21 | 22 | private makeSharedError(): AllFlowsPrecondition.Result { 23 | return this.error({ 24 | identifier: Identifiers.PreconditionGuildVoiceOnly, 25 | message: 'You can only run this command in server voice channels.' 26 | }); 27 | } 28 | } 29 | 30 | void container.stores.loadPiece({ 31 | name: 'GuildVoiceOnly', 32 | piece: CorePrecondition, 33 | store: 'preconditions' 34 | }); 35 | -------------------------------------------------------------------------------- /src/preconditions/NSFW.ts: -------------------------------------------------------------------------------- 1 | import { container } from '@sapphire/pieces'; 2 | import type { ChatInputCommandInteraction, ContextMenuCommandInteraction, Message } from 'discord.js'; 3 | import { Identifiers } from '../lib/errors/Identifiers'; 4 | import { AllFlowsPrecondition } from '../lib/structures/Precondition'; 5 | 6 | export class CorePrecondition extends AllFlowsPrecondition { 7 | public messageRun(message: Message): AllFlowsPrecondition.Result { 8 | // `nsfw` is undefined in DMChannel, doing `=== true` 9 | // will result on it returning `false`. 10 | return Reflect.get(message.channel, 'nsfw') === true 11 | ? this.ok() 12 | : this.error({ identifier: Identifiers.PreconditionNSFW, message: 'You cannot run this message command outside NSFW channels.' }); 13 | } 14 | 15 | public async chatInputRun(interaction: ChatInputCommandInteraction): AllFlowsPrecondition.AsyncResult { 16 | const channel = await this.fetchChannelFromInteraction(interaction); 17 | 18 | // `nsfw` is undefined in DMChannel, doing `=== true` 19 | // will result on it returning `false`. 20 | return Reflect.get(channel, 'nsfw') === true 21 | ? this.ok() 22 | : this.error({ identifier: Identifiers.PreconditionNSFW, message: 'You cannot run this chat input command outside NSFW channels.' }); 23 | } 24 | 25 | public async contextMenuRun(interaction: ContextMenuCommandInteraction): AllFlowsPrecondition.AsyncResult { 26 | const channel = await this.fetchChannelFromInteraction(interaction); 27 | 28 | // `nsfw` is undefined in DMChannel, doing `=== true` 29 | // will result on it returning `false`. 30 | return Reflect.get(channel, 'nsfw') === true 31 | ? this.ok() 32 | : this.error({ identifier: Identifiers.PreconditionNSFW, message: 'You cannot run this command outside NSFW channels.' }); 33 | } 34 | } 35 | 36 | void container.stores.loadPiece({ 37 | name: 'NSFW', 38 | piece: CorePrecondition, 39 | store: 'preconditions' 40 | }); 41 | -------------------------------------------------------------------------------- /src/preconditions/RunIn.ts: -------------------------------------------------------------------------------- 1 | import { container } from '@sapphire/pieces'; 2 | import type { ChatInputCommandInteraction, ContextMenuCommandInteraction, Message } from 'discord.js'; 3 | import { Identifiers } from '../lib/errors/Identifiers'; 4 | import { Command } from '../lib/structures/Command'; 5 | import { AllFlowsPrecondition, type Preconditions } from '../lib/structures/Precondition'; 6 | import type { ChatInputCommand, ContextMenuCommand, MessageCommand } from '../lib/types/CommandTypes'; 7 | 8 | export interface RunInPreconditionContext extends AllFlowsPrecondition.Context { 9 | types?: Preconditions['RunIn']['types']; 10 | } 11 | 12 | export class CorePrecondition extends AllFlowsPrecondition { 13 | public override messageRun(message: Message, _: MessageCommand, context: RunInPreconditionContext): AllFlowsPrecondition.Result { 14 | const commandType = 'message'; 15 | if (!context.types) return this.ok(); 16 | 17 | const channelType = message.channel.type; 18 | 19 | if (Command.runInTypeIsSpecificsObject(context.types)) { 20 | return context.types.messageRun.includes(channelType) ? this.ok() : this.makeSharedError(context, commandType); 21 | } 22 | 23 | return context.types.includes(channelType) ? this.ok() : this.makeSharedError(context, commandType); 24 | } 25 | 26 | public override async chatInputRun( 27 | interaction: ChatInputCommandInteraction, 28 | _: ChatInputCommand, 29 | context: RunInPreconditionContext 30 | ): AllFlowsPrecondition.AsyncResult { 31 | const commandType = 'chat input'; 32 | if (!context.types) return this.ok(); 33 | 34 | const channelType = (await this.fetchChannelFromInteraction(interaction)).type; 35 | 36 | if (Command.runInTypeIsSpecificsObject(context.types)) { 37 | return context.types.chatInputRun.includes(channelType) ? this.ok() : this.makeSharedError(context, commandType); 38 | } 39 | 40 | return context.types.includes(channelType) ? this.ok() : this.makeSharedError(context, commandType); 41 | } 42 | 43 | public override async contextMenuRun( 44 | interaction: ContextMenuCommandInteraction, 45 | _: ContextMenuCommand, 46 | context: RunInPreconditionContext 47 | ): AllFlowsPrecondition.AsyncResult { 48 | const commandType = 'context menu'; 49 | if (!context.types) return this.ok(); 50 | 51 | const channelType = (await this.fetchChannelFromInteraction(interaction)).type; 52 | 53 | if (Command.runInTypeIsSpecificsObject(context.types)) { 54 | return context.types.contextMenuRun.includes(channelType) ? this.ok() : this.makeSharedError(context, commandType); 55 | } 56 | 57 | return context.types.includes(channelType) ? this.ok() : this.makeSharedError(context, commandType); 58 | } 59 | 60 | private makeSharedError(context: RunInPreconditionContext, commandType: string): AllFlowsPrecondition.Result { 61 | return this.error({ 62 | identifier: Identifiers.PreconditionRunIn, 63 | message: `You cannot run this ${commandType} command in this type of channel.`, 64 | context: { types: context.types } 65 | }); 66 | } 67 | } 68 | 69 | void container.stores.loadPiece({ 70 | name: 'RunIn', 71 | piece: CorePrecondition, 72 | store: 'preconditions' 73 | }); 74 | -------------------------------------------------------------------------------- /src/preconditions/_load.ts: -------------------------------------------------------------------------------- 1 | import './ClientPermissions'; 2 | import './Cooldown'; 3 | import './DMOnly'; 4 | import './Enabled'; 5 | import './GuildNewsOnly'; 6 | import './GuildNewsThreadOnly'; 7 | import './GuildOnly'; 8 | import './GuildPrivateThreadOnly'; 9 | import './GuildPublicThreadOnly'; 10 | import './GuildTextOnly'; 11 | import './GuildThreadOnly'; 12 | import './GuildVoiceOnly'; 13 | import './NSFW'; 14 | import './RunIn'; 15 | import './UserPermissions'; 16 | -------------------------------------------------------------------------------- /src/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.base.json", 3 | "compilerOptions": { 4 | "rootDir": "./" 5 | }, 6 | "include": ["."] 7 | } 8 | -------------------------------------------------------------------------------- /tests/Flags.test.ts: -------------------------------------------------------------------------------- 1 | import { Lexer, Parser } from '@sapphire/lexure'; 2 | import { FlagUnorderedStrategy } from '../src/lib/utils/strategies/FlagUnorderedStrategy'; 3 | 4 | const parse = (testString: string) => 5 | new Parser(new FlagUnorderedStrategy({ flags: ['f', 'hello'], options: ['o', 'option'] })).run( 6 | new Lexer({ 7 | quotes: [ 8 | ['"', '"'], 9 | ['“', '”'], 10 | ['「', '」'], 11 | ['«', '»'] 12 | ] 13 | }).run(testString) 14 | ); 15 | 16 | describe('Flag parsing strategy', () => { 17 | test('GIVEN typeof FlagStrategy THEN returns function', () => { 18 | expect(typeof FlagUnorderedStrategy).toBe('function'); 19 | }); 20 | test('GIVEN flag without value THEN returns flag', () => { 21 | const { flags, options } = parse('-f'); 22 | expect(flags.size).toBe(1); 23 | expect([...flags]).toStrictEqual(['f']); 24 | expect(options.size).toBe(0); 25 | }); 26 | 27 | test('GIVEN flag without value inside text THEN returns flag', () => { 28 | const { flags, options } = parse('commit "hello there" -f'); 29 | expect(flags.size).toBe(1); 30 | expect([...flags]).toStrictEqual(['f']); 31 | expect(options.size).toBe(0); 32 | }); 33 | 34 | test('GIVEN flag with value THEN returns nothing', () => { 35 | const { flags, options } = parse('-f=hi'); 36 | expect(flags.size).toBe(0); 37 | expect(options.size).toBe(0); 38 | }); 39 | 40 | test('GIVEN flag with value inside text THEN returns nothing', () => { 41 | const { flags, options } = parse('commit "hello there" -f=hi'); 42 | expect(flags.size).toBe(0); 43 | expect(options.size).toBe(0); 44 | }); 45 | 46 | test('GIVEN option with value THEN returns option', () => { 47 | const { flags, options } = parse('--option=world'); 48 | expect(flags.size).toBe(0); 49 | expect(options.size).toBe(1); 50 | expect(options.has('option')).toBe(true); 51 | expect(options.get('option')).toStrictEqual(['world']); 52 | }); 53 | 54 | test('GIVEN option with value inside text THEN returns option with single value', () => { 55 | const { flags, options } = parse('command --option=world'); 56 | expect(flags.size).toBe(0); 57 | expect(options.size).toBe(1); 58 | expect(options.has('option')).toBe(true); 59 | expect(options.get('option')).toStrictEqual(['world']); 60 | }); 61 | 62 | test('GIVEN option with multiple occurences inside text THEN returns option with multiple values', () => { 63 | const { flags, options } = parse('command --option=world --option=sammy'); 64 | expect(flags.size).toBe(0); 65 | expect(options.size).toBe(1); 66 | expect(options.has('option')).toBe(true); 67 | expect(options.get('option')).toStrictEqual(['world', 'sammy']); 68 | }); 69 | 70 | test('GIVEN flag inside quotes THEN returns nothing', () => { 71 | const { flags, options } = parse('commit "hello there -f"'); 72 | expect(flags.size).toBe(0); 73 | expect(options.size).toBe(0); 74 | }); 75 | 76 | test('GIVEN option without value inside quote THEN returns nothing', () => { 77 | const { flags, options } = parse('mention "try --hello"'); 78 | expect(flags.size).toBe(0); 79 | expect(options.size).toBe(0); 80 | }); 81 | }); 82 | -------------------------------------------------------------------------------- /tests/precondition-resolvers/clientPermissions.test.ts: -------------------------------------------------------------------------------- 1 | import { PermissionFlagsBits } from 'discord.js'; 2 | import { parseConstructorPreConditionsRequiredClientPermissions } from '../../src/lib/precondition-resolvers/clientPermissions'; 3 | import { CommandPreConditions } from '../../src/lib/types/Enums'; 4 | import { PreconditionContainerArray } from '../../src/lib/utils/preconditions/PreconditionContainerArray'; 5 | import type { PreconditionContainerSingle } from '../../src/lib/utils/preconditions/PreconditionContainerSingle'; 6 | import type { PermissionPreconditionContext } from '../../src/preconditions/ClientPermissions'; 7 | 8 | describe('parseConstructorPreConditionsRequiredClientPermissions', () => { 9 | test('GIVEN valid permissions THEN appends to preconditionContainerArray', () => { 10 | const preconditionContainerArray = new PreconditionContainerArray(); 11 | parseConstructorPreConditionsRequiredClientPermissions(PermissionFlagsBits.Administrator, preconditionContainerArray); 12 | expect(preconditionContainerArray.entries.length).toBe(1); 13 | expect((preconditionContainerArray.entries[0] as PreconditionContainerSingle).name).toBe(CommandPreConditions.ClientPermissions); 14 | expect( 15 | ((preconditionContainerArray.entries[0] as PreconditionContainerSingle).context as PermissionPreconditionContext).permissions?.has( 16 | PermissionFlagsBits.Administrator 17 | ) 18 | ).toBe(true); 19 | }); 20 | 21 | test('GIVEN no permissions THEN does not append to preconditionContainerArray', () => { 22 | const preconditionContainerArray = new PreconditionContainerArray(); 23 | parseConstructorPreConditionsRequiredClientPermissions(undefined, preconditionContainerArray); 24 | expect(preconditionContainerArray.entries.length).toBe(0); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /tests/precondition-resolvers/nsfw.test.ts: -------------------------------------------------------------------------------- 1 | import { parseConstructorPreConditionsNsfw } from '../../src/lib/precondition-resolvers/nsfw'; 2 | import { CommandPreConditions } from '../../src/lib/types/Enums'; 3 | import { PreconditionContainerArray } from '../../src/lib/utils/preconditions/PreconditionContainerArray'; 4 | 5 | describe('parseConstructorPreConditionsNsfw', () => { 6 | test('GIVEN nsfw true THEN appends to preconditionContainerArray', () => { 7 | const preconditionContainerArray = new PreconditionContainerArray(); 8 | parseConstructorPreConditionsNsfw(true, preconditionContainerArray); 9 | expect(preconditionContainerArray.entries.length).toBe(1); 10 | expect((preconditionContainerArray.entries[0] as any).name).toBe(CommandPreConditions.NotSafeForWork); 11 | }); 12 | 13 | test('GIVEN nsfw false THEN does not append to preconditionContainerArray', () => { 14 | const preconditionContainerArray = new PreconditionContainerArray(); 15 | parseConstructorPreConditionsNsfw(false, preconditionContainerArray); 16 | expect(preconditionContainerArray.entries.length).toBe(0); 17 | }); 18 | 19 | test('GIVEN nsfw undefined THEN does not append to preconditionContainerArray', () => { 20 | const preconditionContainerArray = new PreconditionContainerArray(); 21 | parseConstructorPreConditionsNsfw(undefined, preconditionContainerArray); 22 | expect(preconditionContainerArray.entries.length).toBe(0); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /tests/precondition-resolvers/userPermissions.test.ts: -------------------------------------------------------------------------------- 1 | import { PermissionFlagsBits } from 'discord.js'; 2 | import { parseConstructorPreConditionsRequiredUserPermissions } from '../../src/lib/precondition-resolvers/userPermissions'; 3 | import { CommandPreConditions } from '../../src/lib/types/Enums'; 4 | import { PreconditionContainerArray } from '../../src/lib/utils/preconditions/PreconditionContainerArray'; 5 | import type { PreconditionContainerSingle } from '../../src/lib/utils/preconditions/PreconditionContainerSingle'; 6 | import type { PermissionPreconditionContext } from '../../src/preconditions/ClientPermissions'; 7 | 8 | describe('parseConstructorPreConditionsRequiredUserPermissions', () => { 9 | test('GIVEN valid permissions THEN appends to preconditionContainerArray', () => { 10 | const preconditionContainerArray = new PreconditionContainerArray(); 11 | parseConstructorPreConditionsRequiredUserPermissions(PermissionFlagsBits.Administrator, preconditionContainerArray); 12 | expect(preconditionContainerArray.entries.length).toBe(1); 13 | expect((preconditionContainerArray.entries[0] as PreconditionContainerSingle).name).toBe(CommandPreConditions.UserPermissions); 14 | expect( 15 | ((preconditionContainerArray.entries[0] as PreconditionContainerSingle).context as PermissionPreconditionContext).permissions?.has( 16 | PermissionFlagsBits.Administrator 17 | ) 18 | ).toBe(true); 19 | }); 20 | 21 | test('GIVEN no permissions THEN does not append to preconditionContainerArray', () => { 22 | const preconditionContainerArray = new PreconditionContainerArray(); 23 | parseConstructorPreConditionsRequiredUserPermissions(undefined, preconditionContainerArray); 24 | expect(preconditionContainerArray.entries.length).toBe(0); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /tests/resolvers/boolean.test.ts: -------------------------------------------------------------------------------- 1 | import { Result } from '@sapphire/result'; 2 | import { Identifiers } from '../../src/lib/errors/Identifiers'; 3 | import { resolveBoolean } from '../../src/lib/resolvers/boolean'; 4 | 5 | describe('Boolean resolver tests', () => { 6 | test('GIVEN a truthy value THEN returns true', () => { 7 | expect(resolveBoolean('true')).toEqual(Result.ok(true)); 8 | expect(resolveBoolean('1')).toEqual(Result.ok(true)); 9 | expect(resolveBoolean('+')).toEqual(Result.ok(true)); 10 | expect(resolveBoolean('yes')).toEqual(Result.ok(true)); 11 | }); 12 | 13 | test('GIVEN a falsy value THEN returns false', () => { 14 | expect(resolveBoolean('false')).toEqual(Result.ok(false)); 15 | expect(resolveBoolean('0')).toEqual(Result.ok(false)); 16 | expect(resolveBoolean('-')).toEqual(Result.ok(false)); 17 | expect(resolveBoolean('no')).toEqual(Result.ok(false)); 18 | }); 19 | 20 | test('GIVEN a truthy value with custom ones THEN returns true', () => { 21 | expect(resolveBoolean('yay', { truths: ['yay'] })).toEqual(Result.ok(true)); 22 | expect(resolveBoolean('yup', { truths: ['yay', 'yup', 'yop'] })).toEqual(Result.ok(true)); 23 | }); 24 | 25 | test('GIVEN a falsy value with custom ones THEN returns false', () => { 26 | expect(resolveBoolean('nah', { falses: ['nah'] })).toEqual(Result.ok(false)); 27 | expect(resolveBoolean('nope', { falses: ['nah', 'nope', 'noooo'] })).toEqual(Result.ok(false)); 28 | }); 29 | 30 | test('GIVEN an invalid values THEN returns error', () => { 31 | expect(resolveBoolean('hello')).toEqual(Result.err(Identifiers.ArgumentBooleanError)); 32 | expect(resolveBoolean('world', { truths: ['nah', 'nope', 'noooo'] })).toEqual(Result.err(Identifiers.ArgumentBooleanError)); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /tests/resolvers/date.test.ts: -------------------------------------------------------------------------------- 1 | import { Result } from '@sapphire/result'; 2 | import { Identifiers } from '../../src/lib/errors/Identifiers'; 3 | import { resolveDate } from '../../src/lib/resolvers/date'; 4 | 5 | const DATE_2018_PLAIN_STRING = 'August 11, 2018 00:00:00'; 6 | const DATE_2018 = new Date(DATE_2018_PLAIN_STRING); 7 | 8 | const DATE_2020_PLAIN_STRING = 'August 11, 2020 00:00:00'; 9 | const DATE_2020 = new Date(DATE_2020_PLAIN_STRING); 10 | 11 | const DATE_2022_PLAIN_STRING = 'August 11, 2022 00:00:00'; 12 | const DATE_2022 = new Date(DATE_2022_PLAIN_STRING); 13 | 14 | const MINIMUM = { minimum: new Date('August 11, 2019 00:00:00').getTime() }; 15 | const MAXIMUM = { maximum: new Date('August 11, 2021 00:00:00').getTime() }; 16 | 17 | describe('Date resolver tests', () => { 18 | test('GIVEN a valid date-time THEN returns the associated timestamp', () => { 19 | expect(resolveDate(DATE_2020_PLAIN_STRING)).toEqual(Result.ok(DATE_2020)); 20 | }); 21 | test('GIVEN a valid date-time with minimum THEN returns the associated timestamp', () => { 22 | expect(resolveDate(DATE_2022_PLAIN_STRING, MINIMUM)).toEqual(Result.ok(DATE_2022)); 23 | }); 24 | test('GIVEN a valid date-time with maximum THEN returns the associated timestamp', () => { 25 | expect(resolveDate(DATE_2018_PLAIN_STRING, MAXIMUM)).toEqual(Result.ok(DATE_2018)); 26 | }); 27 | test('GIVEN a date-time before minimum THEN returns error', () => { 28 | expect(resolveDate(DATE_2018_PLAIN_STRING, MINIMUM)).toEqual(Result.err(Identifiers.ArgumentDateTooEarly)); 29 | }); 30 | test('GIVEN a date-time beyond maximum THEN returns error', () => { 31 | expect(resolveDate(DATE_2022_PLAIN_STRING, MAXIMUM)).toEqual(Result.err(Identifiers.ArgumentDateTooFar)); 32 | }); 33 | test('GIVEN an invalid date THEN returns error', () => { 34 | expect(resolveDate('hello')).toEqual(Result.err(Identifiers.ArgumentDateError)); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /tests/resolvers/emoji.test.ts: -------------------------------------------------------------------------------- 1 | import { Result } from '@sapphire/result'; 2 | import { Identifiers } from '../../src/lib/errors/Identifiers'; 3 | import { resolveEmoji } from '../../src/lib/resolvers/emoji'; 4 | 5 | describe('Emoji resolver tests', () => { 6 | test('GIVEN an unicode emoji THEN returns emojiObject', () => { 7 | const resolvedEmoji = resolveEmoji('😄'); 8 | expect(resolvedEmoji.isOk()).toBe(true); 9 | expect(() => resolvedEmoji.unwrapErr()).toThrowError(); 10 | expect(resolvedEmoji.unwrap()).toMatchObject({ id: null, name: '😄' }); 11 | }); 12 | test('GIVEN a string emoji THEN returns ArgumentEmojiError', () => { 13 | const resolvedEmoji = resolveEmoji(':smile:'); 14 | expect(resolvedEmoji).toEqual(Result.err(Identifiers.ArgumentEmojiError)); 15 | }); 16 | test('GIVEN a string THEN returns ArgumentEmojiError', () => { 17 | const resolvedEmoji = resolveEmoji('foo'); 18 | expect(resolvedEmoji).toEqual(Result.err(Identifiers.ArgumentEmojiError)); 19 | }); 20 | test('GIVEN a wrongly formatted string custom emoji THEN returns ArgumentEmojiError', () => { 21 | const resolvedEmoji = resolveEmoji(''); 22 | expect(resolvedEmoji).toEqual(Result.err(Identifiers.ArgumentEmojiError)); 23 | }); 24 | test('GIVEN a string custom emoji THEN returns emojiObject', () => { 25 | const resolvedEmoji = resolveEmoji('<:custom:737141877803057244>'); 26 | expect(resolvedEmoji.isOk()).toBe(true); 27 | expect(() => resolvedEmoji.unwrapErr()).toThrowError(); 28 | expect(resolvedEmoji.unwrap()).toMatchObject({ id: '737141877803057244', name: 'custom' }); 29 | }); 30 | test('GIVEN a string custom animated emoji THEN returns emojiObject', () => { 31 | const resolvedEmoji = resolveEmoji(''); 32 | expect(resolvedEmoji.isOk()).toBe(true); 33 | expect(() => resolvedEmoji.unwrapErr()).toThrowError(); 34 | expect(resolvedEmoji.unwrap()).toMatchObject({ animated: true, id: '737141877803057244', name: 'custom' }); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /tests/resolvers/enum.test.ts: -------------------------------------------------------------------------------- 1 | import { Result } from '@sapphire/result'; 2 | import { Identifiers } from '../../src/lib/errors/Identifiers'; 3 | import { resolveEnum } from '../../src/lib/resolvers/enum'; 4 | 5 | describe('Enum resolver tests', () => { 6 | test('GIVEN good lowercase enum from one option THEN returns string', () => { 7 | const resolvedEnum = resolveEnum('foo', { enum: ['foo'] }); 8 | expect(resolvedEnum).toEqual(Result.ok('foo')); 9 | }); 10 | test('GIVEN good mixedcase enum from one option THEN returns string', () => { 11 | const resolvedEnum = resolveEnum('FoO', { enum: ['FoO'] }); 12 | expect(resolvedEnum).toEqual(Result.ok('FoO')); 13 | }); 14 | test('GIVEN good enum from more options THEN returns string', () => { 15 | const resolvedEnum = resolveEnum('foo', { enum: ['foo', 'bar', 'baz'] }); 16 | expect(resolvedEnum).toEqual(Result.ok('foo')); 17 | }); 18 | test('GIVEN good case insensitive enum from more options THEN returns string', () => { 19 | const resolvedEnum = resolveEnum('FoO', { enum: ['FoO', 'foo', 'bar', 'baz'], caseInsensitive: false }); 20 | expect(resolvedEnum).toEqual(Result.ok('FoO')); 21 | }); 22 | test('GIVEN good enum from one option THEN returns ArgumentEnumError', () => { 23 | const resolvedEnum = resolveEnum('foo', { enum: ['foo'] }); 24 | expect(resolvedEnum.isOk()).toBe(true); 25 | }); 26 | test('GIVEN an empty enum array THEN returns ArgumentEnumEmptyError', () => { 27 | const resolvedEnum = resolveEnum('foo'); 28 | expect(resolvedEnum).toEqual(Result.err(Identifiers.ArgumentEnumEmptyError)); 29 | }); 30 | test('GIVEN an enum not listed in the array THEN returns ArgumentEnumError', () => { 31 | const resolvedEnum = resolveEnum('foo', { enum: ['bar', 'baz'] }); 32 | expect(resolvedEnum).toEqual(Result.err(Identifiers.ArgumentEnumError)); 33 | }); 34 | test('GIVEN an enum with wrong case THEN returns ArgumentEnumError', () => { 35 | const resolvedEnum = resolveEnum('FOO', { enum: ['bar', 'baz'], caseInsensitive: false }); 36 | expect(resolvedEnum).toEqual(Result.err(Identifiers.ArgumentEnumError)); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /tests/resolvers/float.test.ts: -------------------------------------------------------------------------------- 1 | import { Result } from '@sapphire/result'; 2 | import { Identifiers } from '../../src/lib/errors/Identifiers'; 3 | import { resolveFloat } from '../../src/lib/resolvers/float'; 4 | 5 | describe('Float resolver tests', () => { 6 | test('GIVEN a valid float THEN returns its parsed value', () => { 7 | expect(resolveFloat('1.23')).toEqual(Result.ok(1.23)); 8 | }); 9 | test('GIVEN a valid float with minimum THEN returns its parsed value', () => { 10 | expect(resolveFloat('2.34', { minimum: 2 })).toEqual(Result.ok(2.34)); 11 | }); 12 | test('GIVEN a valid float with maximum THEN returns its parsed value', () => { 13 | expect(resolveFloat('3.45', { maximum: 4 })).toEqual(Result.ok(3.45)); 14 | }); 15 | test('GIVEN a float before minimum THEN returns error', () => { 16 | expect(resolveFloat('1.23', { minimum: 2 })).toEqual(Result.err(Identifiers.ArgumentFloatTooSmall)); 17 | }); 18 | test('GIVEN a float beyond maximum THEN returns error', () => { 19 | expect(resolveFloat('4.56', { maximum: 4 })).toEqual(Result.err(Identifiers.ArgumentFloatTooLarge)); 20 | }); 21 | test('GIVEN an invalid float THEN returns error', () => { 22 | expect(resolveFloat('hello')).toEqual(Result.err(Identifiers.ArgumentFloatError)); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /tests/resolvers/hyperlink.test.ts: -------------------------------------------------------------------------------- 1 | import { Result } from '@sapphire/result'; 2 | import { URL } from 'node:url'; 3 | import { Identifiers } from '../../src/lib/errors/Identifiers'; 4 | import { resolveHyperlink } from '../../src/lib/resolvers/hyperlink'; 5 | 6 | const STRING_URL = 'https://github.com/sapphiredev'; 7 | const PARSED_URL = new URL(STRING_URL); 8 | 9 | describe('Hyperlink resolver tests', () => { 10 | test('GIVEN a valid hyperlink THEN returns its parsed value', () => { 11 | expect(resolveHyperlink(STRING_URL)).toEqual(Result.ok(PARSED_URL)); 12 | }); 13 | test('GIVEN an invalid hyperlink THEN returns error', () => { 14 | expect(resolveHyperlink('hello')).toEqual(Result.err(Identifiers.ArgumentHyperlinkError)); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /tests/resolvers/integer.test.ts: -------------------------------------------------------------------------------- 1 | import { Result } from '@sapphire/result'; 2 | import { Identifiers } from '../../src/lib/errors/Identifiers'; 3 | import { resolveInteger } from '../../src/lib/resolvers/integer'; 4 | 5 | describe('Integer resolver tests', () => { 6 | test('GIVEN a valid integer THEN returns its parsed value', () => { 7 | expect(resolveInteger('1')).toEqual(Result.ok(1)); 8 | }); 9 | test('GIVEN a valid integer with minimum THEN returns its parsed value', () => { 10 | expect(resolveInteger('2', { minimum: 2 })).toEqual(Result.ok(2)); 11 | }); 12 | test('GIVEN a valid integer with maximum THEN returns its parsed value', () => { 13 | expect(resolveInteger('3', { maximum: 4 })).toEqual(Result.ok(3)); 14 | }); 15 | test('GIVEN a integer before minimum THEN returns error', () => { 16 | expect(resolveInteger('1', { minimum: 2 })).toEqual(Result.err(Identifiers.ArgumentIntegerTooSmall)); 17 | }); 18 | test('GIVEN a integer beyond maximum THEN returns error', () => { 19 | expect(resolveInteger('5', { maximum: 4 })).toEqual(Result.err(Identifiers.ArgumentIntegerTooLarge)); 20 | }); 21 | test('GIVEN an invalid integer THEN returns error', () => { 22 | expect(resolveInteger('hello')).toEqual(Result.err(Identifiers.ArgumentIntegerError)); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /tests/resolvers/number.test.ts: -------------------------------------------------------------------------------- 1 | import { Result } from '@sapphire/result'; 2 | import { Identifiers } from '../../src/lib/errors/Identifiers'; 3 | import { resolveNumber } from '../../src/lib/resolvers/number'; 4 | 5 | describe('Number resolver tests', () => { 6 | test('GIVEN a valid number THEN returns its parsed value', () => { 7 | expect(resolveNumber('1.23')).toEqual(Result.ok(1.23)); 8 | 9 | expect(resolveNumber('1')).toEqual(Result.ok(1)); 10 | }); 11 | test('GIVEN a valid number with minimum THEN returns its parsed value', () => { 12 | expect(resolveNumber('2.34', { minimum: 2 })).toEqual(Result.ok(2.34)); 13 | 14 | expect(resolveNumber('2', { minimum: 2 })).toEqual(Result.ok(2)); 15 | }); 16 | test('GIVEN a valid number with maximum THEN returns its parsed value', () => { 17 | expect(resolveNumber('3.45', { maximum: 4 })).toEqual(Result.ok(3.45)); 18 | 19 | expect(resolveNumber('3', { maximum: 4 })).toEqual(Result.ok(3)); 20 | }); 21 | test('GIVEN a number smaller than minimum THEN returns error', () => { 22 | expect(resolveNumber('1.23', { minimum: 2 })).toEqual(Result.err(Identifiers.ArgumentNumberTooSmall)); 23 | 24 | expect(resolveNumber('1', { minimum: 2 })).toEqual(Result.err(Identifiers.ArgumentNumberTooSmall)); 25 | }); 26 | test('GIVEN a number larger than maximum THEN returns error', () => { 27 | expect(resolveNumber('4.56', { maximum: 4 })).toEqual(Result.err(Identifiers.ArgumentNumberTooLarge)); 28 | 29 | expect(resolveNumber('5', { maximum: 4 })).toEqual(Result.err(Identifiers.ArgumentNumberTooLarge)); 30 | }); 31 | test('GIVEN an invalid number THEN returns error', () => { 32 | expect(resolveNumber('hello')).toEqual(Result.err(Identifiers.ArgumentNumberError)); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /tests/resolvers/string.test.ts: -------------------------------------------------------------------------------- 1 | import { Result } from '@sapphire/result'; 2 | import { Identifiers } from '../../src/lib/errors/Identifiers'; 3 | import { resolveString } from '../../src/lib/resolvers/string'; 4 | 5 | describe('String resolver tests', () => { 6 | test('GIVEN a valid string THEN returns it', () => { 7 | expect(resolveString('hello')).toEqual(Result.ok('hello')); 8 | 9 | expect(resolveString('100')).toEqual(Result.ok('100')); 10 | }); 11 | test('GIVEN a valid string with minimum THEN returns it', () => { 12 | expect(resolveString('hello', { minimum: 2 })).toEqual(Result.ok('hello')); 13 | 14 | expect(resolveString('100', { minimum: 2 })).toEqual(Result.ok('100')); 15 | }); 16 | test('GIVEN a valid string with maximum THEN returns its parsed value', () => { 17 | expect(resolveString('hello', { maximum: 10 })).toEqual(Result.ok('hello')); 18 | 19 | expect(resolveString('100', { maximum: 100 })).toEqual(Result.ok('100')); 20 | }); 21 | test('GIVEN a string shorter than minimum THEN returns error', () => { 22 | expect(resolveString('hello', { minimum: 10 })).toEqual(Result.err(Identifiers.ArgumentStringTooShort)); 23 | 24 | expect(resolveString('100', { minimum: 10 })).toEqual(Result.err(Identifiers.ArgumentStringTooShort)); 25 | }); 26 | test('GIVEN a string longer than maximum THEN returns error', () => { 27 | expect(resolveString('hello', { maximum: 2 })).toEqual(Result.err(Identifiers.ArgumentStringTooLong)); 28 | 29 | expect(resolveString('100', { maximum: 2 })).toEqual(Result.err(Identifiers.ArgumentStringTooLong)); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /tests/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.base.json", 3 | "compilerOptions": { 4 | "types": ["vitest/globals"] 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["@sapphire/ts-config", "@sapphire/ts-config/bundler", "@sapphire/ts-config/extra-strict", "@sapphire/ts-config/verbatim"], 3 | "compilerOptions": { 4 | "noEmit": true, 5 | "incremental": false, 6 | "useDefineForClassFields": false 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "compilerOptions": { 4 | "types": ["vitest/globals"], 5 | "lib": ["DOM", "ESNext"] 6 | }, 7 | "include": ["src", "tests", "scripts", "vitest.config.ts", "tsup.config.ts"] 8 | } 9 | -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { esbuildPluginFilePathExtensions } from 'esbuild-plugin-file-path-extensions'; 2 | import { esbuildPluginVersionInjector } from 'esbuild-plugin-version-injector'; 3 | import { defineConfig, type Options } from 'tsup'; 4 | 5 | const baseOptions: Options = { 6 | clean: true, 7 | entry: ['src/**/*.ts'], 8 | dts: true, 9 | minify: false, 10 | skipNodeModulesBundle: true, 11 | sourcemap: true, 12 | target: 'es2021', 13 | tsconfig: 'src/tsconfig.json', 14 | keepNames: true, 15 | esbuildPlugins: [esbuildPluginVersionInjector(), esbuildPluginFilePathExtensions()], 16 | treeshake: true 17 | }; 18 | 19 | export default [ 20 | defineConfig({ 21 | ...baseOptions, 22 | outDir: 'dist/cjs', 23 | format: 'cjs', 24 | outExtension: () => ({ js: '.cjs' }) 25 | }), 26 | defineConfig({ 27 | ...baseOptions, 28 | outDir: 'dist/esm', 29 | format: 'esm' 30 | }) 31 | ]; 32 | -------------------------------------------------------------------------------- /typedoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://typedoc.org/schema.json", 3 | "entryPoints": ["src/index.ts"], 4 | "json": "docs/api.json", 5 | "tsconfig": "src/tsconfig.json", 6 | "excludePrivate": false 7 | } 8 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | 3 | export default defineConfig({ 4 | test: { 5 | globals: true, 6 | coverage: { 7 | enabled: true, 8 | reporter: ['text', 'lcov'] 9 | } 10 | }, 11 | esbuild: { 12 | target: 'es2022' 13 | } 14 | }); 15 | --------------------------------------------------------------------------------