├── .github ├── CODEOWNERS ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── ISSUE_TEMPLATE │ ├── 1-bug.yml │ ├── 2-idea.yml │ ├── 3-help.yml │ └── config.yml ├── PULL_REQUEST_TEMPLATE.md ├── SECURITY.md ├── copilot-instructions.md ├── dependabot.yml └── workflows │ ├── ci.yml │ ├── coverage.yml │ ├── npm-publish.yml │ └── scorecard.yml ├── .gitignore ├── LICENSE ├── README.md ├── biome.jsonc ├── package-lock.json ├── package.json ├── src ├── builders │ ├── autocomplete.test.ts │ ├── autocomplete.ts │ ├── command.test.ts │ ├── command.ts │ ├── components-v2.test.ts │ ├── components-v2.ts │ ├── components.test.ts │ ├── components.ts │ ├── embed.test.ts │ ├── embed.ts │ ├── index.ts │ ├── modal.test.ts │ ├── modal.ts │ ├── poll.test.ts │ ├── poll.ts │ ├── utils.test.ts │ └── utils.ts ├── context.test.ts ├── context.ts ├── discord-hono.test.ts ├── discord-hono.ts ├── helpers │ ├── create-factory.test.ts │ ├── create-factory.ts │ ├── index.ts │ ├── register.test.ts │ ├── register.ts │ ├── retry429.test.ts │ ├── retry429.ts │ ├── webhook.test.ts │ └── webhook.ts ├── index.ts ├── rest │ ├── index.ts │ ├── rest-path.ts │ ├── rest-types.ts │ ├── rest.test.ts │ └── rest.ts ├── types.ts ├── utils.test.ts ├── utils.ts ├── verify.test.ts └── verify.ts ├── tsconfig.json └── vitest.config.ts /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @luisfun -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | - Mutual Respect: Treat all participants with respect and consideration. 4 | - Constructive Criticism: Ensure all feedback is polite and aimed at improving the project. 5 | - Embracing Diversity: Welcome different backgrounds, experiences, and perspectives to foster an inclusive environment. 6 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution Guide 2 | 3 | Contributions are welcome! We appreciate your help. 4 | 5 | ## Create an Issue 6 | 7 | Please select the appropriate category from `New Issue` and create an issue. 8 | 9 | For quick questions or immediate assistance, consider using our Discord chat. 10 | 11 | ## Create a Pull Request 12 | 13 | ![node](https://img.shields.io/badge/Node.js-5FA04E?logo=Node.js&logoColor=white) ![npm](https://img.shields.io/badge/npm-CB3837?logo=npm&logoColor=white) 14 | 15 | - Run `npm i` to install 16 | - Run `npm run fix` before commit 17 | - Run `npm run test` before pull request 18 | 19 | ### License Agreement 20 | 21 | By Creating a pull request, you agree to license your contribution under the project's existing license. 22 | 23 | ## Other Ways to Contribute 24 | 25 | You can also contribute to the project by: 26 | - Sharing: Create posts on social media, blogs, or technical articles 27 | - Using: Develop projects using discord-hono 28 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/1-bug.yml: -------------------------------------------------------------------------------- 1 | name: 🐛 Bug 2 | description: Report an issue that should be fixed 3 | labels: [bug] 4 | body: 5 | - type: input 6 | attributes: 7 | label: discord-hono version 8 | placeholder: 0.0.0 9 | validations: 10 | required: true 11 | - type: textarea 12 | attributes: 13 | label: Bug Details 14 | placeholder: | 15 | - Steps to reproduce the bug 16 | - Relevant code examples 17 | - Expected behavior vs Actual behavior 18 | validations: 19 | required: true 20 | - type: textarea 21 | attributes: 22 | label: Additional Info 23 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/2-idea.yml: -------------------------------------------------------------------------------- 1 | name: ✨ Idea 2 | description: Suggest an idea, feature, or enhancement 3 | labels: [idea] 4 | body: 5 | - type: textarea 6 | attributes: 7 | label: Idea Description 8 | placeholder: | 9 | - New features or ideas 10 | - References, links, or code snippets 11 | validations: 12 | required: true 13 | - type: checkboxes 14 | attributes: 15 | label: Pull Request 16 | options: 17 | - label: I'm willing to submit a pull request for this idea 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/3-help.yml: -------------------------------------------------------------------------------- 1 | name: ❓ Help 2 | description: Unresolvable errors or points where you're stuck 3 | labels: [help] 4 | body: 5 | - type: input 6 | attributes: 7 | label: discord-hono version 8 | placeholder: 0.0.0 9 | validations: 10 | required: true 11 | - type: input 12 | attributes: 13 | label: Runtime / Platform 14 | placeholder: Cloudflare Workers, Deno, Bun, etc. 15 | - type: textarea 16 | attributes: 17 | label: What you cannot solve 18 | placeholder: | 19 | - Unresolvable error 20 | - Need hints on implementation method 21 | validations: 22 | required: true 23 | - type: textarea 24 | attributes: 25 | label: Additional Info 26 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true 2 | contact_links: 3 | - name: 💬 Discord 4 | url: https://discord.gg/KFAgHFwBsr 5 | about: Join our Discord server to chat 6 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### Prefix: Title 2 | (skip) docs: style: refactor: test: chore: 3 | (patch) fix: perf: 4 | (minor) feat: 5 | (major) BREAKING CHANGE: 6 | 7 | *v0.x: minor -> patch, major -> minor 8 | 9 | ### Checklist 10 | - [ ] npm run fix 11 | - [ ] npm run test 12 | - [ ] Add tests (as needed) 13 | - [ ] Add JsDoc (as needed) 14 | -------------------------------------------------------------------------------- /.github/SECURITY.md: -------------------------------------------------------------------------------- 1 | ## Reporting a Vulnerability 2 | 3 | If you discover a security vulnerability, please report it to us as soon as possible. To do so: 4 | 5 | 1. Join our Discord server using the following link: [Invite](https://discord.gg/KFAgHFwBsr) 6 | 2. Send me (@luis.fun) a direct message (DM) on Discord 7 | 8 | We take all security reports seriously and will respond promptly to address any issues. 9 | 10 | Thank you for helping to keep this project safe and secure! 11 | -------------------------------------------------------------------------------- /.github/copilot-instructions.md: -------------------------------------------------------------------------------- 1 | ## Project Overview 2 | A library for building Discord bots on Cloudflare Workers. 3 | 4 | ### Features 5 | - **Intuitive API:** Influenced by Hono, offering a familiar and easy-to-use interface 6 | - **Lightweight:** Zero dependencies, optimized for performance 7 | - **Type-Safe:** Native support for TypeScript 8 | 9 | ## Coding Standards 10 | 11 | - **Code should show "How":** The implementation details should be clear from the code itself. 12 | - **Test code should show "What":** Test cases should clearly state what is being verified or guaranteed. 13 | - **Commit messages should explain "Why":** Each commit message should describe the reason or motivation behind the change. 14 | - **Code comments should clarify "Why not":** Comments should address why alternative approaches were not chosen. 15 | 16 | ### TypeScript 17 | 18 | - Naming Conventions: 19 | - Default: camelCase 20 | - rest-path.ts: snake_case base (e.g., _category_$_tag) 21 | 22 | ### Vitest 23 | 24 | - File Naming Conventions: `*.test.ts` 25 | - npm scripts: `npm run test` 26 | 27 | ### Code Style 28 | - Use Biome for code formatting and linting 29 | - `biome.jsonc` - Biome setting file 30 | - Include JSDoc comments for public APIs 31 | - npm scripts: `npm run fix` 32 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: 'npm' 4 | directory: '/' 5 | schedule: 6 | interval: 'monthly' 7 | ignore: 8 | - dependency-name: '*' 9 | update-types: ['version-update:semver-patch', 'version-update:semver-minor'] 10 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: [ '**', '!main' ] 5 | paths: 6 | - 'src/**' 7 | pull_request: 8 | branches: [ main ] 9 | 10 | permissions: 11 | contents: read 12 | 13 | jobs: 14 | test: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: actions/setup-node@v4 19 | with: 20 | node-version: 'lts/*' 21 | registry-url: 'https://registry.npmjs.org' 22 | - run: npm ci 23 | 24 | - name: Test 25 | run: npm run test 26 | -------------------------------------------------------------------------------- /.github/workflows/coverage.yml: -------------------------------------------------------------------------------- 1 | name: Coverage 2 | on: 3 | push: 4 | branches: [main] 5 | paths: 6 | - 'src/**' 7 | 8 | permissions: read-all 9 | 10 | jobs: 11 | coverage: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: actions/setup-node@v4 16 | with: 17 | node-version: 'lts/*' 18 | - run: npm ci 19 | - run: npm run test 20 | - uses: codecov/codecov-action@13ce06bfc6bbe3ecf90edbbf1bc32fe5978ca1d3 # v5.3.1 21 | -------------------------------------------------------------------------------- /.github/workflows/npm-publish.yml: -------------------------------------------------------------------------------- 1 | name: 📦 NPM Publish 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | version_type: 7 | description: 'Version type to bump' 8 | required: true 9 | type: choice 10 | options: 11 | - patch 12 | - minor 13 | - major 14 | - prerelease 15 | - prepatch 16 | - preminor 17 | - premajor 18 | 19 | permissions: read-all 20 | 21 | jobs: 22 | publish: 23 | runs-on: ubuntu-latest 24 | permissions: 25 | contents: write 26 | pull-requests: write 27 | id-token: write 28 | steps: 29 | - uses: actions/checkout@v4 30 | - uses: actions/setup-node@v4 31 | with: 32 | node-version: 'lts/*' 33 | registry-url: 'https://registry.npmjs.org' 34 | - run: | 35 | npm ci 36 | git config --global user.name "${GITHUB_ACTOR}" 37 | git config --global user.email "${GITHUB_ACTOR}@users.noreply.github.com" 38 | 39 | - name: Create release branch 40 | run: git checkout -b release 41 | 42 | - name: Test 43 | run: | 44 | npm run test 45 | npm run build 46 | 47 | - name: Version upgrade 48 | run: npm version ${{ github.event.inputs.version_type }} 49 | 50 | - name: Push changes and create pull request 51 | env: 52 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 53 | run: | 54 | git push --set-upstream origin release 55 | git push origin --tags 56 | gh pr create --base main --head release --title "Release ${{ github.event.inputs.version_type }}" --body "Automated release PR" 57 | 58 | - name: Publish to npm 59 | run: npm publish --provenance 60 | env: 61 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 62 | -------------------------------------------------------------------------------- /.github/workflows/scorecard.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. They are provided 2 | # by a third-party and are governed by separate terms of service, privacy 3 | # policy, and support documentation. 4 | 5 | name: Scorecard supply-chain security 6 | on: 7 | # For Branch-Protection check. Only the default branch is supported. See 8 | # https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection 9 | branch_protection_rule: 10 | # To guarantee Maintained check is occasionally updated. See 11 | # https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained 12 | schedule: 13 | - cron: '0 0 1 * *' 14 | push: 15 | branches: [ "main" ] 16 | 17 | # Declare default permissions as read only. 18 | permissions: read-all 19 | 20 | jobs: 21 | analysis: 22 | name: Scorecard analysis 23 | runs-on: ubuntu-latest 24 | permissions: 25 | # Needed to upload the results to code-scanning dashboard. 26 | security-events: write 27 | # Needed to publish results and get a badge (see publish_results below). 28 | id-token: write 29 | # Uncomment the permissions below if installing in a private repository. 30 | # contents: read 31 | # actions: read 32 | 33 | steps: 34 | - name: "Checkout code" 35 | uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 36 | with: 37 | persist-credentials: false 38 | 39 | - name: "Run analysis" 40 | uses: ossf/scorecard-action@62b2cac7ed8198b15735ed49ab1e5cf35480ba46 # v2.4.0 41 | with: 42 | results_file: results.sarif 43 | results_format: sarif 44 | # (Optional) "write" PAT token. Uncomment the `repo_token` line below if: 45 | # - you want to enable the Branch-Protection check on a *public* repository, or 46 | # - you are installing Scorecard on a *private* repository 47 | # To create the PAT, follow the steps in https://github.com/ossf/scorecard-action?tab=readme-ov-file#authentication-with-fine-grained-pat-optional. 48 | # repo_token: ${{ secrets.SCORECARD_TOKEN }} 49 | 50 | # Public repositories: 51 | # - Publish results to OpenSSF REST API for easy access by consumers 52 | # - Allows the repository to include the Scorecard badge. 53 | # - See https://github.com/ossf/scorecard-action#publishing-results. 54 | # For private repositories: 55 | # - `publish_results` will always be set to `false`, regardless 56 | # of the value entered here. 57 | publish_results: true 58 | 59 | # Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF 60 | # format to the repository Actions tab. 61 | - name: "Upload artifact" 62 | uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 63 | with: 64 | name: SARIF file 65 | path: results.sarif 66 | retention-days: 5 67 | 68 | # Upload the results to GitHub's code scanning dashboard (optional). 69 | # Commenting out will disable upload of results to your repo's Code Scanning dashboard 70 | - name: "Upload to code-scanning" 71 | uses: github/codeql-action/upload-sarif@v3 72 | with: 73 | sarif_file: results.sarif 74 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | coverage/ 2 | node_modules/ 3 | dist/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Luis 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🔥 Discord Hono [![npm v](https://img.shields.io/npm/v/discord-hono)](https://www.npmjs.com/package/discord-hono) [![Bundle Size](https://img.shields.io/bundlephobia/minzip/discord-hono?label=minzip)](https://bundlephobia.com/package/discord-hono) [![Discord](https://img.shields.io/discord/1331893810501914694?label=Discord)](https://discord.gg/KFAgHFwBsr) 2 | 3 | **This library enables you to easily build Discord bots on Cloudflare Workers** 4 | 5 | [👉 Documentation](https://discord-hono.luis.fun) 6 | 7 | This project is influenced by [Hono](https://github.com/honojs/hono). 8 | Thank you for [Yusuke Wada](https://github.com/yusukebe) and Hono contributors! 9 | 10 | ## Features 11 | 12 | - **Intuitive API** - Influenced by Hono, offering a familiar and easy-to-use interface 13 | - **Lightweight** - Zero dependencies, optimized for performance 14 | - **Type-Safe** - Native support for TypeScript 15 | 16 | ## Install 17 | 18 | ```shell 19 | npm i discord-hono 20 | npm i -D discord-api-types # When using TypeScript 21 | # npm i -D @types/node # As needed 22 | ``` 23 | 24 | ## Example Code 25 | 26 | index.ts 27 | 28 | ```ts 29 | import { DiscordHono } from 'discord-hono' 30 | 31 | const app = new DiscordHono() 32 | .command('hello', c => c.res('Hello, World!')) 33 | 34 | export default app 35 | ``` 36 | 37 | register.ts 38 | 39 | ```ts 40 | import { Command, register } from 'discord-hono' 41 | 42 | const commands = [ 43 | new Command('hello', 'Hello, World!'), 44 | ] 45 | 46 | register( 47 | commands, 48 | process.env.DISCORD_APPLICATION_ID, 49 | process.env.DISCORD_TOKEN, 50 | //process.env.DISCORD_TEST_GUILD_ID, 51 | ) 52 | ``` 53 | 54 | ## Health 55 | 56 | [![CodeQL](https://github.com/luisfun/discord-hono/actions/workflows/github-code-scanning/codeql/badge.svg)](https://github.com/luisfun/discord-hono/actions/workflows/github-code-scanning/codeql) [![Codecov](https://codecov.io/github/luisfun/discord-hono/graph/badge.svg)](https://codecov.io/github/luisfun/discord-hono) [![OpenSSF](https://api.scorecard.dev/projects/github.com/luisfun/discord-hono/badge)](https://scorecard.dev/viewer/?uri=github.com/luisfun/discord-hono) 57 | 58 | ## Links 59 | 60 | - [Examples Repository](https://github.com/luisfun/discord-hono-examples) 61 | - [Documentation Repository](https://github.com/luisfun/discord-hono-docs) 62 | - [DeepWiki](https://deepwiki.com/luisfun/discord-hono) 63 | 64 | ## References 65 | 66 | - [Hono](https://github.com/honojs/hono) - [MIT License](https://github.com/honojs/hono/blob/main/LICENSE) 67 | - [Discord App](https://github.com/discord/cloudflare-sample-app) - [MIT License](https://github.com/discord/cloudflare-sample-app/blob/main/LICENSE) 68 | - [Verify for Workers](https://gist.github.com/devsnek/77275f6e3f810a9545440931ed314dc1) 69 | -------------------------------------------------------------------------------- /biome.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", 3 | "formatter": { 4 | "indentStyle": "space", 5 | "lineWidth": 120 6 | }, 7 | "javascript": { 8 | "formatter": { 9 | "arrowParentheses": "asNeeded", 10 | "quoteStyle": "single", 11 | "jsxQuoteStyle": "double", 12 | "semicolons": "asNeeded" 13 | }, 14 | "globals": ["vi", "test", "describe", "it", "beforeEach", "afterEach", "expect"] // vitest globals 15 | }, 16 | "organizeImports": { "enabled": true }, 17 | "linter": { 18 | "rules": { 19 | "all": true, 20 | "correctness": { 21 | "useImportExtensions": "off" // For simple coding purposes 22 | }, 23 | "performance": { 24 | "noBarrelFile": "off", // For simple coding purposes 25 | "noReExportAll": "off" // For consistency in file separation 26 | }, 27 | "style": { 28 | "noDefaultExport": "off", // using default export 29 | "noNonNullAssertion": "off", // Allow Non Null 30 | "useBlockStatements": "off", // For simple coding purposes 31 | "useDefaultSwitchClause": "off", // For simple coding purposes 32 | "useNamingConvention": "off", // To accommodate the special naming conventions 33 | "useSingleCaseStatement": "off" // For simple coding purposes 34 | }, 35 | "suspicious": { 36 | "noExplicitAny": "off" // Allow type any 37 | } 38 | } 39 | }, 40 | "files": { 41 | "ignore": ["coverage", "dist", "package.json"] 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "discord-hono", 3 | "version": "0.19.3", 4 | "description": "This library enables you to easily build Discord bots on Cloudflare Workers", 5 | "author": "Luis (https://github.com/luisfun)", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/luisfun/discord-hono" 9 | }, 10 | "homepage": "https://github.com/luisfun/discord-hono", 11 | "license": "MIT", 12 | "keywords": [ 13 | "discord-hono", 14 | "discord-bot", 15 | "cloudflare-workers" 16 | ], 17 | "files": [ 18 | "dist" 19 | ], 20 | "type": "module", 21 | "main": "dist/index.js", 22 | "engines": { 23 | "node": ">=18.4.0" 24 | }, 25 | "scripts": { 26 | "fix": "biome check --write .", 27 | "fix:unsafe": "biome check --write --unsafe .", 28 | "test": "biome check . && tsc && vitest run --coverage", 29 | "build": "tsup src/index.ts --format esm --dts --clean && attw -P . --ignore-rules cjs-resolves-to-esm" 30 | }, 31 | "devDependencies": { 32 | "@arethetypeswrong/cli": "^0.17.4", 33 | "@biomejs/biome": "1.9.4", 34 | "@discordjs/builders": "^1.11.1", 35 | "@vitest/coverage-v8": "^3.1.2", 36 | "discord-api-types": "^0.38.1", 37 | "tsup": "^8.4.0", 38 | "typescript": "^5.8.3", 39 | "vitest": "^3.1.2" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/builders/autocomplete.test.ts: -------------------------------------------------------------------------------- 1 | import type { APICommandAutocompleteInteractionResponseCallbackData } from 'discord-api-types/v10' 2 | import { describe, expect, it } from 'vitest' 3 | import { Autocomplete } from './autocomplete' 4 | 5 | describe('Autocomplete', () => { 6 | it('should filter choices based on search string', () => { 7 | const autocomplete = new Autocomplete('test') 8 | const choices: APICommandAutocompleteInteractionResponseCallbackData['choices'] = [ 9 | { name: 'test1', value: '1' }, 10 | { name: 'test2', value: '2' }, 11 | { name: 'other', value: '3' }, 12 | { name: 'another', value: 'test' }, 13 | ] 14 | 15 | const result = autocomplete.choices(...choices) 16 | 17 | expect(result.toJSON().choices).toEqual([ 18 | { name: 'test1', value: '1' }, 19 | { name: 'test2', value: '2' }, 20 | { name: 'another', value: 'test' }, 21 | ]) 22 | }) 23 | 24 | it('should filter choices based on localized names', () => { 25 | const autocomplete = new Autocomplete('テスト') 26 | const choices: APICommandAutocompleteInteractionResponseCallbackData['choices'] = [ 27 | { name: 'test1', name_localizations: { ja: 'テスト1' }, value: '1' }, 28 | { name: 'test2', name_localizations: { ja: 'テスト2' }, value: '2' }, 29 | { name: 'other', value: '3' }, 30 | ] 31 | 32 | const result = autocomplete.choices(...choices) 33 | 34 | expect(result.toJSON().choices).toEqual([ 35 | { name: 'test1', name_localizations: { ja: 'テスト1' }, value: '1' }, 36 | { name: 'test2', name_localizations: { ja: 'テスト2' }, value: '2' }, 37 | ]) 38 | }) 39 | 40 | it('should limit choices to 25', () => { 41 | const autocomplete = new Autocomplete('a') 42 | const choices: APICommandAutocompleteInteractionResponseCallbackData['choices'] = new Array(30) 43 | .fill(null) 44 | .map((_, i) => ({ name: `a${i}`, value: `${i}` })) 45 | 46 | const result = autocomplete.choices(...choices) 47 | 48 | expect(result.toJSON().choices).toHaveLength(25) 49 | }) 50 | 51 | it('should handle empty search string', () => { 52 | const autocomplete = new Autocomplete() 53 | const choices: APICommandAutocompleteInteractionResponseCallbackData['choices'] = [ 54 | { name: 'test1', value: '1' }, 55 | { name: 'test2', value: '2' }, 56 | ] 57 | 58 | const result = autocomplete.choices(...choices) 59 | 60 | expect(result.toJSON().choices).toEqual(choices) 61 | }) 62 | 63 | it('should handle numeric search', () => { 64 | const autocomplete = new Autocomplete(2) 65 | const choices: APICommandAutocompleteInteractionResponseCallbackData['choices'] = [ 66 | { name: 'test1', value: '1' }, 67 | { name: 'test2', value: '2' }, 68 | { name: 'test3', value: '3' }, 69 | ] 70 | 71 | const result = autocomplete.choices(...choices) 72 | 73 | expect(result.toJSON().choices).toEqual([{ name: 'test2', value: '2' }]) 74 | }) 75 | }) 76 | -------------------------------------------------------------------------------- /src/builders/autocomplete.ts: -------------------------------------------------------------------------------- 1 | import type { APICommandAutocompleteInteractionResponseCallbackData } from 'discord-api-types/v10' 2 | import { Builder } from './utils' 3 | 4 | export class Autocomplete extends Builder { 5 | #search: string 6 | constructor(search?: string | number) { 7 | super({}) 8 | this.#search = search?.toString() || '' 9 | } 10 | choices = (...e: Required['choices']) => { 11 | const choices = e.filter(e2 => { 12 | if (e2.name.includes(this.#search)) return true 13 | if (Object.values(e2.name_localizations || {}).some(e3 => e3?.includes(this.#search))) return true 14 | if (e2.value.toString().includes(this.#search)) return true 15 | return false 16 | }) 17 | if (choices.length > 25) choices.length = 25 18 | return this.a({ choices }) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/builders/command.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ApplicationCommandType, 3 | ApplicationIntegrationType, 4 | ChannelType, 5 | InteractionContextType, 6 | Locale, 7 | } from 'discord-api-types/v10' 8 | import { describe, expect, it, vi } from 'vitest' 9 | import { Command, Option, SubCommand, SubGroup } from './command' 10 | 11 | describe('Command class', () => { 12 | it('creates a basic command', () => { 13 | const command = new Command('test', 'Test command') 14 | expect(command.toJSON()).toEqual({ 15 | name: 'test', 16 | description: 'Test command', 17 | }) 18 | }) 19 | 20 | it('sets command properties', () => { 21 | const command = new Command('test', 'Test command') 22 | .type(ApplicationCommandType.ChatInput) 23 | .name_localizations({ [Locale.Japanese]: 'テスト' }) 24 | .description_localizations({ [Locale.Japanese]: 'テストコマンド' }) 25 | .default_member_permissions('0') 26 | .dm_permission(false) 27 | .nsfw() 28 | .integration_types(ApplicationIntegrationType.GuildInstall) 29 | .contexts(InteractionContextType.Guild) 30 | 31 | expect(command.toJSON()).toMatchObject({ 32 | name: 'test', 33 | description: 'Test command', 34 | type: ApplicationCommandType.ChatInput, 35 | name_localizations: { [Locale.Japanese]: 'テスト' }, 36 | description_localizations: { [Locale.Japanese]: 'テストコマンド' }, 37 | default_member_permissions: '0', 38 | dm_permission: false, 39 | nsfw: true, 40 | integration_types: [ApplicationIntegrationType.GuildInstall], 41 | contexts: [InteractionContextType.Guild], 42 | }) 43 | }) 44 | 45 | it('adds options to command', () => { 46 | const subCommand = new SubCommand('sub', 'Sub command') 47 | const option = new Option('opt', 'Option', 'String') 48 | const command = new Command('test', 'Test command').options(subCommand, option) 49 | 50 | expect(command.toJSON().options).toHaveLength(2) 51 | expect(command.toJSON().options?.[0]).toMatchObject(subCommand.toJSON()) 52 | expect(command.toJSON().options?.[1]).toMatchObject(option.toJSON()) 53 | }) 54 | }) 55 | 56 | describe('SubGroup class', () => { 57 | it('creates a sub group', () => { 58 | const subGroup = new SubGroup('group', 'Group description') 59 | expect(subGroup.toJSON()).toMatchObject({ 60 | name: 'group', 61 | description: 'Group description', 62 | type: 2, 63 | }) 64 | }) 65 | 66 | it('adds sub commands to sub group', () => { 67 | const subCommand = new SubCommand('sub', 'Sub command') 68 | const subGroup = new SubGroup('group', 'Group description').options(subCommand) 69 | 70 | expect(subGroup.toJSON().options).toHaveLength(1) 71 | expect(subGroup.toJSON().options?.[0]).toMatchObject(subCommand.toJSON()) 72 | }) 73 | }) 74 | 75 | describe('SubCommand class', () => { 76 | it('creates a sub command', () => { 77 | const subCommand = new SubCommand('sub', 'Sub command') 78 | expect(subCommand.toJSON()).toMatchObject({ 79 | name: 'sub', 80 | description: 'Sub command', 81 | type: 1, 82 | }) 83 | }) 84 | 85 | it('adds options to sub command', () => { 86 | const option = new Option('opt', 'Option', 'String') 87 | const subCommand = new SubCommand('sub', 'Sub command').options(option) 88 | 89 | expect(subCommand.toJSON().options).toHaveLength(1) 90 | expect(subCommand.toJSON().options?.[0]).toMatchObject(option.toJSON()) 91 | }) 92 | }) 93 | 94 | describe('Option class', () => { 95 | it('creates a string option', () => { 96 | const option = new Option('opt', 'Option', 'String') 97 | expect(option.toJSON()).toMatchObject({ 98 | name: 'opt', 99 | description: 'Option', 100 | type: 3, 101 | }) 102 | }) 103 | 104 | it('sets option properties', () => { 105 | const option = new Option('opt', 'Option', 'String').required().min_length(1).max_length(10).autocomplete() 106 | 107 | expect(option.toJSON()).toMatchObject({ 108 | name: 'opt', 109 | description: 'Option', 110 | type: 3, 111 | required: true, 112 | min_length: 1, 113 | max_length: 10, 114 | autocomplete: true, 115 | }) 116 | }) 117 | 118 | it('sets channel types for channel option', () => { 119 | const option = new Option('channel', 'Channel', 'Channel').channel_types( 120 | ChannelType.GuildText, 121 | ChannelType.GuildVoice, 122 | ) 123 | 124 | expect(option.toJSON()).toMatchObject({ 125 | name: 'channel', 126 | description: 'Channel', 127 | type: 7, 128 | channel_types: [ChannelType.GuildText, ChannelType.GuildVoice], 129 | }) 130 | }) 131 | 132 | it('sets min and max values for number option', () => { 133 | const option = new Option('num', 'Number', 'Number').min_value(0).max_value(100) 134 | 135 | expect(option.toJSON()).toMatchObject({ 136 | name: 'num', 137 | description: 'Number', 138 | type: 10, 139 | min_value: 0, 140 | max_value: 100, 141 | }) 142 | }) 143 | 144 | it('warns when using incompatible methods', () => { 145 | // biome-ignore lint: empty block 146 | const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) 147 | // @ts-expect-error 148 | const _option = new Option('str', 'String', 'String') 149 | // @ts-expect-error 150 | .min_value(0) 151 | // @ts-expect-error 152 | .channel_types(ChannelType.GuildText) 153 | 154 | expect(consoleSpy).toHaveBeenCalledTimes(2) 155 | consoleSpy.mockRestore() 156 | }) 157 | }) 158 | -------------------------------------------------------------------------------- /src/builders/command.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | APIApplicationCommandBasicOption, 3 | APIApplicationCommandOption, 4 | APIApplicationCommandOptionChoice, 5 | APIApplicationCommandSubcommandGroupOption, // 2 6 | APIApplicationCommandSubcommandOption, // 1 7 | ApplicationCommandType, 8 | ApplicationIntegrationType, 9 | ChannelType, 10 | EntryPointCommandHandlerType, 11 | InteractionContextType, 12 | Locale, 13 | RESTPostAPIApplicationCommandsJSONBody, 14 | } from 'discord-api-types/v10' 15 | import { toJSON } from '../utils' 16 | import { Builder, warnBuilder } from './utils' 17 | 18 | abstract class CommandBase< 19 | Obj extends RESTPostAPIApplicationCommandsJSONBody | APIApplicationCommandOption, 20 | > extends Builder { 21 | /** 22 | * [Command Structure](https://discord.com/developers/docs/interactions/application-commands#application-command-object) 23 | * @param {string} name 1-32 character name; `CHAT_INPUT` command names must be all lowercase matching `^[-_\p{L}\p{N}\p{sc=Deva}\p{sc=Thai}]{1,32}$` 24 | * @param {string} description 1-100 character description for `CHAT_INPUT` commands, empty string for `USER` and `MESSAGE` commands 25 | */ 26 | constructor(name: string, description = '') { 27 | super({ name, description } as Obj) 28 | } 29 | /** 30 | * [Locale](https://discord.com/developers/docs/reference#locales) 31 | * 32 | * Localization dictionary for the name field. Values follow the same restrictions as name 33 | * @param {Partial>} e 34 | * @returns {this} 35 | */ 36 | name_localizations = (e: Partial>) => this.a({ name_localizations: e } as Obj) 37 | /** 38 | * [Locale](https://discord.com/developers/docs/reference#locales) 39 | * 40 | * Localization dictionary for the description field. Values follow the same restrictions as description 41 | * @param {Partial>} e 42 | * @returns {this} 43 | */ 44 | description_localizations = (e: Partial>) => this.a({ description_localizations: e } as Obj) 45 | } 46 | 47 | export class Command extends CommandBase { 48 | /** 49 | * @param {string} e 50 | * @returns {this} 51 | */ 52 | // @ts-expect-error ??? why 53 | id = (e: string) => this.a({ id: e }) 54 | /** 55 | * [Application Command Types](https://discord.com/developers/docs/interactions/application-commands#application-command-object-application-command-types) 56 | * @param {ApplicationCommandType} e 57 | * @returns {this} 58 | */ 59 | type = (e: ApplicationCommandType) => this.a({ type: e }) 60 | /** 61 | * @param {string} e 62 | * @returns {this} 63 | */ 64 | // @ts-expect-error ??? why 65 | application_id = (e: string) => this.a({ application_id: e }) 66 | /** 67 | * Guild id of the command, if not global 68 | * @param {string} e 69 | * @returns {this} 70 | */ 71 | // @ts-expect-error ??? why 72 | guild_id = (e: string) => this.a({ guild_id: e }) 73 | /** 74 | * Valid Types: 1:CHAT_INPUT 75 | * @param {...(Option | APIApplicationCommandOption)} e 76 | * @returns {this} 77 | */ 78 | options = (...e: (Option | SubGroup | SubCommand | APIApplicationCommandOption)[]) => 79 | this.a({ options: e.map(toJSON) }) 80 | /** 81 | * @param {string | null} e 82 | * @returns {this} 83 | */ 84 | default_member_permissions = (e: string | null) => this.a({ default_member_permissions: e }) 85 | /** 86 | * @deprecated Use `contexts` instead 87 | * @param {boolean} [e=true] 88 | * @returns {this} 89 | */ 90 | dm_permission = (e = true) => this.a({ dm_permission: e }) 91 | /** 92 | * Whether the command is enabled by default when the app is added to a guild 93 | * 94 | * If missing, this property should be assumed as `true` 95 | * @deprecated Use `default_member_permissions` instead 96 | * @param {boolean} [e=true] 97 | * @returns {this} 98 | */ 99 | default_permission = (e = true) => this.a({ default_permission: e }) 100 | /** 101 | * Indicates whether the command is age-restricted 102 | * @param {boolean} [e=true] 103 | * @returns {this} 104 | */ 105 | nsfw = (e = true) => this.a({ nsfw: e }) 106 | /** 107 | * [Application Integration Types](https://discord.com/developers/docs/resources/application#application-object-application-integration-types) 108 | * 109 | * Installation context(s) where the command is available, only for globally-scoped commands. 110 | * @unstable 111 | * @param {...ApplicationIntegrationType} e 112 | * @returns {this} 113 | */ 114 | integration_types = (...e: ApplicationIntegrationType[]) => this.a({ integration_types: e }) 115 | /** 116 | * [Interaction Context Types](https://discord.com/developers/docs/interactions/receiving-and-responding#interaction-object-interaction-context-types) 117 | * 118 | * Interaction context(s) where the command can be used, only for globally-scoped commands. 119 | * @unstable 120 | * @param {...InteractionContextType} e 121 | * @returns {this} 122 | */ 123 | contexts = (...e: InteractionContextType[]) => this.a({ contexts: e }) 124 | /** 125 | * @param {string} e 126 | * @returns {this} 127 | */ 128 | // @ts-expect-error ??? why 129 | version = (e: string) => this.a({ version: e }) 130 | /** 131 | * Valid Types: 4:PRIMARY_ENTRY_POINT 132 | * 133 | * [Hander Types](https://discord.com/developers/docs/interactions/application-commands#application-command-object-entry-point-command-handler-types) 134 | * @param e 135 | * @returns 136 | */ 137 | handler = (e: EntryPointCommandHandlerType) => this.a({ handler: e }) 138 | } 139 | 140 | export class SubGroup extends CommandBase { 141 | /** 142 | * [Command Structure](https://discord.com/developers/docs/interactions/application-commands#application-command-object) 143 | * @param {string} name 1-32 character name 144 | * @param {string} description 1-100 character description 145 | */ 146 | constructor(name: string, description = '') { 147 | super(name, description) 148 | this.a({ type: 2 }) 149 | } 150 | /** 151 | * @param {...(SubCommand | APIApplicationCommandSubcommandOption)} e 152 | * @returns {this} 153 | */ 154 | options = (...e: (SubCommand | APIApplicationCommandSubcommandOption)[]) => this.a({ options: e.map(toJSON) }) 155 | } 156 | 157 | export class SubCommand extends CommandBase { 158 | /** 159 | * [Command Structure](https://discord.com/developers/docs/interactions/application-commands#application-command-object) 160 | * @param {string} name 1-32 character name 161 | * @param {string} description 1-100 character description 162 | */ 163 | constructor(name: string, description = '') { 164 | super(name, description) 165 | this.a({ type: 1 }) 166 | } 167 | /** 168 | * @param {...(Option | APIApplicationCommandBasicOption)} e 169 | * @returns {this} 170 | */ 171 | options = (...e: (Option | APIApplicationCommandBasicOption)[]) => this.a({ options: e.map(toJSON) }) 172 | } 173 | 174 | type OptionType = 175 | | 'String' 176 | | 'Integer' 177 | | 'Number' 178 | | 'Boolean' 179 | | 'User' 180 | | 'Channel' 181 | | 'Role' 182 | | 'Mentionable' 183 | | 'Attachment' 184 | export class Option extends CommandBase { 185 | #type: OptionType 186 | #assign = (method: string, doType: OptionType[], obj: Partial) => { 187 | if (!doType.includes(this.#type)) { 188 | warnBuilder('Option', this.#type, method) 189 | return this 190 | } 191 | return this.a(obj) 192 | } 193 | /** 194 | * [Command Option Structure](https://discord.com/developers/docs/interactions/application-commands#application-command-object-application-command-option-structure) 195 | * @param {string} name 1-32 character name 196 | * @param {string} description 1-100 character description 197 | * @param {"String" | "Integer" | "Number" | "Boolean" | "User" | "Channel" | "Role" | "Mentionable" | "Attachment"} [option_type="String"] 198 | */ 199 | constructor(name: string, description: string, option_type: T = 'String' as T) { 200 | const typeNum = { 201 | String: 3, 202 | Integer: 4, 203 | Boolean: 5, 204 | User: 6, 205 | Channel: 7, 206 | Role: 8, 207 | Mentionable: 9, 208 | Number: 10, 209 | Attachment: 11, 210 | } as const 211 | super(name, description) 212 | this.a({ type: typeNum[option_type] || 3 }) 213 | this.#type = option_type 214 | } 215 | /** 216 | * @param {boolean} [e=true] 217 | * @returns {this} 218 | */ 219 | required = (e = true) => this.a({ required: e }) 220 | /** 221 | * available: String, Integer, Number 222 | * @param {...APIApplicationCommandOptionChoice} e 223 | * @returns {this} 224 | */ 225 | choices = ( 226 | // biome-ignore format: ternary operator 227 | ...e: 228 | T extends 'String' ? APIApplicationCommandOptionChoice[] : 229 | T extends 'Integer' | 'Number' ? APIApplicationCommandOptionChoice[] : 230 | undefined[] 231 | ) => 232 | this.#assign('choices', ['String', 'Integer', 'Number'], { choices: e as APIApplicationCommandOptionChoice[] }) 233 | /** 234 | * available: Channel 235 | * 236 | * [Channel Types](https://discord.com/developers/docs/resources/channel#channel-object-channel-types) 237 | * @param {...ChannelType} e 238 | * @returns {this} 239 | */ 240 | channel_types = (...e: T extends 'Channel' ? ChannelType[] : undefined[]) => 241 | // @ts-expect-error 242 | this.#assign('channel_types', ['Channel'], { channel_types: e as ChannelType[] }) 243 | /** 244 | * available: Integer, Number 245 | * @param e 246 | * @returns {this} 247 | */ 248 | min_value = (e: T extends 'Integer' | 'Number' ? number : undefined) => 249 | this.#assign('min_value', ['Integer', 'Number'], { min_value: e }) 250 | /** 251 | * available: Integer, Number 252 | * @param e 253 | * @returns {this} 254 | */ 255 | max_value = (e: T extends 'Integer' | 'Number' ? number : undefined) => 256 | this.#assign('max_value', ['Integer', 'Number'], { max_value: e }) 257 | /** 258 | * available: String 259 | * @param e 0 - 6000 260 | * @returns {this} 261 | */ 262 | min_length = (e: T extends 'String' ? number : undefined) => this.#assign('min_length', ['String'], { min_length: e }) 263 | /** 264 | * available: String 265 | * @param e 1 - 6000 266 | * @returns {this} 267 | */ 268 | max_length = (e: T extends 'String' ? number : undefined) => this.#assign('max_length', ['String'], { max_length: e }) 269 | /** 270 | * available: String, Integer, Number 271 | * @param e default: true 272 | * @returns {this} 273 | */ 274 | autocomplete = (e?: T extends 'String' | 'Integer' | 'Number' ? boolean : undefined) => 275 | this.#assign('autocomplete', ['String', 'Integer', 'Number'], { autocomplete: e !== false }) 276 | } 277 | -------------------------------------------------------------------------------- /src/builders/components-v2.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest' 2 | import { Content, Layout } from './components-v2' 3 | 4 | describe('Layout', () => { 5 | describe('ActionRow', () => { 6 | it('creates an action row with correct type', () => { 7 | const actionRow = new Layout('Action Row') 8 | expect(actionRow.toJSON()).toEqual({ type: 1 }) 9 | }) 10 | 11 | it('can add components to an action row', () => { 12 | const actionRow = new Layout('Action Row').components( 13 | { type: 2, style: 1, label: 'Button', custom_id: 'btn1' }, 14 | { type: 3, custom_id: 'select1', options: [] }, 15 | ) 16 | expect(actionRow.toJSON()).toEqual({ 17 | type: 1, 18 | components: [ 19 | { type: 2, style: 1, label: 'Button', custom_id: 'btn1' }, 20 | { type: 3, custom_id: 'select1', options: [] }, 21 | ], 22 | }) 23 | }) 24 | 25 | it('can set an id', () => { 26 | const actionRow = new Layout('Action Row').id(123) 27 | expect(actionRow.toJSON()).toEqual({ type: 1, id: 123 }) 28 | }) 29 | }) 30 | 31 | describe('Section', () => { 32 | it('creates a section with correct type', () => { 33 | const section = new Layout('Section') 34 | expect(section.toJSON()).toEqual({ type: 9 }) 35 | }) 36 | 37 | it('can add components to a section', () => { 38 | const section = new Layout('Section').components(new Content('Hello world')) 39 | expect(section.toJSON()).toEqual({ 40 | type: 9, 41 | components: [{ type: 10, content: 'Hello world' }], 42 | }) 43 | }) 44 | 45 | it('can add an accessory', () => { 46 | const section = new Layout('Section').accessory({ type: 2, style: 1, label: 'Button', custom_id: 'btn1' }) 47 | expect(section.toJSON()).toEqual({ 48 | type: 9, 49 | accessory: { type: 2, style: 1, label: 'Button', custom_id: 'btn1' }, 50 | }) 51 | }) 52 | }) 53 | 54 | describe('Separator', () => { 55 | it('creates a separator with correct type', () => { 56 | const separator = new Layout('Separator') 57 | expect(separator.toJSON()).toEqual({ type: 14 }) 58 | }) 59 | 60 | it('can set divider property', () => { 61 | const separator = new Layout('Separator').divider(true) 62 | expect(separator.toJSON()).toEqual({ type: 14, divider: true }) 63 | }) 64 | 65 | it('can set spacing property', () => { 66 | const separator = new Layout('Separator').spacing(2) 67 | expect(separator.toJSON()).toEqual({ type: 14, spacing: 2 }) 68 | }) 69 | }) 70 | 71 | describe('Container', () => { 72 | it('creates a container with correct type', () => { 73 | const container = new Layout('Container') 74 | expect(container.toJSON()).toEqual({ type: 17 }) 75 | }) 76 | 77 | it('can add components to a container', () => { 78 | const container = new Layout('Container').components( 79 | new Layout('Action Row'), 80 | new Layout('Section').components(new Content('Text content')), 81 | new Content('Another text', 'Text Display'), 82 | ) 83 | expect(container.toJSON()).toEqual({ 84 | type: 17, 85 | components: [ 86 | { type: 1 }, 87 | { type: 9, components: [{ type: 10, content: 'Text content' }] }, 88 | { type: 10, content: 'Another text' }, 89 | ], 90 | }) 91 | }) 92 | 93 | it('can set accent_color property', () => { 94 | const container = new Layout('Container').accent_color(0xff0000) 95 | expect(container.toJSON()).toEqual({ type: 17, accent_color: 0xff0000 }) 96 | }) 97 | 98 | it('can set spoiler property', () => { 99 | const container = new Layout('Container').spoiler(true) 100 | expect(container.toJSON()).toEqual({ type: 17, spoiler: true }) 101 | }) 102 | }) 103 | }) 104 | 105 | describe('Content', () => { 106 | describe('TextDisplay', () => { 107 | it('creates a text display with correct type and content', () => { 108 | const textDisplay = new Content('Hello, world!') 109 | expect(textDisplay.toJSON()).toEqual({ type: 10, content: 'Hello, world!' }) 110 | }) 111 | 112 | it('can set an id', () => { 113 | const textDisplay = new Content('Hello, world!').id(123) 114 | expect(textDisplay.toJSON()).toEqual({ type: 10, content: 'Hello, world!', id: 123 }) 115 | }) 116 | }) 117 | 118 | describe('Thumbnail', () => { 119 | it('creates a thumbnail with correct type and media from string', () => { 120 | const thumbnail = new Content('image.png', 'Thumbnail') 121 | expect(thumbnail.toJSON()).toEqual({ 122 | type: 11, 123 | media: { url: 'attachment://image.png' }, 124 | }) 125 | }) 126 | 127 | it('handles URLs properly', () => { 128 | const thumbnail = new Content('https://example.com/image.png', 'Thumbnail') 129 | expect(thumbnail.toJSON()).toEqual({ 130 | type: 11, 131 | media: { url: 'https://example.com/image.png' }, 132 | }) 133 | }) 134 | 135 | it('can set description', () => { 136 | const thumbnail = new Content('image.png', 'Thumbnail').description('Image description') 137 | expect(thumbnail.toJSON()).toEqual({ 138 | type: 11, 139 | media: { url: 'attachment://image.png' }, 140 | description: 'Image description', 141 | }) 142 | }) 143 | 144 | it('can set spoiler', () => { 145 | const thumbnail = new Content('image.png', 'Thumbnail').spoiler(true) 146 | expect(thumbnail.toJSON()).toEqual({ 147 | type: 11, 148 | media: { url: 'attachment://image.png' }, 149 | spoiler: true, 150 | }) 151 | }) 152 | }) 153 | 154 | describe('MediaGallery', () => { 155 | it('creates a media gallery with correct type and items from string', () => { 156 | const gallery = new Content('image.png', 'Media Gallery') 157 | expect(gallery.toJSON()).toEqual({ 158 | type: 12, 159 | items: [{ media: { url: 'attachment://image.png' } }], 160 | }) 161 | }) 162 | 163 | it('handles multiple items', () => { 164 | const gallery = new Content(['image1.png', 'image2.png'], 'Media Gallery') 165 | expect(gallery.toJSON()).toEqual({ 166 | type: 12, 167 | items: [{ media: { url: 'attachment://image1.png' } }, { media: { url: 'attachment://image2.png' } }], 168 | }) 169 | }) 170 | }) 171 | 172 | describe('File', () => { 173 | it('creates a file with correct type and file from string', () => { 174 | const file = new Content('document.pdf', 'File') 175 | expect(file.toJSON()).toEqual({ 176 | type: 13, 177 | file: { url: 'attachment://document.pdf' }, 178 | }) 179 | }) 180 | 181 | it('can set spoiler', () => { 182 | const file = new Content('document.pdf', 'File').spoiler(true) 183 | expect(file.toJSON()).toEqual({ 184 | type: 13, 185 | file: { url: 'attachment://document.pdf' }, 186 | spoiler: true, 187 | }) 188 | }) 189 | }) 190 | }) 191 | -------------------------------------------------------------------------------- /src/builders/components-v2.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | APIActionRowComponent, 3 | APIButtonComponent, 4 | APIComponentInMessageActionRow, 5 | APIContainerComponent, 6 | APIFileComponent, 7 | APIMediaGalleryComponent, 8 | APISectionComponent, 9 | APISeparatorComponent, 10 | APITextDisplayComponent, 11 | APIThumbnailComponent, 12 | } from 'discord-api-types/v10' 13 | import type { ExcludeMethods } from '../types' 14 | import { toJSON } from '../utils' 15 | import type { Button, Select } from './components' 16 | import { Builder } from './utils' 17 | 18 | type LayoutStyle = 'Action Row' | 'Section' | 'Separator' | 'Container' 19 | // biome-ignore format: ternary operator 20 | type LayoutComponent = 21 | T extends 'Action Row' ? APIActionRowComponent : 22 | T extends 'Section' ? APISectionComponent : 23 | T extends 'Separator' ? APISeparatorComponent : 24 | T extends 'Container' ? APIContainerComponent : 25 | never 26 | class LayoutImpl extends Builder> { 27 | /** 28 | * required: flags("IS_COMPONENTS_V2") 29 | * 30 | * [Layout Style Structure](https://discord.com/developers/docs/components/reference#component-object) 31 | * @param {"Action Row" | "Section" | "Separator" | "Container"} style 32 | */ 33 | constructor(style: T) { 34 | const typeNum = { 35 | 'Action Row': 1, 36 | Section: 9, 37 | Separator: 14, 38 | Container: 17, 39 | } as const 40 | super({ type: typeNum[style] } as LayoutComponent) 41 | } 42 | /** 43 | * available: ALL 44 | * @param {number} e 45 | * @returns {this} 46 | */ 47 | id = (e: number) => this.a({ id: e } as Partial>) 48 | /** 49 | * required: [Action Row](https://discord.com/developers/docs/components/reference#action-row-action-row-structure), [Section](https://discord.com/developers/docs/components/reference#section-section-structure), [Container](https://discord.com/developers/docs/components/reference#container-container-structure) 50 | * @param e 51 | * @returns {this} 52 | */ 53 | components = ( 54 | ...e: ( 55 | // biome-ignore format: ternary operator 56 | T extends 'Action Row' ? APIComponentInMessageActionRow | Button | Select : 57 | T extends 'Section' ? APISectionComponent | ContentTextDisplay : 58 | T extends 'Container' ? 59 | | APIContainerComponent 60 | | LayoutActionRow | LayoutSection | LayoutSeparator 61 | | ContentTextDisplay | ContentMediaGallery | ContentFile : 62 | never 63 | )[] 64 | ) => 65 | // @ts-expect-error 66 | this.a({ components: e.map(toJSON) }) 67 | /** 68 | * available: [Section](https://discord.com/developers/docs/components/reference#section-section-structure) 69 | * @param {APIButtonComponent | APIThumbnailComponent} e 70 | * @returns {this} 71 | */ 72 | accessory = ( 73 | e: T extends 'Section' ? APIButtonComponent | APIThumbnailComponent | Button | ContentThumbnail : never, 74 | ) => 75 | // @ts-expect-error 76 | this.a({ accessory: toJSON(e) }) 77 | /** 78 | * available: [Separator](https://discord.com/developers/docs/components/reference#separator-separator-structure) 79 | * @param {boolean} e 80 | * @returns {this} 81 | */ 82 | // @ts-expect-error 83 | divider = (e: T extends 'Separator' ? boolean : never) => this.a({ divider: e }) 84 | /** 85 | * available: [Separator](https://discord.com/developers/docs/components/reference#separator-separator-structure) 86 | * @param {1 | 2} e 87 | * @returns {this} 88 | */ 89 | // @ts-expect-error 90 | spacing = (e: T extends 'Separator' ? 1 | 2 : never) => this.a({ spacing: e }) 91 | /** 92 | * available: [Container](https://discord.com/developers/docs/components/reference#container-container-structure) 93 | * @param {number} e 94 | * @returns {this} 95 | */ 96 | // @ts-expect-error 97 | accent_color = (e: T extends 'Container' ? number : never) => this.a({ accent_color: e }) 98 | /** 99 | * available: [Container](https://discord.com/developers/docs/components/reference#container-container-structure) 100 | * @param {boolean} [e=true] default: true 101 | * @returns {this} 102 | */ 103 | // @ts-expect-error 104 | spoiler = (e: T extends 'Container' ? boolean : never = true) => this.a({ spoiler: e }) 105 | } 106 | 107 | export type LayoutActionRow = ExcludeMethods< 108 | LayoutImpl<'Action Row'>, 109 | 'accessory' | 'divider' | 'spacing' | 'accent_color' | 'spoiler' 110 | > 111 | export type LayoutSection = ExcludeMethods, 'divider' | 'spacing' | 'accent_color' | 'spoiler'> 112 | export type LayoutSeparator = ExcludeMethods< 113 | LayoutImpl<'Separator'>, 114 | 'components' | 'accessory' | 'accent_color' | 'spoiler' 115 | > 116 | export type LayoutContainer = ExcludeMethods, 'accessory' | 'divider' | 'spacing'> 117 | 118 | export const Layout = LayoutImpl as { 119 | new (style: 'Action Row'): LayoutActionRow 120 | new (style: 'Section'): LayoutSection 121 | new (style: 'Separator'): LayoutSeparator 122 | new (style: 'Container'): LayoutContainer 123 | } 124 | 125 | const mediaItem = (str: string) => ({ 126 | url: URL.canParse(str) || str.startsWith('attachment://') ? str : `attachment://${str}`, 127 | }) 128 | 129 | type ContentStyle = 'Text Display' | 'Thumbnail' | 'Media Gallery' | 'File' 130 | // biome-ignore format: ternary operator 131 | type ContentJson = 132 | T extends 'Text Display' ? APITextDisplayComponent : 133 | T extends 'Thumbnail' ? APIThumbnailComponent : 134 | T extends 'Media Gallery' ? APIMediaGalleryComponent : 135 | T extends 'File' ? APIFileComponent : 136 | never 137 | // biome-ignore format: ternary operator 138 | type ContentData = 139 | T extends 'Text Display' ? APITextDisplayComponent['content'] : 140 | T extends 'Thumbnail' ? string | APIThumbnailComponent['media'] : 141 | T extends 'Media Gallery' ? 142 | | string 143 | | APIMediaGalleryComponent['items'][number] 144 | | (string | APIMediaGalleryComponent['items'][number])[] : 145 | T extends 'File' ? string | APIFileComponent['file'] : 146 | never 147 | class ContentImpl extends Builder> { 148 | /** 149 | * required: flags("IS_COMPONENTS_V2") 150 | * 151 | * [Content Style Structure](https://discord.com/developers/docs/components/reference#component-object) 152 | * @param data 153 | * @param {"Text Display" | "Thumbnail" | "Media Gallery" | "File"} style 154 | */ 155 | constructor(data: ContentData, style: T = 'Text Display' as T) { 156 | switch (style) { 157 | case 'Thumbnail': 158 | super({ type: 11, media: typeof data === 'string' ? mediaItem(data) : data } as ContentJson) 159 | break 160 | case 'Media Gallery': { 161 | const items = (Array.isArray(data) ? data : [data]) as (string | APIMediaGalleryComponent['items'][number])[] 162 | super({ 163 | type: 12, 164 | items: items.map(item => (typeof item === 'string' ? { media: mediaItem(item) } : item)), 165 | } as ContentJson) 166 | break 167 | } 168 | case 'File': 169 | super({ type: 13, file: typeof data === 'string' ? mediaItem(data) : data } as ContentJson) 170 | break 171 | default: // Text Display 172 | super({ type: 10, content: data } as ContentJson) 173 | } 174 | } 175 | /** 176 | * available: ALL 177 | * @param {number} e 178 | * @returns {this} 179 | */ 180 | id = (e: number) => this.a({ id: e } as Partial>) 181 | /** 182 | * available: [Thumbnail](https://discord.com/developers/docs/components/reference#thumbnail-thumbnail-structure) 183 | * @param {string} e 184 | * @returns {this} 185 | */ 186 | // @ts-expect-error 187 | description = (e: T extends 'Thumbnail' ? string : never) => this.a({ description: e }) 188 | /** 189 | * available: [Thumbnail](https://discord.com/developers/docs/components/reference#thumbnail-thumbnail-structure), [File](https://discord.com/developers/docs/components/reference#file-file-structure) 190 | * @param {string} e 191 | * @returns {this} 192 | */ 193 | // @ts-expect-error 194 | spoiler = (e: T extends 'Thumbnail' | 'File' ? boolean : never = true) => this.a({ spoiler: e }) 195 | } 196 | 197 | export type ContentTextDisplay = ExcludeMethods, 'description' | 'spoiler'> 198 | type ContentThumbnail = ContentImpl<'Thumbnail'> 199 | export type ContentMediaGallery = ExcludeMethods, 'description' | 'spoiler'> 200 | export type ContentFile = ExcludeMethods, 'description'> 201 | 202 | export const Content = ContentImpl as { 203 | new (data: string, style?: 'Text Display'): ContentTextDisplay 204 | new (data: string | APIThumbnailComponent['media'], style: 'Thumbnail'): ContentThumbnail 205 | new ( 206 | data: string | APIMediaGalleryComponent['items'][number] | (string | APIMediaGalleryComponent['items'][number])[], 207 | style: 'Media Gallery', 208 | ): ContentMediaGallery 209 | new (data: string | APIFileComponent['file'], style: 'File'): ContentFile 210 | } 211 | -------------------------------------------------------------------------------- /src/builders/components.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi } from 'vitest' 2 | import { CUSTOM_ID_SEPARATOR } from '../utils' 3 | import { Button, Components, Select } from './components' 4 | 5 | describe('Components Builder', () => { 6 | describe('Components', () => { 7 | it('should create empty component list', () => { 8 | const components = new Components() 9 | expect(components.toJSON()).toEqual([]) 10 | }) 11 | 12 | it('should add a row with a button', () => { 13 | const components = new Components() 14 | const button = new Button('test_button', 'Click Me') 15 | 16 | components.row(button) 17 | 18 | expect(components.toJSON()).toEqual([ 19 | { 20 | type: 1, 21 | components: [ 22 | { 23 | type: 2, 24 | label: 'Click Me', 25 | style: 1, 26 | custom_id: `test_button${CUSTOM_ID_SEPARATOR}`, 27 | }, 28 | ], 29 | }, 30 | ]) 31 | }) 32 | 33 | it('should add multiple rows', () => { 34 | const components = new Components() 35 | const button1 = new Button('button1', 'First') 36 | const button2 = new Button('button2', 'Second') 37 | 38 | components.row(button1).row(button2) 39 | 40 | expect(components.toJSON()).toHaveLength(2) 41 | }) 42 | 43 | it('should warn when adding more than 5 action rows', () => { 44 | // biome-ignore lint: empty block 45 | const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) 46 | const components = new Components() 47 | 48 | // Add 6 rows 49 | for (let i = 0; i < 6; i++) { 50 | components.row(new Button(`button${i}`, `Button ${i}`)) 51 | } 52 | 53 | expect(consoleSpy).toHaveBeenCalledWith('You can have up to 5 Action Rows per message') 54 | consoleSpy.mockRestore() 55 | }) 56 | }) 57 | 58 | describe('Button', () => { 59 | it('should create a primary button', () => { 60 | const button = new Button('test_button', 'Click Me') 61 | 62 | expect(button.toJSON()).toEqual({ 63 | type: 2, 64 | label: 'Click Me', 65 | style: 1, 66 | custom_id: `test_button${CUSTOM_ID_SEPARATOR}`, 67 | }) 68 | }) 69 | 70 | it('should create a secondary button', () => { 71 | const button = new Button('test_button', 'Click Me', 'Secondary') 72 | 73 | expect(button.toJSON()).toEqual({ 74 | type: 2, 75 | label: 'Click Me', 76 | style: 2, 77 | custom_id: `test_button${CUSTOM_ID_SEPARATOR}`, 78 | }) 79 | }) 80 | 81 | it('should create a link button', () => { 82 | const button = new Button('https://example.com', 'Visit Website', 'Link') 83 | 84 | expect(button.toJSON()).toEqual({ 85 | type: 2, 86 | label: 'Visit Website', 87 | style: 5, 88 | url: 'https://example.com', 89 | }) 90 | }) 91 | 92 | it('should create a SKU button', () => { 93 | const button = new Button('sku_123', '', 'SKU') 94 | 95 | expect(button.toJSON()).toEqual({ 96 | type: 2, 97 | style: 6, 98 | sku_id: 'sku_123', 99 | }) 100 | }) 101 | 102 | it('should add emoji to button', () => { 103 | const button = new Button('test_button', 'Click Me').emoji('👍') 104 | 105 | expect(button.toJSON()).toEqual({ 106 | type: 2, 107 | label: 'Click Me', 108 | style: 1, 109 | custom_id: `test_button${CUSTOM_ID_SEPARATOR}`, 110 | emoji: { name: '👍' }, 111 | }) 112 | }) 113 | 114 | it('should create button with emoji from constructor', () => { 115 | const button = new Button('test_button', ['👍', 'Click Me']) 116 | 117 | expect(button.toJSON()).toEqual({ 118 | type: 2, 119 | label: 'Click Me', 120 | style: 1, 121 | custom_id: `test_button${CUSTOM_ID_SEPARATOR}`, 122 | emoji: { name: '👍' }, 123 | }) 124 | }) 125 | 126 | it('should add custom_id suffix', () => { 127 | const button = new Button('test_button', 'Click Me').custom_id('suffix') 128 | 129 | expect(button.toJSON()).toEqual({ 130 | type: 2, 131 | label: 'Click Me', 132 | style: 1, 133 | custom_id: `test_button${CUSTOM_ID_SEPARATOR}suffix`, 134 | }) 135 | }) 136 | 137 | it('should disable a button', () => { 138 | const button = new Button('test_button', 'Click Me').disabled() 139 | 140 | expect(button.toJSON()).toEqual({ 141 | type: 2, 142 | label: 'Click Me', 143 | style: 1, 144 | custom_id: `test_button${CUSTOM_ID_SEPARATOR}`, 145 | disabled: true, 146 | }) 147 | }) 148 | 149 | it('should change label', () => { 150 | const button = new Button('test_button', 'Original').label('Changed') 151 | 152 | expect(button.toJSON()).toEqual({ 153 | type: 2, 154 | label: 'Changed', 155 | style: 1, 156 | custom_id: `test_button${CUSTOM_ID_SEPARATOR}`, 157 | }) 158 | }) 159 | }) 160 | 161 | describe('Select', () => { 162 | it('should create a string select menu', () => { 163 | const select = new Select('test_select') 164 | 165 | expect(select.toJSON()).toEqual({ 166 | type: 3, 167 | custom_id: `test_select${CUSTOM_ID_SEPARATOR}`, 168 | }) 169 | }) 170 | 171 | it('should create a user select menu', () => { 172 | const select = new Select('test_select', 'User') 173 | 174 | expect(select.toJSON()).toEqual({ 175 | type: 5, 176 | custom_id: `test_select${CUSTOM_ID_SEPARATOR}`, 177 | }) 178 | }) 179 | 180 | it('should add options to string select menu', () => { 181 | const select = new Select('test_select').options({ label: 'Option 1', value: 'option1' }) 182 | 183 | expect(select.toJSON()).toEqual({ 184 | type: 3, 185 | custom_id: `test_select${CUSTOM_ID_SEPARATOR}`, 186 | options: [{ label: 'Option 1', value: 'option1' }], 187 | }) 188 | }) 189 | 190 | it('should add channel types to channel select menu', () => { 191 | const select = new Select('test_select', 'Channel').channel_types(0, 1) // GUILD_TEXT and DM 192 | 193 | expect(select.toJSON()).toEqual({ 194 | type: 8, 195 | custom_id: `test_select${CUSTOM_ID_SEPARATOR}`, 196 | channel_types: [0, 1], 197 | }) 198 | }) 199 | 200 | it('should add placeholder', () => { 201 | const select = new Select('test_select').placeholder('Choose an option') 202 | 203 | expect(select.toJSON()).toEqual({ 204 | type: 3, 205 | custom_id: `test_select${CUSTOM_ID_SEPARATOR}`, 206 | placeholder: 'Choose an option', 207 | }) 208 | }) 209 | 210 | it('should add default values to user select menu', () => { 211 | const select = new Select('test_select', 'User').default_values({ id: '123456789', type: 'user' }) 212 | 213 | expect(select.toJSON()).toEqual({ 214 | type: 5, 215 | custom_id: `test_select${CUSTOM_ID_SEPARATOR}`, 216 | default_values: [{ id: '123456789', type: 'user' }], 217 | }) 218 | }) 219 | 220 | it('should add min and max values', () => { 221 | const select = new Select('test_select').min_values(1).max_values(3) 222 | 223 | expect(select.toJSON()).toEqual({ 224 | type: 3, 225 | custom_id: `test_select${CUSTOM_ID_SEPARATOR}`, 226 | min_values: 1, 227 | max_values: 3, 228 | }) 229 | }) 230 | 231 | it('should disable a select menu', () => { 232 | const select = new Select('test_select').disabled() 233 | 234 | expect(select.toJSON()).toEqual({ 235 | type: 3, 236 | custom_id: `test_select${CUSTOM_ID_SEPARATOR}`, 237 | disabled: true, 238 | }) 239 | }) 240 | 241 | it('should add custom_id suffix', () => { 242 | const select = new Select('test_select').custom_id('suffix') 243 | 244 | expect(select.toJSON()).toEqual({ 245 | type: 3, 246 | custom_id: `test_select${CUSTOM_ID_SEPARATOR}suffix`, 247 | }) 248 | }) 249 | }) 250 | }) 251 | -------------------------------------------------------------------------------- /src/builders/components.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | APIActionRowComponent, 3 | APIButtonComponent, 4 | APIChannelSelectComponent, 5 | APIComponentInMessageActionRow, 6 | APIMentionableSelectComponent, 7 | APIMessageComponentEmoji, 8 | APIRoleSelectComponent, 9 | APISelectMenuOption, 10 | APIStringSelectComponent, 11 | APIUserSelectComponent, 12 | ChannelType, 13 | } from 'discord-api-types/v10' 14 | import { CUSTOM_ID_SEPARATOR, toJSON } from '../utils' 15 | import { Builder, ifThrowHasSemicolon, warnBuilder } from './utils' 16 | 17 | /** 18 | * [Message Components](https://discord.com/developers/docs/interactions/message-components) 19 | */ 20 | export class Components { 21 | #components: APIActionRowComponent[] = [] 22 | /** 23 | * push component 24 | * @param {...(Button | Select | APIComponentInMessageActionRow)} e 25 | * @returns {this} 26 | */ 27 | row = (...e: (Button | Select | APIComponentInMessageActionRow)[]) => { 28 | // biome-ignore lint: console 29 | if (this.#components.length >= 5) console.warn('You can have up to 5 Action Rows per message') 30 | this.#components.push({ 31 | type: 1, 32 | components: e.map(toJSON), 33 | }) 34 | return this 35 | } 36 | /** 37 | * export json object 38 | * @returns {Obj} 39 | */ 40 | toJSON = () => this.#components 41 | } 42 | 43 | //type ButtonStyle = 'Primary' | 'Secondary' | 'Success' | 'Danger' | 'Link' | 'SKU' 44 | const buttonStyleNum = { 45 | Primary: 1, 46 | Secondary: 2, 47 | Success: 3, 48 | Danger: 4, 49 | Link: 5, 50 | SKU: 6, 51 | } as const 52 | type ButtonStyle = keyof typeof buttonStyleNum 53 | export class Button extends Builder { 54 | #style: ButtonStyle 55 | #uniqueStr = '' 56 | #assign = (method: string, doNotStyle: ButtonStyle[], obj: Partial) => { 57 | if (doNotStyle.includes(this.#style)) { 58 | warnBuilder('Button', this.#style, method) 59 | return this 60 | } 61 | return this.a(obj) 62 | } 63 | /** 64 | * [Button Structure](https://discord.com/developers/docs/interactions/message-components#button-object) 65 | * @param {string} str Basic: unique_id, Link: URL, SKU: sku_id 66 | * @param {string} label The label to be displayed on the button. max 80 characters - Ignore: SKU 67 | * @param {"Primary" | "Secondary" | "Success" | "Danger" | "Link" | "SKU"} [button_style="Primary"] 68 | */ 69 | constructor( 70 | str: string, 71 | labels: T extends 'SKU' ? '' | undefined : string | [string | APIMessageComponentEmoji, string], 72 | button_style: T = 'Primary' as T, 73 | ) { 74 | const style = buttonStyleNum[button_style] || 1 75 | const custom_id = str + CUSTOM_ID_SEPARATOR 76 | const isArrayLabels = Array.isArray(labels) 77 | const label: string | undefined = isArrayLabels ? labels[1] : labels 78 | let obj: APIButtonComponent 79 | switch (style) { 80 | case 5: 81 | obj = { type: 2, label, style, url: str } 82 | break 83 | case 6: 84 | obj = { type: 2, style, sku_id: str } 85 | break 86 | default: 87 | ifThrowHasSemicolon(str) 88 | obj = { type: 2, label, style, custom_id } 89 | } 90 | super(obj) 91 | this.#style = button_style 92 | this.#uniqueStr = custom_id 93 | if (isArrayLabels) this.emoji(labels[0] as T extends 'SKU' ? undefined : string | APIMessageComponentEmoji) 94 | } 95 | /** 96 | * available: Primary, Secondary, Success, Danger, Link 97 | * @param {string | APIMessageComponentEmoji} e 98 | * @returns {this} 99 | */ 100 | emoji = (e: T extends 'SKU' ? undefined : string | APIMessageComponentEmoji) => 101 | this.#assign('emoji', ['SKU'], { emoji: typeof e === 'string' ? { name: e } : e }) 102 | /** 103 | * available: Primary, Secondary, Success, Danger 104 | * @param {string} e 105 | * @returns {this} 106 | */ 107 | custom_id = (e: T extends 'Link' | 'SKU' ? undefined : string) => 108 | this.#assign('custom_id', ['Link', 'SKU'], { custom_id: this.#uniqueStr + e }) 109 | /** 110 | * available: ALL 111 | * @param {boolean} [e=true] 112 | * @returns {this} 113 | */ 114 | disabled = (e = true) => this.a({ disabled: e }) 115 | /** 116 | * Overwrite label 117 | * 118 | * available: Primary, Secondary, Success, Danger, Link 119 | * @param {string} e 120 | * @returns {this} 121 | */ 122 | label = (e: T extends 'SKU' ? undefined : string) => this.#assign('label', ['SKU'], { label: e }) 123 | /** 124 | * Overwrite button style 125 | * 126 | * available: Primary, Secondary, Success, Danger 127 | * @param {"Primary" | "Secondary" | "Success" | "Danger"} e 128 | * @returns {this} 129 | */ 130 | style = (e: Exclude) => 131 | this.#assign('style', ['Link', 'SKU'], { style: buttonStyleNum[e] }) 132 | } 133 | 134 | type SelectType = 'String' | 'User' | 'Role' | 'Mentionable' | 'Channel' 135 | type SelectComponent = 136 | | APIStringSelectComponent 137 | | APIUserSelectComponent 138 | | APIRoleSelectComponent 139 | | APIMentionableSelectComponent 140 | | APIChannelSelectComponent 141 | export class Select extends Builder { 142 | #type: SelectType 143 | #uniqueStr = '' 144 | #assign = (method: string, doType: SelectType[], obj: Partial) => { 145 | if (!doType.includes(this.#type)) { 146 | warnBuilder('Select', this.#type, method) 147 | return this 148 | } 149 | return this.a(obj) 150 | } 151 | /** 152 | * [Select Structure](https://discord.com/developers/docs/interactions/message-components#select-menu-object) 153 | * @param {string} unique_id 154 | * @param {"String" | "User" | "Role" | "Mentionable" | "Channel"} [selectType="String"] 155 | */ 156 | constructor(unique_id: string, select_type: T = 'String' as T) { 157 | ifThrowHasSemicolon(unique_id) 158 | const typeNum = { 159 | String: 3, 160 | User: 5, 161 | Role: 6, 162 | Mentionable: 7, 163 | Channel: 8, 164 | } as const 165 | const custom_id = unique_id + CUSTOM_ID_SEPARATOR 166 | super({ type: typeNum[select_type], custom_id } as SelectComponent) 167 | this.#type = select_type 168 | this.#uniqueStr = custom_id 169 | } 170 | /** 171 | * available: ALL 172 | * @param {string} e 173 | * @returns {this} 174 | */ 175 | custom_id = (e: string) => this.a({ custom_id: this.#uniqueStr + e }) 176 | /** 177 | * required: String 178 | * 179 | * [Select Option Structure](https://discord.com/developers/docs/interactions/message-components#select-menu-object-select-option-structure) 180 | * @param {APISelectMenuOption} e 181 | * @returns {this} 182 | */ 183 | options = (...e: T extends 'String' ? APISelectMenuOption[] : undefined[]) => 184 | this.#assign('options', ['String'], { options: e as APISelectMenuOption[] }) 185 | /** 186 | * available: Channel 187 | * 188 | * [Channel Types](https://discord.com/developers/docs/resources/channel#channel-object-channel-types) 189 | * @param {...ChannelType} e 190 | * @returns {this} 191 | */ 192 | channel_types = (...e: T extends 'Channel' ? ChannelType[] : undefined[]) => 193 | this.#assign('channel_types', ['Channel'], { channel_types: e as ChannelType[] }) 194 | /** 195 | * Custom placeholder text if nothing is selected, max 150 characters 196 | * @param {string} e 197 | * @returns {this} 198 | */ 199 | placeholder = (e: string) => this.a({ placeholder: e }) 200 | /** 201 | * available: User, Role, Channel, Mentionable 202 | * 203 | * [Select Default Value Structure](https://discord.com/developers/docs/interactions/message-components#select-menu-object-select-default-value-structure) 204 | * @param {...{ id: string, type: "user" | "role" | "channel" }} e 205 | * @returns {this} 206 | */ 207 | default_values = ( 208 | // biome-ignore format: ternary operator 209 | ...e: T extends 'String' ? never[] : 210 | { 211 | id: string 212 | type: 213 | T extends 'User' ? 'user' : 214 | T extends 'Role' ? 'role' : 215 | T extends 'Channel' ? 'channel' : 216 | T extends 'Mentionable' ? 'user' | 'role' : 217 | never 218 | }[] 219 | // @ts-expect-error 220 | ) => this.#assign('default_values', ['User', 'Role', 'Channel', 'Mentionable'], { default_values: e }) 221 | /** 222 | * The minimum number of items that must be chosen; min 0, max 25 223 | * @param {number} [e=1] 224 | * @returns {this} 225 | */ 226 | min_values = (e = 1) => this.a({ min_values: e }) 227 | /** 228 | * The maximum number of items that can be chosen; max 25 229 | * @param {number} [e=1] 230 | * @returns {this} 231 | */ 232 | max_values = (e = 1) => this.a({ max_values: e }) 233 | /** 234 | * Disable the select 235 | * @param {boolean} [e=true] 236 | * @returns {this} 237 | */ 238 | disabled = (e = true) => this.a({ disabled: e }) 239 | } 240 | -------------------------------------------------------------------------------- /src/builders/embed.test.ts: -------------------------------------------------------------------------------- 1 | import type { APIEmbedField, EmbedType } from 'discord-api-types/v10' 2 | import { describe, expect, it } from 'vitest' 3 | import { Embed } from './embed' 4 | 5 | describe('Embed', () => { 6 | it('should create an empty embed', () => { 7 | const embed = new Embed() 8 | expect(embed.toJSON()).toEqual({}) 9 | }) 10 | 11 | it('should set title', () => { 12 | const embed = new Embed().title('Test Title') 13 | expect(embed.toJSON()).toEqual({ title: 'Test Title' }) 14 | }) 15 | 16 | it('should set type', () => { 17 | const embed = new Embed().type('rich' as EmbedType) 18 | expect(embed.toJSON()).toEqual({ type: 'rich' }) 19 | }) 20 | 21 | it('should set description', () => { 22 | const embed = new Embed().description('Test Description') 23 | expect(embed.toJSON()).toEqual({ description: 'Test Description' }) 24 | }) 25 | 26 | it('should set url', () => { 27 | const embed = new Embed().url('https://example.com') 28 | expect(embed.toJSON()).toEqual({ url: 'https://example.com' }) 29 | }) 30 | 31 | it('should set timestamp', () => { 32 | const timestamp = '2023-01-01T00:00:00.000Z' 33 | const embed = new Embed().timestamp(timestamp) 34 | expect(embed.toJSON()).toEqual({ timestamp }) 35 | }) 36 | 37 | it('should set color', () => { 38 | const embed = new Embed().color(0xff0000) 39 | expect(embed.toJSON()).toEqual({ color: 0xff0000 }) 40 | }) 41 | 42 | it('should set footer', () => { 43 | const footer = { text: 'Footer Text' } 44 | const embed = new Embed().footer(footer) 45 | expect(embed.toJSON()).toEqual({ footer }) 46 | }) 47 | 48 | it('should set image', () => { 49 | const image = { url: 'https://example.com/image.png' } 50 | const embed = new Embed().image(image) 51 | expect(embed.toJSON()).toEqual({ image }) 52 | }) 53 | 54 | it('should set thumbnail', () => { 55 | const thumbnail = { url: 'https://example.com/thumbnail.png' } 56 | const embed = new Embed().thumbnail(thumbnail) 57 | expect(embed.toJSON()).toEqual({ thumbnail }) 58 | }) 59 | 60 | it('should set video', () => { 61 | const video = { url: 'https://example.com/video.mp4' } 62 | const embed = new Embed().video(video) 63 | expect(embed.toJSON()).toEqual({ video }) 64 | }) 65 | 66 | it('should set provider', () => { 67 | const provider = { name: 'Provider Name' } 68 | const embed = new Embed().provider(provider) 69 | expect(embed.toJSON()).toEqual({ provider }) 70 | }) 71 | 72 | it('should set author', () => { 73 | const author = { name: 'Author Name' } 74 | const embed = new Embed().author(author) 75 | expect(embed.toJSON()).toEqual({ author }) 76 | }) 77 | 78 | it('should set fields', () => { 79 | const fields: APIEmbedField[] = [ 80 | { name: 'Field 1', value: 'Value 1' }, 81 | { name: 'Field 2', value: 'Value 2' }, 82 | ] 83 | const embed = new Embed().fields(...fields) 84 | expect(embed.toJSON()).toEqual({ fields }) 85 | }) 86 | 87 | it('should chain multiple methods', () => { 88 | const embed = new Embed() 89 | .title('Test Title') 90 | .description('Test Description') 91 | .color(0xff0000) 92 | .fields({ name: 'Field 1', value: 'Value 1' }) 93 | 94 | expect(embed.toJSON()).toEqual({ 95 | title: 'Test Title', 96 | description: 'Test Description', 97 | color: 0xff0000, 98 | fields: [{ name: 'Field 1', value: 'Value 1' }], 99 | }) 100 | }) 101 | }) 102 | -------------------------------------------------------------------------------- /src/builders/embed.ts: -------------------------------------------------------------------------------- 1 | import type { APIEmbed, APIEmbedField, EmbedType } from 'discord-api-types/v10' 2 | import { Builder } from './utils' 3 | 4 | export class Embed extends Builder { 5 | /** 6 | * [Embed Structure](https://discord.com/developers/docs/resources/message#embed-object) 7 | */ 8 | constructor() { 9 | super({}) 10 | } 11 | /** 12 | * @param {string} e Length limit: 256 characters 13 | * @returns {this} 14 | */ 15 | title = (e: string) => this.a({ title: e }) 16 | /** 17 | * @deprecated Embed types should be considered deprecated and might be removed in a future API version 18 | * @param {EmbedType} e 19 | * @returns {this} 20 | */ 21 | type = (e: EmbedType) => this.a({ type: e }) 22 | /** 23 | * @param {string} e Length limit: 4096 characters 24 | * @returns {this} 25 | */ 26 | description = (e: string) => this.a({ description: e }) 27 | /** 28 | * @param {string} e 29 | * @returns {this} 30 | */ 31 | url = (e: string) => this.a({ url: e }) 32 | /** 33 | * @param {string} e ISO8601 timestamp 34 | * @returns {this} 35 | */ 36 | timestamp = (e: string) => this.a({ timestamp: e }) 37 | /** 38 | * @param {number} e 39 | * @returns {this} 40 | */ 41 | color = (e: number) => this.a({ color: e }) 42 | /** 43 | * [Footer Structure](https://discord.com/developers/docs/resources/message#embed-object-embed-footer-structure) 44 | * @param {APIEmbed["footer"]} e 45 | * @returns {this} 46 | */ 47 | footer = (e: APIEmbed['footer']) => this.a({ footer: e }) 48 | /** 49 | * [Image Structure](https://discord.com/developers/docs/resources/message#embed-object-embed-image-structure) 50 | * @param {APIEmbed["image"]} e 51 | * @returns {this} 52 | */ 53 | image = (e: APIEmbed['image']) => this.a({ image: e }) 54 | /** 55 | * [Thumbnail Structure](https://discord.com/developers/docs/resources/message#embed-object-embed-thumbnail-structure) 56 | * @param {APIEmbed["thumbnail"]} e 57 | * @returns {this} 58 | */ 59 | thumbnail = (e: APIEmbed['thumbnail']) => this.a({ thumbnail: e }) 60 | /** 61 | * [Video Structure](https://discord.com/developers/docs/resources/message#embed-object-embed-video-structure) 62 | * @param {APIEmbed["video"]} e 63 | * @returns {this} 64 | */ 65 | video = (e: APIEmbed['video']) => this.a({ video: e }) 66 | /** 67 | * [Provider Structure](https://discord.com/developers/docs/resources/message#embed-object-embed-provider-structure) 68 | * @param {APIEmbed["provider"]} e 69 | * @returns {this} 70 | */ 71 | provider = (e: APIEmbed['provider']) => this.a({ provider: e }) 72 | /** 73 | * [Author Structure](https://discord.com/developers/docs/resources/message#embed-object-embed-author-structure) 74 | * @param {APIEmbed["author"]} e 75 | * @returns {this} 76 | */ 77 | author = (e: APIEmbed['author']) => this.a({ author: e }) 78 | /** 79 | * [Field Structure](https://discord.com/developers/docs/resources/message#embed-object-embed-field-structure) 80 | * @param {...APIEmbedField} e Length limit: 25 field objects 81 | * @returns {this} 82 | */ 83 | fields = (...e: APIEmbedField[]) => this.a({ fields: e }) 84 | } 85 | -------------------------------------------------------------------------------- /src/builders/index.ts: -------------------------------------------------------------------------------- 1 | export * from './autocomplete' 2 | export * from './command' 3 | export { Layout, Content } from './components-v2' 4 | export * from './components' 5 | export * from './embed' 6 | export * from './modal' 7 | export * from './poll' 8 | -------------------------------------------------------------------------------- /src/builders/modal.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest' 2 | import { CUSTOM_ID_SEPARATOR } from '../utils' 3 | import { Modal, TextInput } from './modal' 4 | 5 | describe('Modal', () => { 6 | it('should create a modal with correct initial values', () => { 7 | const modal = new Modal('test', 'Test Modal') 8 | expect(modal.toJSON()).toEqual({ 9 | title: 'Test Modal', 10 | custom_id: `test${CUSTOM_ID_SEPARATOR}`, 11 | components: [], 12 | }) 13 | }) 14 | 15 | it('should throw an error if unique_id contains separator', () => { 16 | expect(() => new Modal(`test${CUSTOM_ID_SEPARATOR}id`, 'Test')).toThrow(`Don't use "${CUSTOM_ID_SEPARATOR}"`) 17 | }) 18 | 19 | it('should update custom_id', () => { 20 | const modal = new Modal('test', 'Test Modal') 21 | modal.custom_id('newId') 22 | expect(modal.toJSON().custom_id).toBe(`test${CUSTOM_ID_SEPARATOR}newId`) 23 | }) 24 | 25 | it('should add text input components', () => { 26 | const modal = new Modal('test', 'Test Modal') 27 | const textInput = new TextInput('input1', 'Input 1') 28 | modal.row(textInput) 29 | expect(modal.toJSON().components).toHaveLength(1) 30 | expect(modal.toJSON().components[0].components).toHaveLength(1) 31 | }) 32 | 33 | it('should update title', () => { 34 | const modal = new Modal('test', 'Test Modal') 35 | modal.title('New Title') 36 | expect(modal.toJSON().title).toBe('New Title') 37 | }) 38 | }) 39 | 40 | describe('TextInput', () => { 41 | it('should create a text input with correct initial values', () => { 42 | const textInput = new TextInput('input1', 'Input 1') 43 | expect(textInput.toJSON()).toEqual({ 44 | type: 4, 45 | custom_id: 'input1', 46 | label: 'Input 1', 47 | style: 1, 48 | }) 49 | }) 50 | 51 | it('should set multi-line style', () => { 52 | const textInput = new TextInput('input1', 'Input 1', 'Multi') 53 | expect(textInput.toJSON().style).toBe(2) 54 | }) 55 | 56 | it('should set min_length', () => { 57 | const textInput = new TextInput('input1', 'Input 1') 58 | textInput.min_length(5) 59 | expect(textInput.toJSON().min_length).toBe(5) 60 | }) 61 | 62 | it('should set max_length', () => { 63 | const textInput = new TextInput('input1', 'Input 1') 64 | textInput.max_length(100) 65 | expect(textInput.toJSON().max_length).toBe(100) 66 | }) 67 | 68 | it('should set required', () => { 69 | const textInput = new TextInput('input1', 'Input 1') 70 | textInput.required() 71 | expect(textInput.toJSON().required).toBe(true) 72 | }) 73 | 74 | it('should set value', () => { 75 | const textInput = new TextInput('input1', 'Input 1') 76 | textInput.value('Default value') 77 | expect(textInput.toJSON().value).toBe('Default value') 78 | }) 79 | 80 | it('should set placeholder', () => { 81 | const textInput = new TextInput('input1', 'Input 1') 82 | textInput.placeholder('Enter text here') 83 | expect(textInput.toJSON().placeholder).toBe('Enter text here') 84 | }) 85 | }) 86 | -------------------------------------------------------------------------------- /src/builders/modal.ts: -------------------------------------------------------------------------------- 1 | import type { APIModalInteractionResponseCallbackData, APITextInputComponent } from 'discord-api-types/v10' 2 | import { CUSTOM_ID_SEPARATOR, toJSON } from '../utils' 3 | import { Builder, ifThrowHasSemicolon } from './utils' 4 | 5 | export class Modal { 6 | #uniqueStr: string 7 | #data: APIModalInteractionResponseCallbackData 8 | /** 9 | * @param {string} unique_id 10 | * @param {string} title 11 | */ 12 | constructor(unique_id: string, title: string) { 13 | ifThrowHasSemicolon(unique_id) 14 | this.#uniqueStr = unique_id + CUSTOM_ID_SEPARATOR 15 | this.#data = { title, custom_id: this.#uniqueStr, components: [] } 16 | } 17 | /** 18 | * export json data 19 | * @returns {APIModalInteractionResponseCallbackData} 20 | */ 21 | toJSON = () => this.#data 22 | /** 23 | * @param {string} e 24 | * @returns {this} 25 | */ 26 | custom_id = (e: string) => { 27 | this.#data.custom_id = this.#uniqueStr + e 28 | return this 29 | } 30 | /** 31 | * @param {...(TextInput | APITextInputComponent)} e 32 | * @returns {this} 33 | */ 34 | row = (...e: (TextInput | APITextInputComponent)[]) => { 35 | this.#data.components.push({ 36 | type: 1, 37 | components: e.map(toJSON), 38 | }) 39 | return this 40 | } 41 | /** 42 | * Overwrite title 43 | * @param {string} e 44 | * @returns {this} 45 | */ 46 | title = (e: string) => { 47 | this.#data.title = e 48 | return this 49 | } 50 | } 51 | 52 | export class TextInput extends Builder { 53 | /** 54 | * [Text Input Structure](https://discord.com/developers/docs/interactions/message-components#text-input-object) 55 | * @param {string} custom_id 56 | * @param {string} label 57 | * @param {"Single" | "Multi"} [input_style="Single"] 58 | */ 59 | constructor(custom_id: string, label: string, input_style?: 'Single' | 'Multi') { 60 | super({ type: 4, custom_id, label, style: input_style === 'Multi' ? 2 : 1 }) 61 | } 62 | /** 63 | * @param {number} e 64 | * @returns {this} 65 | */ 66 | min_length = (e: number) => this.a({ min_length: e }) 67 | /** 68 | * @param {number} e 69 | * @returns {this} 70 | */ 71 | max_length = (e: number) => this.a({ max_length: e }) 72 | /** 73 | * Whether or not this text input is required or not 74 | * @param {boolean} [e=true] 75 | * @returns {this} 76 | */ 77 | required = (e = true) => this.a({ required: e }) 78 | /** 79 | * The pre-filled text in the text input 80 | * @param {string} e 81 | * @returns {this} 82 | */ 83 | value = (e: string) => this.a({ value: e }) 84 | /** 85 | * @param {string} e 86 | * @returns {this} 87 | */ 88 | placeholder = (e: string) => this.a({ placeholder: e }) 89 | } 90 | -------------------------------------------------------------------------------- /src/builders/poll.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest' 2 | import { Poll } from './poll' 3 | 4 | describe('Poll', () => { 5 | it('should initialize with a question and answers', () => { 6 | const poll = new Poll('Favorite color?', 'Red', 'Blue', 'Green') 7 | expect(poll.toJSON()).toEqual({ 8 | question: { text: 'Favorite color?' }, 9 | answers: [{ poll_media: { text: 'Red' } }, { poll_media: { text: 'Blue' } }, { poll_media: { text: 'Green' } }], 10 | }) 11 | }) 12 | 13 | it('should overwrite the question', () => { 14 | const poll = new Poll('Initial question').question('Updated question') 15 | expect(poll.toJSON().question).toEqual({ text: 'Updated question' }) 16 | }) 17 | 18 | it('should overwrite the answers', () => { 19 | const poll = new Poll('Question', 'Answer 1').answers('Answer 2', 'Answer 3') 20 | expect(poll.toJSON().answers).toEqual([{ poll_media: { text: 'Answer 2' } }, { poll_media: { text: 'Answer 3' } }]) 21 | }) 22 | 23 | it('should set the duration', () => { 24 | const poll = new Poll('Question').duration(48) 25 | expect(poll.toJSON().duration).toBe(48) 26 | }) 27 | 28 | it('should allow multiselect', () => { 29 | const poll = new Poll('Question').allow_multiselect(true) 30 | expect(poll.toJSON().allow_multiselect).toBe(true) 31 | }) 32 | 33 | it('should set the layout type', () => { 34 | const poll = new Poll('Question').layout_type(2) 35 | expect(poll.toJSON().layout_type).toBe(2) 36 | }) 37 | 38 | it('should handle emoji answers', () => { 39 | const emoji = { id: '123', name: 'smile' } 40 | const poll = new Poll('Question', ['😊', 'Happy'], [emoji, 'Excited']) 41 | expect(poll.toJSON().answers).toEqual([ 42 | { poll_media: { emoji: { id: null, name: '😊' }, text: 'Happy' } }, 43 | { poll_media: { emoji: { id: '123', name: 'smile' }, text: 'Excited' } }, 44 | ]) 45 | }) 46 | }) 47 | -------------------------------------------------------------------------------- /src/builders/poll.ts: -------------------------------------------------------------------------------- 1 | import type { APIPartialEmoji, RESTAPIPoll } from 'discord-api-types/v10' 2 | import { Builder } from './utils' 3 | 4 | const answersRemap = (answers: (string | [string | APIPartialEmoji, string])[]) => 5 | answers.map(e => ({ 6 | poll_media: Array.isArray(e) 7 | ? { emoji: typeof e[0] === 'string' ? { id: null, name: e[0] } : e[0], text: e[1] } 8 | : { text: e }, 9 | })) 10 | 11 | export class Poll extends Builder { 12 | constructor(question?: string, ...answers: (string | [string | APIPartialEmoji, string])[]) { 13 | super({ question: { text: question }, answers: answersRemap(answers) }) 14 | } 15 | /** 16 | * overwrite question 17 | * @param {string} question 18 | * @returns {this} 19 | */ 20 | question = (question: string) => this.a({ question: { text: question } }) 21 | /** 22 | * overwrite answers 23 | * @param {string | [string | APIPartialEmoji, string]} answers 24 | * @returns {this} 25 | */ 26 | answers = (...answers: (string | [string | APIPartialEmoji, string])[]) => this.a({ answers: answersRemap(answers) }) 27 | /** 28 | * Number of hours the poll should be open for, up to 32 days. Defaults to 24 29 | * @param {number} duration 30 | * @returns {this} 31 | */ 32 | duration = (duration = 24) => this.a({ duration }) 33 | /** 34 | * Whether a user can select multiple answers. 35 | * @param {boolean} allow_multiselect 36 | * @returns {this} 37 | */ 38 | allow_multiselect = (allow_multiselect = true) => this.a({ allow_multiselect }) 39 | /** 40 | * https://discord.com/developers/docs/resources/poll#layout-type 41 | * @param {number} layout_type 42 | * @returns {this} 43 | */ 44 | layout_type = (layout_type: number) => this.a({ layout_type }) 45 | } 46 | -------------------------------------------------------------------------------- /src/builders/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi } from 'vitest' 2 | import { CUSTOM_ID_SEPARATOR } from '../utils' 3 | import { Builder, ifThrowHasSemicolon, warnBuilder } from './utils' 4 | 5 | describe('Builder', () => { 6 | class TestBuilder extends Builder<{ test: string }> { 7 | constructor() { 8 | super({ test: '' }) 9 | } 10 | 11 | setTest(value: string) { 12 | return this.a({ test: value }) 13 | } 14 | } 15 | 16 | it('should create an instance with initial value', () => { 17 | const builder = new TestBuilder() 18 | expect(builder.toJSON()).toEqual({ test: '' }) 19 | }) 20 | 21 | it('should update the store using the "a" method', () => { 22 | const builder = new TestBuilder() 23 | builder.setTest('hello') 24 | expect(builder.toJSON()).toEqual({ test: 'hello' }) 25 | }) 26 | 27 | it('should return a new object from toJSON', () => { 28 | const builder = new TestBuilder() 29 | const json1 = builder.toJSON() 30 | const json2 = builder.toJSON() 31 | expect(json1).toEqual(json2) 32 | expect(json1).not.toBe(json2) 33 | }) 34 | }) 35 | 36 | describe('warnBuilder', () => { 37 | it('should log a warning message', () => { 38 | // biome-ignore lint: empty block 39 | const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) 40 | warnBuilder('TestClass', 'TestType', 'testMethod') 41 | expect(consoleSpy).toHaveBeenCalledWith('⚠️ TestClass(TestType).testMethod is not available') 42 | consoleSpy.mockRestore() 43 | }) 44 | }) 45 | 46 | describe('ifThrowHasSemicolon', () => { 47 | it('should throw an error if the string contains CUSTOM_ID_SEPARATOR', () => { 48 | expect(() => ifThrowHasSemicolon(`test${CUSTOM_ID_SEPARATOR}string`)).toThrow(`Don't use "${CUSTOM_ID_SEPARATOR}"`) 49 | }) 50 | 51 | it('should not throw an error if the string does not contain CUSTOM_ID_SEPARATOR', () => { 52 | expect(() => ifThrowHasSemicolon('test string')).not.toThrow() 53 | }) 54 | }) 55 | -------------------------------------------------------------------------------- /src/builders/utils.ts: -------------------------------------------------------------------------------- 1 | import { CUSTOM_ID_SEPARATOR } from '../utils' 2 | 3 | export abstract class Builder { 4 | #store: Obj 5 | constructor(init: Obj) { 6 | this.#store = init 7 | } 8 | /** 9 | * assign object `Object.assign(this.#store, obj)` 10 | */ 11 | protected a = (obj: Partial) => { 12 | Object.assign(this.#store, obj) 13 | return this 14 | } 15 | /** 16 | * export json object 17 | * @returns {Obj} 18 | */ 19 | toJSON = () => ({ ...this.#store }) 20 | } 21 | 22 | export const warnBuilder = (clas: string, type: string, method: string) => 23 | // biome-ignore lint: console 24 | console.warn(`⚠️ ${clas}(${type}).${method} is not available`) 25 | 26 | export const ifThrowHasSemicolon = (str: string) => { 27 | if (str.includes(CUSTOM_ID_SEPARATOR)) throw new Error(`Don't use "${CUSTOM_ID_SEPARATOR}"`) 28 | } 29 | -------------------------------------------------------------------------------- /src/context.test.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | APIApplicationCommandAutocompleteInteraction, 3 | APIApplicationCommandInteraction, 4 | APIInteractionGuildMember, 5 | APIMessageComponentInteraction, 6 | APIModalSubmitInteraction, 7 | } from 'discord-api-types/v10' 8 | import { Locale } from 'discord-api-types/v10' 9 | import { describe, expect, it, vi } from 'vitest' 10 | import { Context } from './context' 11 | import { _webhooks_$_$_messages_original, createRest } from './rest' 12 | import type { CommandContext, ComponentContext } from './types' 13 | 14 | // Mock createRest to avoid actual API calls 15 | vi.mock('./rest', () => ({ 16 | createRest: vi.fn().mockReturnValue( 17 | vi.fn().mockImplementation((method, _endpoint, _pathVars, _data, _file) => { 18 | if (method === 'PATCH') return Promise.resolve({}) 19 | if (method === 'DELETE') return Promise.resolve({}) 20 | return Promise.resolve({}) 21 | }), 22 | ), 23 | _webhooks_$_$_messages_original: '/webhooks/{}/{}/messages/@original', 24 | })) 25 | 26 | // Mock newError for testing error throwing 27 | vi.mock('./utils', async () => { 28 | const actual = await vi.importActual('./utils') 29 | return { 30 | ...actual, 31 | newError: (name: string, message: string) => new Error(`${name}: ${message}`), 32 | prepareData: (data: any) => (typeof data === 'string' ? { content: data } : data), 33 | formData: vi.fn().mockReturnValue(new FormData()), 34 | toJSON: (data: any) => data, 35 | } 36 | }) 37 | 38 | describe('Context', () => { 39 | const env = { TEST_ENV: 'test' } 40 | const executionCtx = { 41 | waitUntil: vi.fn(), 42 | passThroughOnException: vi.fn(), 43 | } 44 | const discordEnv = { TOKEN: 'test-token' } 45 | const cronEvent = { cron: '*/5 * * * *', scheduledTime: Date.now(), type: 'scheduled' } 46 | const key = 'test-key' 47 | 48 | it('should create a context with all properties', () => { 49 | const ctx = new Context(env, executionCtx, discordEnv, key, cronEvent) 50 | expect(ctx.env).toEqual(env) 51 | expect(ctx.executionCtx).toEqual(executionCtx) 52 | expect(ctx.key).toEqual(key) 53 | expect(ctx.interaction).toEqual(cronEvent) 54 | }) 55 | 56 | it('should handle variables', () => { 57 | const ctx = new Context<{ Variables: { testVar: string } }, any>(env, executionCtx, discordEnv, key, cronEvent) 58 | ctx.set('testVar', 'testValue') 59 | expect(ctx.get('testVar')).toEqual('testValue') 60 | expect(ctx.var).toEqual({ testVar: 'testValue' }) 61 | }) 62 | 63 | it('should provide access to rest client', () => { 64 | const ctx = new Context(env, executionCtx, discordEnv, key, cronEvent) 65 | expect(ctx.rest).toBeDefined() 66 | expect(createRest).toHaveBeenCalledWith('test-token') 67 | }) 68 | }) 69 | 70 | describe('Context', () => { 71 | const env = { TEST_ENV: 'test' } 72 | const executionCtx = { 73 | waitUntil: vi.fn(), 74 | passThroughOnException: vi.fn(), 75 | } 76 | const discordEnv = { 77 | TOKEN: 'test-token', 78 | APPLICATION_ID: 'app-id', 79 | } 80 | const key = 'test-key' 81 | 82 | // @ts-expect-error 83 | const member: APIInteractionGuildMember = { 84 | user: { 85 | id: 'user-id', 86 | global_name: 'global-name', 87 | username: 'username', 88 | discriminator: '0000', 89 | avatar: null, 90 | }, 91 | roles: [], 92 | joined_at: '', 93 | deaf: false, 94 | mute: false, 95 | } 96 | 97 | // Mock command interaction 98 | // @ts-expect-error 99 | const commandInteraction: APIApplicationCommandInteraction = { 100 | id: 'interaction-id', 101 | application_id: 'app-id', 102 | type: 2, // APPLICATION_COMMAND 103 | token: 'token', 104 | version: 1, 105 | data: { 106 | id: 'command-id', 107 | name: 'test-command', 108 | type: 1, 109 | options: [ 110 | { 111 | name: 'option1', 112 | type: 3, // STRING 113 | value: 'value1', 114 | }, 115 | ], 116 | }, 117 | guild_id: 'guild-id', 118 | channel_id: 'channel-id', 119 | member: member, 120 | app_permissions: '0', 121 | locale: Locale.EnglishUS, 122 | guild_locale: Locale.EnglishUS, 123 | } 124 | 125 | // Mock subcommand interaction 126 | const subCommandInteraction: APIApplicationCommandInteraction = { 127 | ...commandInteraction, 128 | data: { 129 | ...commandInteraction.data, 130 | // @ts-expect-error 131 | options: [ 132 | { 133 | name: 'group', 134 | type: 2, // SUB_COMMAND_GROUP 135 | options: [ 136 | { 137 | name: 'sub', 138 | type: 1, // SUB_COMMAND 139 | options: [ 140 | { 141 | name: 'option1', 142 | type: 3, // STRING 143 | value: 'value1', 144 | }, 145 | ], 146 | }, 147 | ], 148 | }, 149 | ], 150 | }, 151 | } 152 | 153 | // Mock component interaction 154 | const componentInteraction: APIMessageComponentInteraction = { 155 | id: 'interaction-id', 156 | application_id: 'app-id', 157 | type: 3, // MESSAGE_COMPONENT 158 | token: 'token', 159 | version: 1, 160 | data: { 161 | custom_id: 'button-1', 162 | component_type: 2, // BUTTON 163 | }, 164 | guild_id: 'guild-id', 165 | channel_id: 'channel-id', 166 | member: member, 167 | app_permissions: '0', 168 | locale: Locale.EnglishUS, 169 | guild_locale: Locale.EnglishUS, 170 | // @ts-expect-error 171 | message: { 172 | id: 'message-id', 173 | channel_id: 'channel-id', 174 | content: 'message content', 175 | author: { 176 | id: 'bot-id', 177 | global_name: 'global-bot-name', 178 | username: 'bot', 179 | discriminator: '0000', 180 | avatar: null, 181 | }, 182 | attachments: [], 183 | embeds: [], 184 | mentions: [], 185 | mention_roles: [], 186 | pinned: false, 187 | mention_everyone: false, 188 | tts: false, 189 | timestamp: '', 190 | edited_timestamp: null, 191 | //flags: 0, 192 | components: [], 193 | }, 194 | } 195 | 196 | // Mock autocomplete interaction 197 | // @ts-expect-error 198 | const autocompleteInteraction: APIApplicationCommandAutocompleteInteraction = { 199 | id: 'interaction-id', 200 | application_id: 'app-id', 201 | type: 4, // APPLICATION_COMMAND_AUTOCOMPLETE 202 | token: 'token', 203 | version: 1, 204 | data: { 205 | id: 'command-id', 206 | name: 'test-command', 207 | type: 1, 208 | options: [ 209 | { 210 | name: 'option1', 211 | type: 3, // STRING 212 | value: 'val', 213 | focused: true, 214 | }, 215 | ], 216 | }, 217 | guild_id: 'guild-id', 218 | channel_id: 'channel-id', 219 | member: member, 220 | app_permissions: '0', 221 | locale: Locale.EnglishUS, 222 | guild_locale: Locale.EnglishUS, 223 | } 224 | 225 | // Mock modal interaction 226 | // @ts-expect-error 227 | const modalInteraction: APIModalSubmitInteraction = { 228 | id: 'interaction-id', 229 | application_id: 'app-id', 230 | type: 5, // MODAL_SUBMIT 231 | token: 'token', 232 | version: 1, 233 | data: { 234 | custom_id: 'modal-1', 235 | components: [ 236 | { 237 | type: 1, 238 | components: [ 239 | { 240 | type: 4, 241 | custom_id: 'input-1', 242 | value: 'input value', 243 | }, 244 | ], 245 | }, 246 | ], 247 | }, 248 | guild_id: 'guild-id', 249 | channel_id: 'channel-id', 250 | member: member, 251 | app_permissions: '0', 252 | locale: Locale.EnglishUS, 253 | guild_locale: Locale.EnglishUS, 254 | } 255 | 256 | it('should handle command interactions', () => { 257 | const ctx = new Context<{ Variables: { option1: string } }, any>( 258 | env, 259 | executionCtx, 260 | discordEnv, 261 | key, 262 | commandInteraction, 263 | ) 264 | expect(ctx.interaction).toEqual(commandInteraction) 265 | expect(ctx.get('option1')).toEqual('value1') 266 | }) 267 | 268 | it('should handle subcommand interactions', () => { 269 | const ctx = new Context(env, executionCtx, discordEnv, key, subCommandInteraction) 270 | expect(ctx.sub).toEqual({ 271 | group: 'group', 272 | command: 'sub', 273 | string: 'group sub', 274 | }) 275 | }) 276 | 277 | it('should handle component interactions', () => { 278 | const ctx = new Context<{ Variables: { custom_id: string } }, any>( 279 | env, 280 | executionCtx, 281 | discordEnv, 282 | key, 283 | componentInteraction, 284 | ) 285 | expect(ctx.get('custom_id')).toEqual('button-1') 286 | }) 287 | 288 | it('should handle autocomplete interactions', () => { 289 | const ctx = new Context(env, executionCtx, discordEnv, key, autocompleteInteraction) 290 | expect(ctx.focused).toBeDefined() 291 | expect(ctx.focused?.name).toEqual('option1') 292 | expect(ctx.focused?.value).toEqual('val') 293 | }) 294 | 295 | it('should handle modal interactions', () => { 296 | const ctx = new Context<{ Variables: { custom_id: string; 'input-1': string } }, any>( 297 | env, 298 | executionCtx, 299 | discordEnv, 300 | key, 301 | modalInteraction, 302 | ) 303 | expect(ctx.get('custom_id')).toEqual('modal-1') 304 | expect(ctx.get('input-1')).toEqual('input value') 305 | }) 306 | 307 | it('should set flags correctly', async () => { 308 | const ctx = new Context(env, executionCtx, discordEnv, key, commandInteraction) 309 | const response = ctx.flags('EPHEMERAL').res('Test message') 310 | const body = await response.json() //JSON.parse(response.body as string) 311 | expect(body.data.flags).toEqual(64) // EPHEMERAL flag value 312 | }) 313 | 314 | it('should create proper response', async () => { 315 | const ctx = new Context(env, executionCtx, discordEnv, key, commandInteraction) 316 | const response = ctx.res('Test message') 317 | const body = await response.json() //JSON.parse(response.body as string) 318 | expect(body.type).toEqual(4) // CHANNEL_MESSAGE_WITH_SOURCE 319 | expect(body.data.content).toEqual('Test message') 320 | }) 321 | 322 | it('should create defer response', async () => { 323 | const ctx = new Context(env, executionCtx, discordEnv, key, commandInteraction) 324 | const response = ctx.resDefer() 325 | const body = await response.json() //JSON.parse(response.body as string) 326 | expect(body.type).toEqual(5) // DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE 327 | }) 328 | 329 | it('should create update response for components', async () => { 330 | const ctx = new Context(env, executionCtx, discordEnv, key, componentInteraction) 331 | const response = ctx.update().res('Updated message') 332 | const body = await response.json() //JSON.parse(response.body as string) 333 | expect(body.type).toEqual(7) // UPDATE_MESSAGE 334 | expect(body.data.content).toEqual('Updated message') 335 | }) 336 | 337 | it('should throw error for invalid method calls', () => { 338 | const ctx = new Context(env, executionCtx, discordEnv, key, commandInteraction) 339 | expect(() => ctx.update()).toThrow('c.***') 340 | }) 341 | 342 | it('should allow followup messages', async () => { 343 | const ctx = new Context(env, executionCtx, discordEnv, key, commandInteraction) 344 | await ctx.followup('Followup message') 345 | expect(ctx.rest).toHaveBeenCalledWith( 346 | 'PATCH', 347 | _webhooks_$_$_messages_original, 348 | ['app-id', 'token'], 349 | 'Followup message', 350 | undefined, 351 | ) 352 | }) 353 | 354 | it('should create modal response', async () => { 355 | const ctx = new Context(env, executionCtx, discordEnv, key, commandInteraction) 356 | const response = ctx.resModal({ title: 'Test Modal', custom_id: 'modal-test', components: [] }) 357 | const body = await response.json() //JSON.parse(response.body as string) 358 | expect(body.type).toEqual(9) // MODAL 359 | expect(body.data.title).toEqual('Test Modal') 360 | }) 361 | 362 | it('should create autocomplete response', async () => { 363 | const ctx = new Context(env, executionCtx, discordEnv, key, autocompleteInteraction) 364 | const response = ctx.resAutocomplete({ choices: [{ name: 'Option 1', value: 'option1' }] }) 365 | const body = await response.json() //JSON.parse(response.body as string) 366 | expect(body.type).toEqual(8) // APPLICATION_COMMAND_AUTOCOMPLETE_RESULT 367 | expect(body.data.choices).toEqual([{ name: 'Option 1', value: 'option1' }]) 368 | }) 369 | 370 | it('should create activity response', async () => { 371 | const ctx = new Context(env, executionCtx, discordEnv, key, commandInteraction) 372 | const response = ctx.resActivity() 373 | const body = await response.json() //JSON.parse(response.body as string) 374 | expect(body.type).toEqual(12) // LAUNCH_ACTIVITY 375 | }) 376 | 377 | it('should throw error when event is not found', () => { 378 | const ctx = new Context(env, undefined, discordEnv, key, commandInteraction) 379 | expect(() => ctx.event).toThrow('c.event: not found') 380 | }) 381 | 382 | it('should throw error when executionCtx is not found', () => { 383 | const ctx = new Context(env, undefined, discordEnv, key, commandInteraction) 384 | expect(() => ctx.executionCtx).toThrow('c.executionCtx: not found') 385 | }) 386 | }) 387 | -------------------------------------------------------------------------------- /src/context.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | APIApplicationCommandAutocompleteResponse, 3 | APIApplicationCommandInteractionDataIntegerOption, 4 | APIApplicationCommandInteractionDataNumberOption, 5 | APIApplicationCommandInteractionDataOption, 6 | APIApplicationCommandInteractionDataStringOption, 7 | APICommandAutocompleteInteractionResponseCallbackData, 8 | APIInteraction, 9 | APIInteractionResponse, 10 | APIInteractionResponseCallbackData, 11 | APIInteractionResponseDeferredChannelMessageWithSource, 12 | APIInteractionResponseDeferredMessageUpdate, 13 | APIInteractionResponseLaunchActivity, 14 | APIModalInteractionResponse, 15 | APIModalInteractionResponseCallbackData, 16 | RESTPatchAPIInteractionOriginalResponseJSONBody, 17 | } from 'discord-api-types/v10' 18 | import type { Autocomplete, Modal } from './builders' 19 | import { _webhooks_$_$_messages_original, createRest } from './rest' 20 | import type { 21 | AutocompleteContext, 22 | CommandContext, 23 | ComponentContext, 24 | CronContext, 25 | CronEvent, 26 | CustomCallbackData, 27 | DiscordEnv, 28 | Env, 29 | ExecutionContext, 30 | FetchEventLike, 31 | FileData, 32 | ModalContext, 33 | } from './types' 34 | import { formData, newError, prepareData, toJSON } from './utils' 35 | 36 | type ExecutionCtx = FetchEventLike | ExecutionContext | undefined 37 | 38 | // biome-ignore lint: Same definition as Hono 39 | type ContextVariableMap = {} 40 | interface SetVar { 41 | (key: Key, value: ContextVariableMap[Key]): void 42 | (key: Key, value: E['Variables'][Key]): void 43 | } 44 | interface GetVar { 45 | (key: Key): ContextVariableMap[Key] 46 | (key: Key): E['Variables'][Key] 47 | } 48 | type IsAny = boolean extends (T extends never ? true : false) ? true : false 49 | 50 | type AutocompleteOption = 51 | | APIApplicationCommandInteractionDataStringOption 52 | | APIApplicationCommandInteractionDataIntegerOption 53 | | APIApplicationCommandInteractionDataNumberOption 54 | 55 | export class Context< 56 | E extends Env, 57 | This extends CommandContext | ComponentContext | AutocompleteContext | ModalContext | CronContext, 58 | > { 59 | #env: E['Bindings'] = {} 60 | #executionCtx: ExecutionCtx 61 | #discord: DiscordEnv 62 | #key: string 63 | #var = new Map() 64 | #rest: ReturnType | undefined = undefined 65 | // interaction 66 | #interaction: APIInteraction | CronEvent 67 | #flags: { flags?: number } = {} // 235 68 | #sub = { group: '', command: '', string: '' } // 24 69 | #update = false // 3 70 | #focused: AutocompleteOption | undefined // 4 71 | #throwIfNotAllowType = (allowType: (APIInteraction | CronEvent)['type'][]) => { 72 | if (!allowType.includes(this.#interaction.type)) throw newError('c.***', 'Invalid method') 73 | } 74 | constructor( 75 | env: E['Bindings'], 76 | executionCtx: ExecutionCtx, 77 | discord: DiscordEnv, 78 | key: string, 79 | interaction: APIInteraction | CronEvent, 80 | ) { 81 | this.#env = env 82 | this.#executionCtx = executionCtx 83 | this.#discord = discord 84 | this.#key = key 85 | this.#interaction = interaction 86 | switch (interaction.type) { 87 | case 2: 88 | case 4: { 89 | let options: APIApplicationCommandInteractionDataOption[] | undefined 90 | if ('options' in interaction.data) { 91 | options = interaction.data.options 92 | if (options?.[0].type === 2) { 93 | this.#sub.group = options[0].name 94 | this.#sub.string = `${options[0].name} ` 95 | options = options[0].options 96 | } 97 | if (options?.[0].type === 1) { 98 | this.#sub.command = options[0].name 99 | this.#sub.string += options[0].name 100 | options = options[0].options 101 | } 102 | } 103 | if (options) 104 | for (const e of options) { 105 | const { type } = e 106 | if ((type === 3 || type === 4 || type === 10) && e.focused) this.#focused = e 107 | // @ts-expect-error 108 | this.set(e.name, e.value) 109 | } 110 | break 111 | } 112 | // @ts-expect-error 113 | // biome-ignore lint: case 5 extracts custom_id in the same way as case 3. 114 | case 5: { 115 | const modalRows = interaction.data?.components 116 | if (modalRows) 117 | // @ts-expect-error 118 | for (const row of modalRows) for (const modal of row.components) this.set(modal.custom_id, modal.value) 119 | } 120 | case 3: 121 | // with case 5 122 | ;(this as ComponentContext | ModalContext).set('custom_id', interaction.data?.custom_id) 123 | // not case 5, select only 124 | // @ts-expect-error 125 | if ('values' in interaction.data) this.set(key, interaction.data.values) 126 | } 127 | } 128 | 129 | /** 130 | * Environment Variables 131 | */ 132 | get env(): E['Bindings'] { 133 | return this.#env 134 | } 135 | get event(): FetchEventLike { 136 | if (!(this.#executionCtx && 'respondWith' in this.#executionCtx)) throw newError('c.event', 'not found') 137 | return this.#executionCtx 138 | } 139 | get executionCtx(): ExecutionContext { 140 | if (!this.#executionCtx) throw newError('c.executionCtx', 'not found') 141 | return this.#executionCtx 142 | } 143 | /** 144 | * Handler triggered string 145 | */ 146 | get key(): string { 147 | return this.#key 148 | } 149 | /** 150 | * @param {string} key 151 | * @param {unknown} value 152 | */ 153 | set: SetVar = (key: string, value: unknown) => this.#var.set(key, value) 154 | /** 155 | * @param {string} key 156 | * @returns {unknown} 157 | */ 158 | get: GetVar = (key: string) => this.#var.get(key) 159 | /** 160 | * Variables object 161 | */ 162 | get var(): Readonly< 163 | ContextVariableMap & (IsAny extends true ? Record : E['Variables']) 164 | > { 165 | return Object.fromEntries(this.#var) 166 | } 167 | 168 | /** 169 | * `c.rest` = `createRest(c.env.DISCORD_TOKEN)` 170 | */ 171 | get rest(): ReturnType { 172 | this.#rest ??= createRest(this.#discord.TOKEN) 173 | return this.#rest 174 | } 175 | 176 | /** 177 | * [Interaction Object](https://discord.com/developers/docs/interactions/receiving-and-responding#interaction-object) 178 | */ 179 | get interaction() { 180 | return this.#interaction 181 | } 182 | 183 | /** 184 | * [Message Flags](https://discord.com/developers/docs/resources/message#message-object-message-flags) 185 | * @param {"SUPPRESS_EMBEDS" | "EPHEMERAL" | "SUPPRESS_NOTIFICATIONS" | "IS_COMPONENTS_V2"} flag 186 | * @returns {this} 187 | * @example 188 | * ```ts 189 | * return c.flags('SUPPRESS_EMBEDS', 'EPHEMERAL').res('[Docs](https://example.com)') 190 | * ``` 191 | */ 192 | flags = (...flag: ('SUPPRESS_EMBEDS' | 'EPHEMERAL' | 'SUPPRESS_NOTIFICATIONS' | 'IS_COMPONENTS_V2')[]) => { 193 | this.#throwIfNotAllowType([2, 3, 5]) 194 | const flagNum = { 195 | SUPPRESS_EMBEDS: 1 << 2, 196 | EPHEMERAL: 1 << 6, 197 | SUPPRESS_NOTIFICATIONS: 1 << 12, 198 | IS_COMPONENTS_V2: 1 << 15, 199 | } as const 200 | this.#flags.flags = 0 201 | for (const f of flag) this.#flags.flags |= flagNum[f] 202 | return this as unknown as This 203 | } 204 | 205 | /** 206 | * @param data [Data Structure](https://discord.com/developers/docs/interactions/receiving-and-responding#interaction-response-object-interaction-callback-data-structure) 207 | * @param file File: { blob: Blob, name: string } | { blob: Blob, name: string }[] 208 | * @returns {Response} 209 | */ 210 | res = (data: CustomCallbackData, file?: FileData) => { 211 | this.#throwIfNotAllowType([2, 3, 5]) 212 | const body: APIInteractionResponse = { 213 | data: { ...this.#flags, ...prepareData(data) }, 214 | type: this.#update ? 7 : 4, 215 | } 216 | return file ? new Response(formData(body, file)) : Response.json(body) 217 | } 218 | /** 219 | * ACK an interaction and edit a response later, the user sees a loading state 220 | * @param {(c: This) => Promise} handler 221 | * @returns {Response} 222 | * @example 223 | * ```ts 224 | * return c.resDefer(c => c.followup('Delayed Message')) 225 | * ``` 226 | */ 227 | resDefer = (handler?: (c: This) => Promise) => { 228 | this.#throwIfNotAllowType([2, 3, 5]) 229 | if (handler) this.executionCtx.waitUntil(handler(this as unknown as This)) 230 | return Response.json( 231 | this.#update 232 | ? ({ type: 6 } satisfies APIInteractionResponseDeferredMessageUpdate) 233 | : ({ 234 | type: 5, 235 | data: this.#flags, 236 | } satisfies APIInteractionResponseDeferredChannelMessageWithSource), 237 | ) 238 | } 239 | 240 | /** 241 | * Launch the Activity associated with the app. Only available for apps with Activities enabled 242 | * @returns {Response} 243 | */ 244 | resActivity = () => { 245 | this.#throwIfNotAllowType([2, 3, 5]) 246 | return Response.json({ type: 12 } satisfies APIInteractionResponseLaunchActivity) 247 | } 248 | 249 | /** 250 | * Used for sending messages after resDefer. Functions as a message deletion when called without arguments. 251 | * @param data string or [Data Structure](https://discord.com/developers/docs/resources/webhook#edit-webhook-message) 252 | * @param file File: { blob: Blob, name: string } | { blob: Blob, name: string }[] 253 | * @example 254 | * ```ts 255 | * // followup message 256 | * return c.resDefer(c => c.followup('Image file', { blob: Blob, name: 'image.png' })) 257 | * // delete message 258 | * return c.update().resDefer(c => c.followup()) 259 | * ``` 260 | */ 261 | followup = (data?: CustomCallbackData, file?: FileData) => { 262 | this.#throwIfNotAllowType([2, 3, 5]) 263 | if (!this.#discord.APPLICATION_ID) throw newError('c.followup', 'DISCORD_APPLICATION_ID') 264 | const pathVars: [string, string] = [this.#discord.APPLICATION_ID, (this.interaction as APIInteraction).token] 265 | if (data || file) return this.rest('PATCH', _webhooks_$_$_messages_original, pathVars, data || {}, file) 266 | return this.rest('DELETE', _webhooks_$_$_messages_original, pathVars) 267 | } 268 | 269 | /** 270 | * This object is useful when using subcommands 271 | * @example 272 | * ```ts 273 | * switch (c.sub.string) { 274 | * case 'sub1': 275 | * return c.res('sub1') 276 | * case 'group sub2': 277 | * return c.res('g-sub2') 278 | * } 279 | * ``` 280 | */ 281 | get sub() { 282 | this.#throwIfNotAllowType([2, 4]) 283 | return this.#sub 284 | } 285 | 286 | /** 287 | * Response for modal window display 288 | * @param {Modal} data 289 | * @returns {Response} 290 | * @example 291 | * ```ts 292 | * return c.resModal(new Modal('unique-id', 'Title') 293 | * .row(new TextInput('custom_id', 'Label')) 294 | * ) 295 | * ``` 296 | */ 297 | resModal = (data: Modal | APIModalInteractionResponseCallbackData) => { 298 | this.#throwIfNotAllowType([2, 3]) 299 | return Response.json({ type: 9, data: toJSON(data) } satisfies APIModalInteractionResponse) 300 | } 301 | 302 | /** 303 | * for components, change `c.res()` and `c.resDefer()` to a [Callback Type](https://discord.com/developers/docs/interactions/receiving-and-responding#interaction-response-object-interaction-callback-type) that edits the original message 304 | * @param {boolean} [bool=true] 305 | * @returns {this} 306 | * @example 307 | * ```ts 308 | * return c.update().res('Edit the original message') 309 | * ``` 310 | */ 311 | update = (bool = true) => { 312 | this.#throwIfNotAllowType([3, 5]) 313 | this.#update = bool 314 | return this as unknown as This 315 | } 316 | 317 | /** 318 | * Focused Option 319 | * 320 | * [Data Structure](https://discord.com/developers/docs/interactions/receiving-and-responding#interaction-object-interaction-data) 321 | */ 322 | get focused() { 323 | this.#throwIfNotAllowType([4]) 324 | return this.#focused 325 | } 326 | 327 | /** 328 | * @param {Autocomplete | APICommandAutocompleteInteractionResponseCallbackData} data [Data Structure](https://discord.com/developers/docs/interactions/receiving-and-responding#interaction-response-object-autocomplete) 329 | * @returns {Response} 330 | */ 331 | resAutocomplete = (data: Autocomplete | APICommandAutocompleteInteractionResponseCallbackData) => { 332 | this.#throwIfNotAllowType([4]) 333 | return Response.json({ type: 8, data: toJSON(data) } satisfies APIApplicationCommandAutocompleteResponse) 334 | } 335 | } 336 | -------------------------------------------------------------------------------- /src/discord-hono.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi } from 'vitest' 2 | import { Context } from './context' 3 | import { DiscordHono } from './discord-hono' 4 | 5 | describe('DiscordHono', () => { 6 | const app = new DiscordHono() 7 | const env = { DISCORD_PUBLIC_KEY: 'test_public_key' } 8 | const postRequest = (json: object) => 9 | new Request('https://example.com', { method: 'POST', body: JSON.stringify(json) }) 10 | 11 | it('should register handlers', () => { 12 | const commandHandler = vi.fn() 13 | const componentHandler = vi.fn() 14 | const autocompleteHandler = vi.fn() 15 | const modalHandler = vi.fn() 16 | const cronHandler = vi.fn() 17 | app.command('test', commandHandler) 18 | app.component('test', componentHandler) 19 | app.autocomplete('test', autocompleteHandler) 20 | app.autocomplete('test', autocompleteHandler, commandHandler) 21 | app.modal('test', modalHandler) 22 | app.cron('0 0 * * *', cronHandler) 23 | expect(commandHandler).not.toHaveBeenCalled() 24 | expect(componentHandler).not.toHaveBeenCalled() 25 | expect(autocompleteHandler).not.toHaveBeenCalled() 26 | expect(modalHandler).not.toHaveBeenCalled() 27 | expect(cronHandler).not.toHaveBeenCalled() 28 | }) 29 | 30 | describe('fetch', () => { 31 | it('should return text for GET requests', async () => { 32 | const req = new Request('https://example.com', { method: 'GET' }) 33 | const res = await app.fetch(req) 34 | expect(await res.text()).toBe('Operational🔥') 35 | }) 36 | 37 | it('should return 4xx for bad requests', async () => { 38 | const req404 = new Request('https://example.com', { method: 'bad' }) 39 | const res404 = await app.fetch(req404) 40 | expect(res404.status).toBe(404) 41 | const req401 = postRequest({}) 42 | const res401 = await new DiscordHono({ verify: vi.fn().mockResolvedValue(false) }).fetch(req401, env) 43 | expect(res401.status).toBe(401) 44 | const req400 = postRequest({ type: -1 }) 45 | const res400 = await new DiscordHono({ verify: vi.fn().mockResolvedValue(true) }).fetch(req400, env) 46 | expect(res400.status).toBe(400) 47 | }) 48 | 49 | it("should throw an error if DISCORD_PUBLIC_KEY isn't set", async () => { 50 | const req = postRequest({}) 51 | await expect(app.fetch(req)).rejects.toThrow() 52 | }) 53 | 54 | describe('Correct POST', () => { 55 | const verifiedApp = new DiscordHono({ verify: vi.fn().mockResolvedValue(true) }) 56 | 57 | it('should handle ping interaction correctly', async () => { 58 | const req = postRequest({ type: 1 }) 59 | const res = await verifiedApp.fetch(req, env) 60 | expect(await res.json()).toEqual({ type: 1 }) 61 | }) 62 | 63 | it('should handle command interaction correctly', async () => { 64 | const req = postRequest({ type: 2, data: { name: 'test' } }) 65 | verifiedApp.command('test', c => c.res('command')) 66 | const res = await verifiedApp.fetch(req, env) 67 | expect(await res.json()).toEqual({ type: 4, data: { content: 'command' } }) 68 | }) 69 | 70 | it('should handle component interaction correctly', async () => { 71 | const req = postRequest({ type: 3, data: { custom_id: 'test' } }) 72 | verifiedApp.component('test', c => c.update().res('component')) 73 | const res = await verifiedApp.fetch(req, env) 74 | expect(await res.json()).toEqual({ type: 7, data: { content: 'component' } }) 75 | }) 76 | }) 77 | }) 78 | 79 | describe('scheduled', () => { 80 | it('should call the registered cron handler', async () => { 81 | const handler = vi.fn() 82 | app.cron('0 0 * * *', handler) 83 | const event = { cron: '0 0 * * *', type: '', scheduledTime: 0 } 84 | await app.scheduled(event, {}) 85 | expect(handler).toHaveBeenCalledWith(expect.any(Context)) 86 | }) 87 | }) 88 | }) 89 | 90 | describe('HandlerMap', () => { 91 | it('should call the registered cron default handler', async () => { 92 | const handler = vi.fn() 93 | const app = new DiscordHono().cron('', handler) 94 | const event = { cron: '0 0 * * *', type: '', scheduledTime: 0 } 95 | await app.scheduled(event, {}) 96 | expect(handler).toHaveBeenCalledWith(expect.any(Context)) 97 | }) 98 | 99 | it('should throw error', async () => { 100 | const handler = vi.fn() 101 | const app = new DiscordHono().cron('0 0 * * *', handler) 102 | const event = { cron: '0 * * * *', type: '', scheduledTime: 0 } 103 | await expect(app.scheduled(event, {})).rejects.toThrow() 104 | }) 105 | }) 106 | -------------------------------------------------------------------------------- /src/discord-hono.ts: -------------------------------------------------------------------------------- 1 | import type { APIInteraction, APIInteractionResponsePong } from 'discord-api-types/v10' 2 | import { Context } from './context' 3 | import type { 4 | AutocompleteHandler, 5 | CommandHandler, 6 | ComponentHandler, 7 | ComponentType, 8 | CronContext, 9 | CronEvent, 10 | CronHandler, 11 | DiscordEnv, 12 | Env, 13 | ExecutionContext, 14 | InitOptions, 15 | ModalHandler, 16 | Verify, 17 | } from './types' 18 | import { CUSTOM_ID_SEPARATOR, newError } from './utils' 19 | import { verify } from './verify' 20 | 21 | type DiscordEnvBindings = { 22 | DISCORD_TOKEN?: string 23 | DISCORD_PUBLIC_KEY?: string 24 | DISCORD_APPLICATION_ID?: string 25 | } 26 | 27 | type HandlerNumber = 0 | 2 | 3 | 4 | 5 28 | // biome-ignore format: ternary operator 29 | type AnyHandler = 30 | N extends 0 ? CronHandler : 31 | N extends 2 ? CommandHandler : 32 | N extends 3 ? ComponentHandler : 33 | N extends 4 ? AutocompleteHandler : 34 | N extends 5 ? ModalHandler : 35 | never 36 | 37 | export class DiscordHono { 38 | #verify: Verify = verify 39 | #discord: (env: DiscordEnvBindings | undefined) => DiscordEnv 40 | #map = new Map>() 41 | #set = (num: N, key: string, value: AnyHandler) => { 42 | this.#map.set(`${num}${key}`, value) 43 | return this 44 | } 45 | #get = (num: N, key: string): AnyHandler => 46 | // @ts-expect-error 47 | this.#map.get(`${num}${key}`) ?? 48 | this.#map.get(`${num}`) ?? 49 | (() => { 50 | throw newError('DiscordHono', 'handler') 51 | })() 52 | /** 53 | * [Documentation](https://discord-hono.luis.fun/interactions/discord-hono/) 54 | * @param {InitOptions} options 55 | */ 56 | constructor(options?: InitOptions) { 57 | if (options?.verify) this.#verify = options.verify 58 | this.#discord = env => { 59 | const discordEnv = options?.discordEnv ? options.discordEnv(env) : {} 60 | return { 61 | APPLICATION_ID: env?.DISCORD_APPLICATION_ID, 62 | TOKEN: env?.DISCORD_TOKEN, 63 | PUBLIC_KEY: env?.DISCORD_PUBLIC_KEY, 64 | ...discordEnv, 65 | } 66 | } 67 | } 68 | 69 | /** 70 | * @param {string | RegExp} command Match the first argument of `Command` 71 | * @param handler 72 | * @returns {this} 73 | */ 74 | command = (command: string, handler: CommandHandler) => this.#set(2, command, handler) 75 | /** 76 | * @param {string | RegExp} component_id Match the first argument of `Button` or `Select` 77 | * @param handler 78 | * @returns {this} 79 | */ 80 | component = (component_id: string, handler: ComponentHandler) => 81 | this.#set(3, component_id, handler) 82 | /** 83 | * @param {string | RegExp} command Match the first argument of `Command` 84 | * @param autocomplete 85 | * @param handler 86 | * @returns {this} 87 | */ 88 | autocomplete = (command: string, autocomplete: AutocompleteHandler, handler?: CommandHandler) => 89 | (handler ? this.#set(2, command, handler) : this).#set(4, command, autocomplete) 90 | /** 91 | * @param {string | RegExp} modal_id Match the first argument of `Modal` 92 | * @param handler 93 | * @returns {this} 94 | */ 95 | modal = (modal_id: string, handler: ModalHandler) => this.#set(5, modal_id, handler) 96 | /** 97 | * @param cron Match the crons in the toml file 98 | * @param handler 99 | * @returns {this} 100 | */ 101 | cron = (cron: string, handler: CronHandler) => this.#set(0, cron, handler) 102 | 103 | /** 104 | * @param {Request} request 105 | * @param {Record} env 106 | * @param executionCtx 107 | * @returns {Promise} 108 | */ 109 | fetch = async (request: Request, env?: E['Bindings'], executionCtx?: ExecutionContext) => { 110 | switch (request.method) { 111 | case 'GET': 112 | return new Response('Operational🔥') 113 | case 'POST': { 114 | const discord = this.#discord(env) 115 | if (!discord.PUBLIC_KEY) throw newError('DiscordHono', 'DISCORD_PUBLIC_KEY') 116 | const body = await request.text() 117 | if ( 118 | !(await this.#verify( 119 | body, 120 | request.headers.get('x-signature-ed25519'), 121 | request.headers.get('x-signature-timestamp'), 122 | discord.PUBLIC_KEY, 123 | )) 124 | ) 125 | return new Response('Bad Request', { status: 401 }) 126 | const interaction: APIInteraction = JSON.parse(body) 127 | const key = (() => { 128 | switch (interaction.type) { 129 | case 2: 130 | case 4: 131 | return interaction.data.name 132 | case 3: 133 | case 5: { 134 | const id = interaction.data.custom_id 135 | const key = id.split(CUSTOM_ID_SEPARATOR)[0] 136 | interaction.data.custom_id = id.slice(key.length + 1) 137 | return key 138 | } 139 | } 140 | return '' 141 | })() 142 | switch (interaction.type) { 143 | case 1: 144 | return Response.json({ type: 1 } satisfies APIInteractionResponsePong) 145 | case 2: 146 | case 3: 147 | case 4: 148 | case 5: 149 | return await this.#get( 150 | interaction.type, 151 | key, 152 | // @ts-expect-error 153 | )(new Context(env, executionCtx, discord, key, interaction)) 154 | } 155 | return Response.json({ error: 'Unknown Type' }, { status: 400 }) 156 | } 157 | } 158 | return new Response('Not Found', { status: 404 }) 159 | } 160 | 161 | /** 162 | * Method triggered by cloudflare workers' crons 163 | * @param event 164 | * @param {Record} env 165 | * @param executionCtx 166 | */ 167 | scheduled = async (event: CronEvent, env: E['Bindings'], executionCtx?: ExecutionContext) => { 168 | const handler = this.#get(0, event.cron) 169 | const c = new Context(env, executionCtx, this.#discord(env), event.cron, event) as CronContext 170 | if (executionCtx?.waitUntil) executionCtx.waitUntil(handler(c)) 171 | else await handler(c) 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /src/helpers/create-factory.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi } from 'vitest' 2 | import { Button, Command, Modal } from '../builders' 3 | import { DiscordHono } from '../discord-hono' 4 | import { createFactory } from './create-factory' 5 | 6 | describe('createFactory', () => { 7 | const factory = createFactory() 8 | 9 | it('should create a DiscordHono instance', () => { 10 | const discord = factory.discord() 11 | expect(discord).toBeInstanceOf(DiscordHono) 12 | }) 13 | 14 | it('should create a command wrapper', () => { 15 | const commandMock = new Command('name', 'description') 16 | const handlerMock = vi.fn() 17 | const result = factory.command(commandMock, handlerMock) 18 | expect(result).toEqual({ command: commandMock, handler: handlerMock }) 19 | }) 20 | 21 | it('should create a component wrapper', () => { 22 | const componentMock = new Button('str', 'label') 23 | const handlerMock = vi.fn() 24 | const result = factory.component(componentMock, handlerMock) 25 | expect(result).toEqual({ component: componentMock, handler: handlerMock }) 26 | }) 27 | 28 | it('should create an autocomplete wrapper', () => { 29 | const commandMock = new Command('name', 'description') 30 | const autocompleteMock = vi.fn() 31 | const handlerMock = vi.fn() 32 | const result = factory.autocomplete(commandMock, autocompleteMock, handlerMock) 33 | expect(result).toEqual({ command: commandMock, autocomplete: autocompleteMock, handler: handlerMock }) 34 | }) 35 | 36 | it('should create a modal wrapper', () => { 37 | const modalMock = new Modal('unique_id', 'title') 38 | const handlerMock = vi.fn() 39 | const result = factory.modal(modalMock, handlerMock) 40 | expect(result).toEqual({ modal: modalMock, handler: handlerMock }) 41 | }) 42 | 43 | it('should create a cron wrapper', () => { 44 | const cronExpression = '0 0 * * *' 45 | const handlerMock = vi.fn() 46 | const result = factory.cron(cronExpression, handlerMock) 47 | expect(result).toEqual({ cron: cronExpression, handler: handlerMock }) 48 | }) 49 | 50 | it('should load handlers into DiscordHono instance', () => { 51 | const app = factory.discord() 52 | const commandMock = new Command('name', 'description') 53 | const componentMock = new Button('str', 'label') 54 | const modalMock = new Modal('unique_id', 'title') 55 | const handlerMock = vi.fn() 56 | 57 | const handlers = [ 58 | factory.command(commandMock, handlerMock), 59 | factory.component(componentMock, handlerMock), 60 | factory.modal(modalMock, handlerMock), 61 | factory.cron('0 0 * * *', handlerMock), 62 | ] 63 | 64 | vi.spyOn(app, 'command') 65 | vi.spyOn(app, 'component') 66 | vi.spyOn(app, 'modal') 67 | vi.spyOn(app, 'cron') 68 | 69 | app.loader(handlers) 70 | 71 | expect(app.command).toHaveBeenCalledWith('name', handlerMock) 72 | expect(app.component).toHaveBeenCalledWith('str', handlerMock) 73 | expect(app.modal).toHaveBeenCalledWith('unique_id', handlerMock) 74 | expect(app.cron).toHaveBeenCalledWith('0 0 * * *', handlerMock) 75 | }) 76 | 77 | it('should throw an error for unknown wrapper type', () => { 78 | const app = factory.discord() 79 | expect(() => app.loader([{ unknownProp: 'value' } as any])).toThrow() 80 | }) 81 | 82 | it('should return a list of commands', () => { 83 | const commandMock = new Command('name', 'description') 84 | const handlers = [factory.command(commandMock, vi.fn())] 85 | const commands = factory.getCommands(handlers) 86 | expect(commands).toEqual([commandMock]) 87 | }) 88 | }) 89 | -------------------------------------------------------------------------------- /src/helpers/create-factory.ts: -------------------------------------------------------------------------------- 1 | import type { Command } from '../builders/command' 2 | import type { Modal } from '../builders/modal' 3 | import { DiscordHono } from '../discord-hono' 4 | import type { 5 | AutocompleteHandler, 6 | CommandHandler, 7 | ComponentHandler, 8 | ComponentType, 9 | CronHandler, 10 | Env, 11 | InitOptions, 12 | ModalHandler, 13 | } from '../types' 14 | import { CUSTOM_ID_SEPARATOR, newError } from '../utils' 15 | 16 | class DiscordHonoExtends extends DiscordHono { 17 | loader = (handlers: Handler[]) => { 18 | for (const elem of handlers) { 19 | if ('command' in elem) { 20 | if ('autocomplete' in elem) this.autocomplete(elem.command.toJSON().name, elem.autocomplete, elem.handler) 21 | else this.command(elem.command.toJSON().name, elem.handler) 22 | } else if ('component' in elem) { 23 | const json = elem.component.toJSON() 24 | if ('custom_id' in json) this.component(json.custom_id.split(CUSTOM_ID_SEPARATOR)[0], elem.handler) 25 | } else if ('modal' in elem) this.modal(elem.modal.toJSON().custom_id.split(CUSTOM_ID_SEPARATOR)[0], elem.handler) 26 | else if ('cron' in elem) this.cron(elem.cron, elem.handler) 27 | else throw newError('.loader(obj)', 'obj is Invalid') 28 | } 29 | return this 30 | } 31 | } 32 | 33 | // biome-ignore lint: Null Variables 34 | type Var = {} 35 | 36 | type Factory = { 37 | discord: (init?: InitOptions) => DiscordHonoExtends 38 | command: ( 39 | command: Command, 40 | handler: CommandHandler, 41 | ) => { command: Command; handler: CommandHandler } 42 | component: ( 43 | component: C, 44 | handler: ComponentHandler, 45 | ) => { component: C; handler: ComponentHandler } 46 | autocomplete: ( 47 | command: Command, 48 | autocomplete: AutocompleteHandler, 49 | handler: CommandHandler, 50 | ) => { command: Command; autocomplete: AutocompleteHandler; handler: CommandHandler } 51 | modal: ( 52 | modal: Modal, 53 | handler: ModalHandler, 54 | ) => { modal: Modal; handler: ModalHandler } 55 | cron: ( 56 | cron: string, 57 | handler: CronHandler, 58 | ) => { cron: string; handler: CronHandler } 59 | getCommands: (handlers: Handler[]) => Command[] 60 | } 61 | 62 | type Handler = 63 | | ReturnType['command']> 64 | | ReturnType['component']> 65 | | ReturnType['autocomplete']> 66 | | ReturnType['modal']> 67 | | ReturnType['cron']> 68 | 69 | export const createFactory = (): Factory => ({ 70 | discord: init => new DiscordHonoExtends(init), 71 | command: (command, handler) => ({ command, handler: handler as CommandHandler }), 72 | component: (component, handler) => ({ component, handler: handler as ComponentHandler }), 73 | autocomplete: (command, autocomplete, handler) => ({ 74 | command, 75 | autocomplete: autocomplete as AutocompleteHandler, 76 | handler: handler as CommandHandler, 77 | }), 78 | modal: (modal, handler) => ({ modal, handler: handler as ModalHandler }), 79 | cron: (cron, handler) => ({ cron, handler: handler as CronHandler }), 80 | getCommands: handlers => handlers.filter(e => 'command' in e).map(e => e.command), 81 | }) 82 | -------------------------------------------------------------------------------- /src/helpers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './register' 2 | export * from './create-factory' 3 | export * from './retry429' 4 | export * from './webhook' 5 | -------------------------------------------------------------------------------- /src/helpers/register.test.ts: -------------------------------------------------------------------------------- 1 | import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' 2 | import { _applications_$_commands, _applications_$_guilds_$_commands } from '../rest/rest-path' 3 | import { register } from './register' 4 | 5 | const mockRest = vi.fn() 6 | const mockToken = vi.fn(() => 'mock-token')() 7 | 8 | vi.mock('../utils') 9 | vi.mock('../rest/rest', () => ({ 10 | createRest: vi.fn(() => mockRest), 11 | })) 12 | 13 | describe('register function', () => { 14 | const mockCommands = [{ name: 'test', description: 'A test command' }] 15 | const mockApplicationId = '123456789' 16 | 17 | beforeEach(() => { 18 | vi.clearAllMocks() 19 | }) 20 | 21 | afterEach(() => { 22 | vi.restoreAllMocks() 23 | }) 24 | 25 | it("should throw errors if token or application_id aren't provided", async () => { 26 | await expect(register(mockCommands, mockApplicationId, undefined)).rejects.toThrow() 27 | await expect(register(mockCommands, undefined, mockToken)).rejects.toThrow() 28 | }) 29 | 30 | it('should register commands for a specific guild', async () => { 31 | const mockGuildId = '987654321' 32 | const mockResponse = { ok: true, status: 200, statusText: 'OK' } 33 | mockRest.mockResolvedValue(mockResponse) 34 | 35 | const result = await register(mockCommands, mockApplicationId, mockToken, mockGuildId) 36 | 37 | expect(mockRest).toHaveBeenCalledWith( 38 | 'PUT', 39 | _applications_$_guilds_$_commands, 40 | [mockApplicationId, mockGuildId], 41 | expect.any(Array), 42 | ) 43 | expect(result).toContain('✅ Success') 44 | }) 45 | 46 | it('should register global commands when guild_id is not provided', async () => { 47 | const mockResponse = { ok: true, status: 200, statusText: 'OK' } 48 | mockRest.mockResolvedValue(mockResponse) 49 | 50 | const result = await register(mockCommands, mockApplicationId, mockToken) 51 | 52 | expect(mockRest).toHaveBeenCalledWith('PUT', _applications_$_commands, [mockApplicationId], expect.any(Array)) 53 | expect(result).toContain('✅ Success') 54 | }) 55 | 56 | it('should handle error responses', async () => { 57 | const mockErrorResponse = { 58 | ok: false, 59 | status: 400, 60 | statusText: 'Bad Request', 61 | url: 'https://discord.com/api/v10/applications/123456789/commands', 62 | text: vi.fn().mockResolvedValue('Invalid command structure'), 63 | } 64 | mockRest.mockResolvedValue(mockErrorResponse) 65 | 66 | const result = await register(mockCommands, mockApplicationId, mockToken) 67 | 68 | expect(result).toContain('⚠️ Error') 69 | expect(result).toContain('Error registering commands') 70 | expect(result).toContain('Invalid command structure') 71 | }) 72 | 73 | it('should handle error when reading response body fails', async () => { 74 | const mockErrorResponse = { 75 | ok: false, 76 | status: 500, 77 | statusText: 'Internal Server Error', 78 | url: 'https://discord.com/api/v10/applications/123456789/commands', 79 | text: vi.fn().mockRejectedValue(new Error('Failed to read body')), 80 | } 81 | mockRest.mockResolvedValue(mockErrorResponse) 82 | 83 | const result = await register(mockCommands, mockApplicationId, mockToken) 84 | 85 | expect(result).toContain('⚠️ Error') 86 | expect(result).toContain('Error registering commands') 87 | expect(result).toContain('Error reading body from request') 88 | }) 89 | }) 90 | -------------------------------------------------------------------------------- /src/helpers/register.ts: -------------------------------------------------------------------------------- 1 | import type { SlashCommandBuilder } from '@discordjs/builders' 2 | import type { RESTPostAPIApplicationCommandsJSONBody } from 'discord-api-types/v10' 3 | import type { Command } from '../builders/command' 4 | import { createRest } from '../rest/rest' 5 | import { _applications_$_commands, _applications_$_guilds_$_commands } from '../rest/rest-path' 6 | import { newError, toJSON } from '../utils' 7 | 8 | /** 9 | * [Docs](https://discord-hono.luis.fun/rest-api/register/) 10 | * @param {(Command | SlashCommandBuilder | RESTPostAPIApplicationCommandsJSONBody)[]} commands 11 | * @param {string} application_id 12 | * @param {string} token 13 | * @param {string} [guild_id] 14 | */ 15 | export const register = async ( 16 | commands: (Command | SlashCommandBuilder | RESTPostAPIApplicationCommandsJSONBody)[], 17 | application_id: string | undefined, 18 | token: string | undefined, 19 | guild_id?: string | undefined, 20 | ) => { 21 | if (!token) throw newError('register', 'DISCORD_TOKEN') 22 | if (!application_id) throw newError('register', 'DISCORD_APPLICATION_ID') 23 | 24 | const rest = createRest(token) 25 | const json = commands.map(toJSON) 26 | let res: Response 27 | if (guild_id) res = await rest('PUT', _applications_$_guilds_$_commands, [application_id, guild_id], json) 28 | else res = await rest('PUT', _applications_$_commands, [application_id], json) 29 | 30 | let logText = '' 31 | if (res.ok) { 32 | logText = '===== ✅ Success =====' 33 | // biome-ignore lint: console 34 | console.log(logText) 35 | } else { 36 | logText = `Error registering commands\n${res.url}: ${res.status} ${res.statusText}` 37 | try { 38 | const error = await res.text() 39 | if (error) { 40 | logText += `\n\n${error}` 41 | } 42 | } catch (e) { 43 | logText += `\n\nError reading body from request:\n${e}` 44 | } 45 | logText += '\n===== ⚠️ Error =====' 46 | // biome-ignore lint: console 47 | console.error(logText) 48 | } 49 | return logText 50 | } 51 | -------------------------------------------------------------------------------- /src/helpers/retry429.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi } from 'vitest' // 追加 2 | import { retry429 } from './retry429' 3 | 4 | describe('retry429', () => { 5 | it('returns response immediately on successful request', async () => { 6 | const mockFetch = vi.fn().mockResolvedValue({ status: 200 }) 7 | const result = await retry429(mockFetch, 3) 8 | expect(result.status).toBe(200) 9 | expect(mockFetch).toHaveBeenCalledTimes(1) 10 | }) 11 | 12 | it('retries according to Retry-After header on 429 error', async () => { 13 | const mockFetch = vi 14 | .fn() 15 | .mockResolvedValueOnce({ status: 429, headers: new Headers({ 'Retry-After': '1' }) }) 16 | .mockResolvedValueOnce({ status: 200 }) 17 | 18 | vi.useFakeTimers() 19 | const promise = retry429(mockFetch, 3) 20 | await vi.runAllTimersAsync() 21 | const result = await promise 22 | vi.useRealTimers() 23 | 24 | expect(result.status).toBe(200) 25 | expect(mockFetch).toHaveBeenCalledTimes(2) 26 | }) 27 | 28 | it('applies additional delay', async () => { 29 | const mockFetch = vi 30 | .fn() 31 | .mockResolvedValueOnce({ status: 429, headers: new Headers({ 'Retry-After': '1' }) }) 32 | .mockResolvedValueOnce({ status: 200 }) 33 | 34 | vi.useFakeTimers() 35 | const promise = retry429(mockFetch, 3, 500) 36 | await vi.runAllTimersAsync() 37 | const result = await promise 38 | vi.useRealTimers() 39 | 40 | expect(result.status).toBe(200) 41 | expect(mockFetch).toHaveBeenCalledTimes(2) 42 | //expect(vi.getTimerCount()).toBe(0) // Confirm all timers have been executed 43 | }) 44 | 45 | it('returns last response when max retry count is reached', async () => { 46 | const mockFetch = vi.fn().mockResolvedValue({ status: 429, headers: new Headers({ 'Retry-After': '1' }) }) 47 | 48 | vi.useFakeTimers() 49 | const promise = retry429(mockFetch, 3) 50 | await vi.runAllTimersAsync() 51 | const result = await promise 52 | vi.useRealTimers() 53 | 54 | expect(result.status).toBe(429) 55 | expect(mockFetch).toHaveBeenCalledTimes(4) // Initial + 3 retries 56 | }) 57 | 58 | it('does not retry when Retry-After header is missing', async () => { 59 | const mockFetch = vi.fn().mockResolvedValue({ status: 429, headers: new Headers() }) 60 | const result = await retry429(mockFetch, 3) 61 | expect(result.status).toBe(429) 62 | expect(mockFetch).toHaveBeenCalledTimes(1) 63 | }) 64 | }) 65 | -------------------------------------------------------------------------------- /src/helpers/retry429.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * [Reference](https://discord.com/developers/docs/topics/rate-limits) 3 | * @param {() => ReturnType} fetchFunc 4 | * @param {number} retryCount 5 | * @param {number} [addDelay=0] Additional delay milliseconds 6 | * @returns {ReturnType} 7 | */ 8 | export const retry429 = (fetchFunc: () => ReturnType, retryCount: number, addDelay = 0) => { 9 | const retryFetch = async (count: number) => { 10 | const res = await fetchFunc() 11 | if (res.status !== 429 || count < 1) return res 12 | const retryAfter = res.headers.get('Retry-After') 13 | if (!retryAfter) return res 14 | const delay = Number(retryAfter) * 1e3 + addDelay 15 | await new Promise(resolve => setTimeout(resolve, Math.max(delay, 0))) 16 | return retryFetch(count - 1) 17 | } 18 | return retryFetch(retryCount) 19 | } 20 | 21 | /* 22 | const getXRateLimit = (res: Response) => ({ 23 | RetryAfter: res.headers.get('Retry-After'), 24 | Limit: res.headers.get('X-RateLimit-Limit'), 25 | Remaining: res.headers.get('X-RateLimit-Limit'), 26 | Reset: res.headers.get('X-RateLimit-Reset'), 27 | ResetAfter: res.headers.get('X-RateLimit-Reset-After'), 28 | Bucket: res.headers.get('X-RateLimit-Bucket'), 29 | Scope: res.headers.get('X-RateLimit-Scope'), 30 | Global: res.headers.get('X-RateLimit-Global'), 31 | }) 32 | */ 33 | -------------------------------------------------------------------------------- /src/helpers/webhook.test.ts: -------------------------------------------------------------------------------- 1 | import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' 2 | import { formData, queryStringify } from '../utils' 3 | import { webhook } from './webhook' 4 | 5 | // モックの設定 6 | vi.mock('../utils', async importOriginal => { 7 | const actual = await importOriginal() 8 | return { 9 | ...actual, 10 | formData: vi.fn((data, file) => `mocked-form-data-${JSON.stringify(data)}-${JSON.stringify(file)}`), 11 | prepareData: vi.fn(data => ({ ...data, prepared: true })), 12 | queryStringify: vi.fn(() => '?mocked-query'), 13 | } 14 | }) 15 | 16 | describe('webhook', () => { 17 | const originalFetch = globalThis.fetch 18 | 19 | beforeEach(() => { 20 | globalThis.fetch = vi.fn().mockResolvedValue(new Response('ok')) 21 | }) 22 | 23 | afterEach(() => { 24 | vi.clearAllMocks() 25 | globalThis.fetch = originalFetch 26 | }) 27 | 28 | it('should make a POST request with JSON body when no file is provided', async () => { 29 | const url = 'https://discord.com/api/webhooks/123/abc' 30 | const data = { content: 'Hello, world!' } 31 | 32 | await webhook(url, data) 33 | 34 | expect(globalThis.fetch).toHaveBeenCalledTimes(1) 35 | expect(globalThis.fetch).toHaveBeenCalledWith(`${url}?mocked-query`, { 36 | method: 'POST', 37 | headers: { 'content-type': 'application/json' }, 38 | body: JSON.stringify({ ...data, prepared: true }), 39 | }) 40 | }) 41 | 42 | it('should make a POST request with form data when file is provided', async () => { 43 | const url = 'https://discord.com/api/webhooks/123/abc' 44 | const data = { content: 'Hello with file!' } 45 | const file = { blob: new Blob(['test file content']), name: 'test.txt' } 46 | 47 | await webhook(url, data, file) 48 | 49 | expect(globalThis.fetch).toHaveBeenCalledTimes(1) 50 | expect(globalThis.fetch).toHaveBeenCalledWith(`${url}?mocked-query`, { 51 | method: 'POST', 52 | headers: {}, 53 | body: `mocked-form-data-${JSON.stringify({ ...data, prepared: true })}-${JSON.stringify(file)}`, 54 | }) 55 | expect(formData).toHaveBeenCalledWith({ ...data, prepared: true }, file) 56 | }) 57 | 58 | it('should handle query parameters correctly', async () => { 59 | const url = 'https://discord.com/api/webhooks/123/abc' 60 | const data = { 61 | content: 'Hello, world!', 62 | query: { wait: true, thread_id: '456' }, 63 | } 64 | 65 | await webhook(url, data) 66 | 67 | expect(queryStringify).toHaveBeenCalledWith(data.query) 68 | expect(globalThis.fetch).toHaveBeenCalledWith(`${url}?mocked-query`, expect.any(Object)) 69 | }) 70 | 71 | it('should handle array of files correctly', async () => { 72 | const url = 'https://discord.com/api/webhooks/123/abc' 73 | const data = { content: 'Multiple files' } 74 | const files = [ 75 | { blob: new Blob(['file1']), name: 'file1.txt' }, 76 | { blob: new Blob(['file2']), name: 'file2.txt' }, 77 | ] 78 | 79 | await webhook(url, data, files) 80 | 81 | expect(globalThis.fetch).toHaveBeenCalledTimes(1) 82 | expect(formData).toHaveBeenCalledWith({ ...data, prepared: true }, files) 83 | }) 84 | }) 85 | -------------------------------------------------------------------------------- /src/helpers/webhook.ts: -------------------------------------------------------------------------------- 1 | import type { RESTPostAPIWebhookWithTokenJSONBody, RESTPostAPIWebhookWithTokenQuery } from 'discord-api-types/v10' 2 | import type { Query } from '../rest/rest-types' 3 | import type { CustomCallbackData, FileData } from '../types' 4 | import { formData, prepareData, queryStringify } from '../utils' 5 | 6 | /** 7 | * [Documentation](https://discord-hono.luis.fun/interactions/webhook/) 8 | * @param {string} url webhook url 9 | * @param {CustomCallbackData>} data [RESTPostAPIWebhookWithTokenJSONBody](https://discord-api-types.dev/api/next/discord-api-types-v10/interface/RESTPostAPIWebhookWithTokenJSONBody) 10 | * @param {FileData} file File: { blob: Blob, name: string } | { blob: Blob, name: string }[] 11 | * @returns {Promise} 12 | */ 13 | export const webhook = ( 14 | url: string, 15 | // @ts-expect-error: インデックス シグネチャがありません。ts(2344) 16 | data: CustomCallbackData>, 17 | file?: FileData, 18 | ) => { 19 | const headers: HeadersInit = {} 20 | if (!file) headers['content-type'] = 'application/json' 21 | const requestData: RequestInit = { method: 'POST', headers } 22 | const prepared = prepareData(data) as (Record & { query?: Record }) | undefined 23 | requestData.body = file ? formData(prepared, file) : JSON.stringify(prepared) 24 | return fetch(`${url + queryStringify(prepared?.query)}`, requestData) 25 | } 26 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export type { 2 | InitOptions, 3 | CommandContext, 4 | ComponentContext, 5 | AutocompleteContext, 6 | ModalContext, 7 | CronContext, 8 | CommandHandler, 9 | ComponentHandler, 10 | AutocompleteHandler, 11 | ModalHandler, 12 | CronHandler, 13 | } from './types' 14 | export * from './discord-hono' 15 | export { CUSTOM_ID_SEPARATOR } from './utils' 16 | export * from './rest' 17 | export * from './builders' 18 | export * from './helpers' 19 | -------------------------------------------------------------------------------- /src/rest/index.ts: -------------------------------------------------------------------------------- 1 | export type { RestMethod, RestPath, RestVariables, RestData, RestFile, RestResult } from './rest-types' 2 | export * from './rest' 3 | export * from './rest-path' 4 | -------------------------------------------------------------------------------- /src/rest/rest-path.ts: -------------------------------------------------------------------------------- 1 | ////////// Duplication ////////// 2 | 3 | export const _webhooks_$_$ = '/webhooks/{}/{}' as 4 | | '/webhooks/{application.id}/{interaction.token}' 5 | | '/webhooks/{webhook.id}/{webhook.token}' 6 | export const _webhooks_$_$_messages_$ = '/webhooks/{}/{}/messages/{}' as 7 | | '/webhooks/{application.id}/{interaction.token}/messages/{message.id}' 8 | | '/webhooks/{webhook.id}/{webhook.token}/messages/{message.id}' 9 | 10 | ////////// Receiving and Responding ////////// 11 | // https://discord.com/developers/docs/interactions/receiving-and-responding 12 | // ✅ 25/02/07 13 | export const _interactions_$_$_callback = '/interactions/{interaction.id}/{interaction.token}/callback' as const 14 | // Compressed because it's used in Context 15 | export const _webhooks_$_$_messages_original = 16 | '/webhooks/{}/{}/messages/@original' as '/webhooks/{application.id}/{interaction.token}/messages/@original' 17 | //export const _webhooks_$_$ = '/webhooks/{application.id}/{interaction.token}' as const 18 | //export const _webhooks_$_$_messages_$ = '/webhooks/{application.id}/{interaction.token}/messages/{message.id}' as const 19 | 20 | ////////// Application Commands ////////// 21 | // https://discord.com/developers/docs/interactions/application-commands 22 | // ✅ 25/02/07 23 | export const _applications_$_commands = '/applications/{application.id}/commands' as const 24 | export const _applications_$_commands_$ = '/applications/{application.id}/commands/{command.id}' as const 25 | export const _applications_$_guilds_$_commands = '/applications/{application.id}/guilds/{guild.id}/commands' as const 26 | export const _applications_$_guilds_$_commands_$ = 27 | '/applications/{application.id}/guilds/{guild.id}/commands/{command.id}' as const 28 | export const _applications_$_guilds_$_commands_permissions = 29 | '/applications/{application.id}/guilds/{guild.id}/commands/permissions' as const 30 | export const _applications_$_guilds_$_commands_$_permissions = 31 | '/applications/{application.id}/guilds/{guild.id}/commands/{command.id}/permissions' as const 32 | 33 | ////////// Application ////////// 34 | // https://discord.com/developers/docs/resources/application 35 | // ✅ 25/02/07 36 | export const _applications_me = '/applications/@me' as const 37 | export const _applications_$_activityinstances_$ = 38 | '/applications/{application.id}/activity-instances/{instance_id}' as const 39 | 40 | ////////// Application Role Connection Metadata ////////// 41 | // https://discord.com/developers/docs/resources/application-role-connection-metadata 42 | // ✅ 25/02/07 43 | export const _applications_$_roleconnections_metadata = 44 | '/applications/{application.id}/role-connections/metadata' as const 45 | 46 | ////////// Audit Log ////////// 47 | // https://discord.com/developers/docs/resources/audit-log 48 | // ✅ 25/02/07 49 | export const _guilds_$_auditlogs = '/guilds/{guild.id}/audit-logs' as const 50 | 51 | ////////// Auto Moderation ////////// 52 | // https://discord.com/developers/docs/resources/auto-moderation 53 | // ✅ 25/02/07 54 | export const _guilds_$_automoderation_rules = '/guilds/{guild.id}/auto-moderation/rules' as const 55 | export const _guilds_$_automoderation_rules_$ = 56 | '/guilds/{guild.id}/auto-moderation/rules/{auto_moderation_rule.id}' as const 57 | 58 | ////////// Channel ////////// 59 | // https://discord.com/developers/docs/resources/channel 60 | // ✅ 25/02/08 61 | export const _channels_$ = '/channels/{channel.id}' as const 62 | export const _channels_$_permissions_$ = '/channels/{channel.id}/permissions/{overwrite.id}' as const 63 | export const _channels_$_invites = '/channels/{channel.id}/invites' as const 64 | export const _channels_$_followers = '/channels/{channel.id}/followers' as const 65 | export const _channels_$_typing = '/channels/{channel.id}/typing' as const 66 | export const _channels_$_pins = '/channels/{channel.id}/pins' as const 67 | export const _channels_$_pins_$ = '/channels/{channel.id}/pins/{message.id}' as const 68 | export const _channels_$_recipients_$ = '/channels/{channel.id}/recipients/{user.id}' as const 69 | export const _channels_$_messages_$_threads = '/channels/{channel.id}/messages/{message.id}/threads' as const 70 | export const _channels_$_threads = '/channels/{channel.id}/threads' as const 71 | export const _channels_$_threadmembers_me = '/channels/{channel.id}/thread-members/@me' as const 72 | export const _channels_$_threadmembers_$ = '/channels/{channel.id}/thread-members/{user.id}' as const 73 | export const _channels_$_threadmembers = '/channels/{channel.id}/thread-members' as const 74 | export const _channels_$_threads_archived_public = '/channels/{channel.id}/threads/archived/public' as const 75 | export const _channels_$_threads_archived_private = '/channels/{channel.id}/threads/archived/private' as const 76 | export const _channels_$_users_me_threads_archived_private = 77 | '/channels/{channel.id}/users/@me/threads/archived/private' as const 78 | 79 | ////////// Emoji ////////// 80 | // https://discord.com/developers/docs/resources/emoji 81 | // ✅ 25/02/08 82 | export const _guilds_$_emojis = '/guilds/{guild.id}/emojis' as const 83 | export const _guilds_$_emojis_$ = '/guilds/{guild.id}/emojis/{emoji.id}' as const 84 | export const _applications_$_emojis = '/applications/{application.id}/emojis' as const 85 | export const _applications_$_emojis_$ = '/applications/{application.id}/emojis/{emoji.id}' as const 86 | 87 | ////////// Entitlement ////////// 88 | // https://discord.com/developers/docs/resources/entitlement 89 | // ✅ 25/02/08 90 | export const _applications_$_entitlements = '/applications/{application.id}/entitlements' as const 91 | export const _applications_$_entitlements_$ = '/applications/{application.id}/entitlements/{entitlement.id}' as const 92 | export const _applications_$_entitlements_$_consume = 93 | '/applications/{application.id}/entitlements/{entitlement.id}/consume' as const 94 | 95 | ////////// Guild ////////// 96 | // https://discord.com/developers/docs/resources/guild 97 | // ✅ 25/02/08 98 | export const _guilds = '/guilds' as const 99 | export const _guilds_$ = '/guilds/{guild.id}' as const 100 | export const _guilds_$_preview = '/guilds/{guild.id}/preview' as const 101 | export const _guilds_$_channels = '/guilds/{guild.id}/channels' as const 102 | export const _guilds_$_threads_active = '/guilds/{guild.id}/threads/active' as const 103 | export const _guilds_$_members_$ = '/guilds/{guild.id}/members/{user.id}' as const 104 | export const _guilds_$_members = '/guilds/{guild.id}/members' as const 105 | export const _guilds_$_members_search = '/guilds/{guild.id}/members/search' as const 106 | export const _guilds_$_members_me = '/guilds/{guild.id}/members/@me' as const 107 | export const _guilds_$_members_me_nick = '/guilds/{guild.id}/members/@me/nick' as const 108 | export const _guilds_$_members_$_roles_$ = '/guilds/{guild.id}/members/{user.id}/roles/{role.id}' as const 109 | export const _guilds_$_bans = '/guilds/{guild.id}/bans' as const 110 | export const _guilds_$_bans_$ = '/guilds/{guild.id}/bans/{user.id}' as const 111 | export const _guilds_$_bulkban = '/guilds/{guild.id}/bulk-ban' as const 112 | export const _guilds_$_roles = '/guilds/{guild.id}/roles' as const 113 | export const _guilds_$_roles_$ = '/guilds/{guild.id}/roles/{role.id}' as const 114 | export const _guilds_$_mfa = '/guilds/{guild.id}/mfa' as const 115 | export const _guilds_$_prune = '/guilds/{guild.id}/prune' as const 116 | export const _guilds_$_regions = '/guilds/{guild.id}/regions' as const 117 | export const _guilds_$_invites = '/guilds/{guild.id}/invites' as const 118 | export const _guilds_$_integrations = '/guilds/{guild.id}/integrations' as const 119 | export const _guilds_$_integrations_$ = '/guilds/{guild.id}/integrations/{integration.id}' as const 120 | export const _guilds_$_widget = '/guilds/{guild.id}/widget' as const 121 | export const _guilds_$_widgetjson = '/guilds/{guild.id}/widget.json' as const 122 | export const _guilds_$_vanityurl = '/guilds/{guild.id}/vanity-url' as const 123 | export const _guilds_$_widgetpng = '/guilds/{guild.id}/widget.png' as const 124 | export const _guilds_$_welcomescreen = '/guilds/{guild.id}/welcome-screen' as const 125 | export const _guilds_$_onboarding = '/guilds/{guild.id}/onboarding' as const 126 | export const _guilds_$_incidentactions = '/guilds/{guild.id}/incident-actions' as const 127 | 128 | ////////// Guild Scheduled Event ////////// 129 | // https://discord.com/developers/docs/resources/guild-scheduled-event 130 | // ✅ 25/02/08 131 | export const _guilds_$_scheduledevents = '/guilds/{guild.id}/scheduled-events' as const 132 | export const _guilds_$_scheduledevents_$ = '/guilds/{guild.id}/scheduled-events/{guild_scheduled_event.id}' as const 133 | export const _guilds_$_scheduledevents_$_users = 134 | '/guilds/{guild.id}/scheduled-events/{guild_scheduled_event.id}/users' as const 135 | 136 | ////////// Guild Template ////////// 137 | // https://discord.com/developers/docs/resources/guild-template 138 | // ✅ 25/02/08 139 | export const _guilds_templates_$ = '/guilds/templates/{template.code}' as const 140 | export const _guilds_$_templates = '/guilds/{guild.id}/templates' as const 141 | export const _guilds_$_templates_$ = '/guilds/{guild.id}/templates/{template.code}' as const 142 | 143 | ////////// Invite ////////// 144 | // https://discord.com/developers/docs/resources/invite 145 | // ✅ 25/02/08 146 | export const _invites_$ = '/invites/{invite.code}' as const 147 | 148 | ////////// Message ////////// 149 | // https://discord.com/developers/docs/resources/message 150 | // ✅ 25/02/08 151 | export const _channels_$_messages = '/channels/{channel.id}/messages' as const 152 | export const _channels_$_messages_$ = '/channels/{channel.id}/messages/{message.id}' as const 153 | export const _channels_$_messages_$_crosspost = '/channels/{channel.id}/messages/{message.id}/crosspost' as const 154 | export const _channels_$_messages_$_reactions_$_me = 155 | '/channels/{channel.id}/messages/{message.id}/reactions/{emoji}/@me' as const 156 | export const _channels_$_messages_$_reactions_$_$ = 157 | '/channels/{channel.id}/messages/{message.id}/reactions/{emoji}/{user.id}' as const 158 | export const _channels_$_messages_$_reactions_$ = 159 | '/channels/{channel.id}/messages/{message.id}/reactions/{emoji}' as const 160 | export const _channels_$_messages_$_reactions = '/channels/{channel.id}/messages/{message.id}/reactions' as const 161 | export const _channels_$_messages_bulkdelete = '/channels/{channel.id}/messages/bulk-delete' as const 162 | 163 | ////////// Poll ////////// 164 | // https://discord.com/developers/docs/resources/poll 165 | // ✅ 25/02/08 166 | export const _channels_$_polls_$_answers_$ = '/channels/{channel.id}/polls/{message.id}/answers/{answer_id}' as const 167 | export const _channels_$_polls_$_expire = '/channels/{channel.id}/polls/{message.id}/expire' as const 168 | 169 | ////////// SKU ////////// 170 | // https://discord.com/developers/docs/resources/sku 171 | // ✅ 25/02/08 172 | export const _applications_$_skus = '/applications/{application.id}/skus' as const 173 | 174 | ////////// Soundboard ////////// 175 | // https://discord.com/developers/docs/resources/soundboard 176 | // ✅ 25/02/08 177 | export const _channels_$_sendsoundboardsound = '/channels/{channel.id}/send-soundboard-sound' as const 178 | export const _soundboarddefaultsounds = '/soundboard-default-sounds' as const 179 | export const _guilds_$_soundboardsounds = '/guilds/{guild.id}/soundboard-sounds' as const 180 | export const _guilds_$_soundboardsounds_$ = '/guilds/{guild.id}/soundboard-sounds/{sound.id}' as const 181 | 182 | ////////// Stage Instance ////////// 183 | // https://discord.com/developers/docs/resources/stage-instance 184 | // ✅ 25/02/09 185 | export const _stageinstances = '/stage-instances' as const 186 | export const _stageinstances_$ = '/stage-instances/{channel.id}' as const 187 | 188 | ////////// Sticker ////////// 189 | // https://discord.com/developers/docs/resources/sticker 190 | // ✅ 25/02/09 191 | export const _stickers_$ = '/stickers/{sticker.id}' as const 192 | export const _stickerpacks = '/sticker-packs' as const 193 | export const _stickerpacks_$ = '/sticker-packs/{pack.id}' as const 194 | export const _guilds_$_stickers = '/guilds/{guild.id}/stickers' as const 195 | export const _guilds_$_stickers_$ = '/guilds/{guild.id}/stickers/{sticker.id}' as const 196 | 197 | ///// Not supported yet ///// 198 | // [Create Guild Sticker](https://discord.com/developers/docs/resources/sticker#create-guild-sticker) // https://discord-api-types.dev/search?q=RESTPostAPIGuildSticker 199 | 200 | ////////// Subscription ////////// 201 | // https://discord.com/developers/docs/resources/subscription 202 | // ✅ 25/02/09 203 | export const _skus_$_subscriptions = '/skus/{sku.id}/subscriptions' as const 204 | export const _skus_$_subscriptions_$ = '/skus/{sku.id}/subscriptions/{subscription.id}' as const 205 | 206 | ////////// User ////////// 207 | // https://discord.com/developers/docs/resources/user 208 | // ✅ 25/02/09 209 | export const _users_me = '/users/@me' as const 210 | export const _users_$ = '/users/{user.id}' as const 211 | export const _users_me_guilds = '/users/@me/guilds' as const 212 | export const _users_me_guilds_$_member = '/users/@me/guilds/{guild.id}/member' as const 213 | export const _users_me_guilds_$ = '/users/@me/guilds/{guild.id}' as const 214 | export const _users_me_channels = '/users/@me/channels' as const 215 | export const _users_me_connections = '/users/@me/connections' as const 216 | export const _users_me_applications_$_roleconnection = 217 | '/users/@me/applications/{application.id}/role-connection' as const 218 | 219 | ////////// Voice ////////// 220 | // https://discord.com/developers/docs/resources/voice 221 | // ✅ 25/02/09 222 | export const _voice_regions = '/voice/regions' as const 223 | export const _guilds_$_voicestates_me = '/guilds/{guild.id}/voice-states/@me' as const 224 | export const _guilds_$_voicestates_$ = '/guilds/{guild.id}/voice-states/{user.id}' as const 225 | 226 | ////////// Webhook ////////// 227 | // https://discord.com/developers/docs/resources/webhook 228 | // ✅ 25/02/09 229 | export const _channels_$_webhooks = '/channels/{channel.id}/webhooks' as const 230 | export const _guilds_$_webhooks = '/guilds/{guild.id}/webhooks' as const 231 | export const _webhooks_$ = '/webhooks/{webhook.id}' as const 232 | //export const _webhooks_$_$ = '/webhooks/{webhook.id}/{webhook.token}' as const 233 | export const _webhooks_$_$_slack = '/webhooks/{webhook.id}/{webhook.token}/slack' as const 234 | export const _webhooks_$_$_github = '/webhooks/{webhook.id}/{webhook.token}/github' as const 235 | //export const _webhooks_$_$_messages_$ = '/webhooks/{webhook.id}/{webhook.token}/messages/{message.id}' as const 236 | -------------------------------------------------------------------------------- /src/rest/rest.test.ts: -------------------------------------------------------------------------------- 1 | import { beforeEach, describe, expect, it, vi } from 'vitest' 2 | import { formData } from '../utils' 3 | import { createRest } from './rest' 4 | 5 | const mockToken = vi.fn(() => 'mock-token')() 6 | 7 | // モックの作成 8 | vi.mock('../utils', () => ({ 9 | prepareData: vi.fn(data => data), 10 | formData: vi.fn(), 11 | queryStringify: vi.fn(obj => { 12 | if (!obj || Object.keys(obj).length === 0) return '' 13 | return `?${Object.entries(obj) 14 | .map(([key, value]) => `${key}=${value}`) 15 | .join('&')}` 16 | }), 17 | })) 18 | 19 | describe('Rest', () => { 20 | let rest: ReturnType 21 | const mockFetch = vi.fn() 22 | 23 | beforeEach(() => { 24 | vi.resetAllMocks() 25 | // @ts-expect-error 26 | global.fetch = mockFetch 27 | rest = createRest(mockToken) 28 | }) 29 | 30 | it('should throw an error if token is not provided', () => { 31 | expect(() => createRest(undefined)('GET', '/applications/@me')).toThrow() 32 | }) 33 | 34 | it('should make a GET request', async () => { 35 | const mockResponse = { json: vi.fn().mockResolvedValue({ data: 'mock_data' }) } 36 | mockFetch.mockResolvedValue(mockResponse) 37 | // @ts-expect-error 38 | const result = await rest('GET', '/users/{user.id}/emoji/{emoji.id}', ['123', '45678'], { query: 'param' }).then( 39 | r => r.json(), 40 | ) 41 | 42 | expect(mockFetch).toHaveBeenCalledWith('https://discord.com/api/v10/users/123/emoji/45678?query=param', { 43 | method: 'GET', 44 | headers: { 45 | Authorization: `Bot ${mockToken}`, 46 | 'content-type': 'application/json', 47 | }, 48 | }) 49 | expect(result).toEqual({ data: 'mock_data' }) 50 | }) 51 | 52 | it('should make a PUT request', async () => { 53 | mockFetch.mockResolvedValue({}) 54 | // @ts-expect-error 55 | await rest('PUT', '/guilds/{guild.id}', ['456'], { name: 'New Guild Name' }) 56 | 57 | expect(mockFetch).toHaveBeenCalledWith('https://discord.com/api/v10/guilds/456', { 58 | method: 'PUT', 59 | headers: { 60 | Authorization: `Bot ${mockToken}`, 61 | 'content-type': 'application/json', 62 | }, 63 | body: '{"name":"New Guild Name"}', 64 | }) 65 | }) 66 | 67 | it('should make a POST request with file', async () => { 68 | mockFetch.mockResolvedValue({}) 69 | const mockFormData = new FormData() 70 | // @ts-expect-error 71 | formData.mockReturnValue(mockFormData) 72 | 73 | const fileData = { name: 'test.png', blob: new Blob(['test']) } 74 | await rest('POST', '/channels/{channel.id}/messages', ['789'], { content: 'Hello' }, fileData) 75 | 76 | expect(mockFetch).toHaveBeenCalledWith('https://discord.com/api/v10/channels/789/messages', { 77 | method: 'POST', 78 | headers: { 79 | Authorization: `Bot ${mockToken}`, 80 | }, 81 | body: mockFormData, 82 | }) 83 | expect(formData).toHaveBeenCalledWith({ content: 'Hello' }, fileData) 84 | }) 85 | 86 | it('should make a PATCH request', async () => { 87 | mockFetch.mockResolvedValue({}) 88 | 89 | await rest('PATCH', '/channels/{channel.id}', ['101'], { name: 'Updated Channel' }) 90 | 91 | expect(mockFetch).toHaveBeenCalledWith('https://discord.com/api/v10/channels/101', { 92 | method: 'PATCH', 93 | headers: { 94 | Authorization: `Bot ${mockToken}`, 95 | 'content-type': 'application/json', 96 | }, 97 | body: '{"name":"Updated Channel"}', 98 | }) 99 | }) 100 | 101 | it('should make a DELETE request', async () => { 102 | mockFetch.mockResolvedValue({}) 103 | await rest('DELETE', '/channels/{channel.id}', ['202']) 104 | 105 | expect(mockFetch).toHaveBeenCalledWith('https://discord.com/api/v10/channels/202', { 106 | method: 'DELETE', 107 | headers: { 108 | Authorization: `Bot ${mockToken}`, 109 | 'content-type': 'application/json', 110 | }, 111 | }) 112 | }) 113 | }) 114 | -------------------------------------------------------------------------------- /src/rest/rest.ts: -------------------------------------------------------------------------------- 1 | import type { FileData } from '../types' 2 | import { formData, newError, prepareData, queryStringify } from '../utils' 3 | import type { Rest } from './rest-types' 4 | 5 | const API_VER = 'v10' 6 | 7 | /** 8 | * [Documentation](https://discord-hono.luis.fun/interactions/rest/) 9 | * @param {string} token 10 | */ 11 | export const createRest = 12 | (token: string | undefined): Rest => 13 | /** 14 | * [Documentation](https://discord-hono.luis.fun/interactions/rest/) 15 | * @param {'GET' | 'PUT' | 'POST' | 'PATCH' | 'DELETE'} method 16 | * @param {string} path Official document path 17 | * @param {string[]} variables Variable part of official document path 18 | * @param {Record | Record[]} data 19 | * @param {FileData} file 20 | * @returns {Promise} 21 | */ 22 | ( 23 | method: string, 24 | path: string, 25 | variables: string[] = [], 26 | data?: (Record & { query?: any }) | Record[] | string, 27 | file?: FileData, 28 | ) => { 29 | if (!token) throw newError('REST', 'DISCORD_TOKEN') 30 | const isGet = method.toUpperCase() === 'GET' 31 | const vars = [...variables] 32 | const headers: HeadersInit = { Authorization: `Bot ${token}` } 33 | if (!file) headers['content-type'] = 'application/json' 34 | const requestData: RequestInit = { method, headers } 35 | const prepared: 36 | | (Record & { query?: Record }) 37 | | Record[] 38 | | undefined = prepareData(data) 39 | if (!isGet) requestData.body = file ? formData(prepared, file) : JSON.stringify(prepared) 40 | return fetch( 41 | `https://discord.com/api/${API_VER + path.replace(/\{[^}]*\}/g, () => vars.shift() ?? '') + queryStringify(Array.isArray(prepared) ? undefined : isGet ? prepared : prepared?.query)}`, 42 | requestData, 43 | ) 44 | } 45 | 46 | /* 47 | const rest = createRest('') 48 | const res1 = await rest('POST', '/applications/{application.id}/commands', ['application.id'], { 49 | name: '', 50 | //description: '', 51 | }).then(r => r.json()) 52 | const res2 = await rest('GET', '/applications/{application.id}/activity-instances/{instance_id}', [ 53 | 'application.id', 54 | 'instance_id', 55 | ]).then(r => r.json()) 56 | // @ts-expect-error 57 | const res3 = await rest('GET', '/unknown', [], { content: '' }).then(r => r.json()) 58 | const res4 = await rest('GET', '/applications/@me').then(r => r.json()) 59 | const res5 = await rest('POST', "/interactions/{interaction.id}/{interaction.token}/callback", ["", ""], {type: 1}).then(r => r.json()) 60 | */ 61 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import type { EmbedBuilder } from '@discordjs/builders' 2 | import type { 3 | APIApplicationCommandAutocompleteInteraction, 4 | APIApplicationCommandInteraction, 5 | APIMessageComponentButtonInteraction, 6 | APIMessageComponentInteraction, 7 | APIMessageComponentSelectMenuInteraction, 8 | APIModalSubmitInteraction, 9 | } from 'discord-api-types/v10' 10 | import type { Button, Components, Select } from './builders/components' 11 | import type { 12 | ContentFile, 13 | ContentMediaGallery, 14 | ContentTextDisplay, 15 | LayoutActionRow, 16 | LayoutContainer, 17 | LayoutSection, 18 | LayoutSeparator, 19 | } from './builders/components-v2' 20 | import type { Embed } from './builders/embed' 21 | import type { Poll } from './builders/poll' 22 | import type { Context } from './context' 23 | 24 | ////////// Env ////////// 25 | 26 | export type Env = { 27 | Bindings?: object 28 | Variables?: Record 29 | } 30 | 31 | ////////// DiscordEnv ////////// 32 | 33 | export type DiscordEnv = { 34 | TOKEN?: string 35 | PUBLIC_KEY?: string 36 | APPLICATION_ID?: string 37 | } 38 | 39 | ////////// Context ////////// 40 | 41 | export type ExcludeMethods = { [P in keyof T as P extends K ? never : P]: T[P] } 42 | 43 | export type ComponentType = Button | Select //'Button' | 'Select' 44 | // biome-ignore format: ternary operator 45 | type ComponentInteraction = 46 | T extends Button ? APIMessageComponentButtonInteraction : 47 | T extends Select ? APIMessageComponentSelectMenuInteraction : 48 | APIMessageComponentInteraction 49 | 50 | export type CommandContext = ExcludeMethods< 51 | Context>, 52 | 'update' | 'focused' | 'resAutocomplete' | 'interaction' 53 | > & { interaction: APIApplicationCommandInteraction } 54 | 55 | export type ComponentContext = ExcludeMethods< 56 | Context>, 57 | 'sub' | 'focused' | 'resAutocomplete' | 'interaction' 58 | > & { interaction: ComponentInteraction } 59 | 60 | export type AutocompleteContext = ExcludeMethods< 61 | Context>, 62 | 'flags' | 'res' | 'resDefer' | 'resActivity' | 'followup' | 'resModal' | 'update' | 'interaction' 63 | > & { interaction: APIApplicationCommandAutocompleteInteraction } 64 | 65 | export type ModalContext = ExcludeMethods< 66 | Context>, 67 | 'sub' | 'resModal' | 'focused' | 'resAutocomplete' | 'interaction' 68 | > & { interaction: APIModalSubmitInteraction } 69 | 70 | export type CronContext = ExcludeMethods< 71 | Context>, 72 | | 'flags' 73 | | 'res' 74 | | 'resDefer' 75 | | 'resActivity' 76 | | 'followup' 77 | | 'sub' 78 | | 'resModal' 79 | | 'update' 80 | | 'focused' 81 | | 'resAutocomplete' 82 | | 'interaction' 83 | > & { interaction: CronEvent } 84 | 85 | ////////// Handler ////////// 86 | 87 | export type CommandHandler = (c: CommandContext) => Promise | Response 88 | export type ComponentHandler = ( 89 | c: ComponentContext, 90 | ) => Promise | Response 91 | export type AutocompleteHandler = (c: AutocompleteContext) => Promise | Response 92 | export type ModalHandler = (c: ModalContext) => Promise | Response 93 | export type CronHandler = (c: CronContext) => Promise 94 | 95 | ////////// InitOptions ////////// 96 | 97 | export type Verify = ( 98 | body: string, 99 | signature: string | null, 100 | timestamp: string | null, 101 | publicKey: string, 102 | ) => Promise | boolean 103 | export type InitOptions = { 104 | verify?: Verify 105 | discordEnv?: (env: E['Bindings']) => DiscordEnv 106 | } 107 | 108 | ////////// CronEvent ////////// 109 | // https://developers.cloudflare.com/workers/runtime-apis/handlers/scheduled/#syntax 110 | 111 | export type CronEvent = { 112 | cron: string 113 | type: string 114 | scheduledTime: number 115 | } 116 | 117 | ////////// ExecutionContext ////////// 118 | 119 | export interface ExecutionContext { 120 | waitUntil(promise: Promise): void 121 | passThroughOnException(): void 122 | } 123 | 124 | ////////// FetchEventLike ////////// 125 | 126 | export abstract class FetchEventLike { 127 | abstract readonly request: Request 128 | abstract respondWith(promise: Response | Promise): void 129 | abstract passThroughOnException(): void 130 | abstract waitUntil(promise: Promise): void 131 | } 132 | 133 | ////////// InteractionData ////////// 134 | 135 | export type CustomCallbackData> = 136 | | (Omit & { 137 | components?: 138 | | Components 139 | | ( 140 | | LayoutActionRow 141 | | LayoutSection 142 | | LayoutSeparator 143 | | LayoutContainer 144 | | ContentTextDisplay 145 | | ContentMediaGallery 146 | | ContentFile 147 | )[] 148 | | T['components'] 149 | embeds?: (Embed | EmbedBuilder)[] | T['embeds'] 150 | poll?: Poll | T['poll'] 151 | }) 152 | | string 153 | 154 | ////////// FileData ////////// 155 | 156 | type FileUnit = { 157 | blob: Blob 158 | name: string 159 | } 160 | export type FileData = FileUnit | FileUnit[] 161 | -------------------------------------------------------------------------------- /src/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { beforeEach, describe, expect, it, test, vi } from 'vitest' 2 | import { Components, Embed } from '.' 3 | import { formData, newError, prepareData, queryStringify, toJSON } from './utils' 4 | 5 | describe('toJSON function', () => { 6 | it('should return the result of toJSON method if it exists', () => { 7 | const obj = { 8 | toJSON: () => ({ custom: 'json' }), 9 | } 10 | expect(toJSON(obj)).toEqual({ custom: 'json' }) 11 | }) 12 | 13 | it('should return the object itself if toJSON method does not exist', () => { 14 | const obj = { key: 'value' } 15 | expect(toJSON(obj)).toEqual(obj) 16 | }) 17 | 18 | it('should return the object itself if toJSON is not a function', () => { 19 | const obj = { 20 | toJSON: 'not a function', 21 | } 22 | expect(toJSON(obj)).toEqual(obj) 23 | }) 24 | 25 | it('should handle empty objects', () => { 26 | const obj = {} 27 | expect(toJSON(obj)).toEqual({}) 28 | }) 29 | 30 | it('should handle objects with nested properties', () => { 31 | const obj = { 32 | nested: { 33 | toJSON: () => ({ custom: 'nested json' }), 34 | }, 35 | } 36 | expect(toJSON(obj)).toEqual(obj) 37 | }) 38 | }) 39 | 40 | describe('prepareData', () => { 41 | it('should handle string input', () => { 42 | const input = 'test string' 43 | const result = prepareData(input) 44 | expect(result).toEqual({ content: 'test string' }) 45 | }) 46 | 47 | it('should handle object input without components or embeds', () => { 48 | const input = { someKey: 'someValue' } 49 | const result = prepareData(input) 50 | expect(result).toEqual(input) 51 | }) 52 | 53 | it('should handle object input with both components and embeds', () => { 54 | const input = { 55 | components: new Components(), 56 | embeds: [new Embed()], 57 | } 58 | const result = prepareData(input) 59 | expect(result).toEqual({ 60 | components: [], 61 | embeds: [{}], 62 | }) 63 | }) 64 | }) 65 | 66 | describe('formData function', () => { 67 | let appendSpy: ReturnType 68 | 69 | beforeEach(() => { 70 | appendSpy = vi.fn() 71 | vi.stubGlobal( 72 | 'FormData', 73 | vi.fn(() => ({ 74 | append: appendSpy, 75 | })), 76 | ) 77 | }) 78 | 79 | it('should create FormData with payload_json when data is provided', () => { 80 | const mockData = { key: 'value' } 81 | formData(mockData) 82 | 83 | expect(appendSpy).toHaveBeenCalledWith('payload_json', JSON.stringify(mockData)) 84 | }) 85 | 86 | it('should not append payload_json when data is empty', () => { 87 | formData({}) 88 | 89 | expect(appendSpy).not.toHaveBeenCalledWith('payload_json', expect.any(String)) 90 | }) 91 | 92 | it('should append single file when file object is provided', () => { 93 | const mockFile = { blob: new Blob(), name: 'test.txt' } 94 | formData(undefined, mockFile) 95 | 96 | expect(appendSpy).toHaveBeenCalledWith('files[0]', mockFile.blob, mockFile.name) 97 | }) 98 | 99 | it('should append multiple files when file array is provided', () => { 100 | const mockFiles = [ 101 | { blob: new Blob(), name: 'test1.txt' }, 102 | { blob: new Blob(), name: 'test2.txt' }, 103 | ] 104 | formData(undefined, mockFiles) 105 | 106 | expect(appendSpy).toHaveBeenCalledWith('files[0]', mockFiles[0].blob, mockFiles[0].name) 107 | expect(appendSpy).toHaveBeenCalledWith('files[1]', mockFiles[1].blob, mockFiles[1].name) 108 | }) 109 | 110 | it('should handle both data and file', () => { 111 | const mockData = { key: 'value' } 112 | const mockFile = { blob: new Blob(), name: 'test.txt' } 113 | formData(mockData, mockFile) 114 | 115 | expect(appendSpy).toHaveBeenCalledWith('payload_json', JSON.stringify(mockData)) 116 | expect(appendSpy).toHaveBeenCalledWith('files[0]', mockFile.blob, mockFile.name) 117 | }) 118 | }) 119 | 120 | test('newError function', () => { 121 | const e = newError('locate', 'text') 122 | expect(e).toBeInstanceOf(Error) 123 | expect(e.message).toBe('discord-hono(locate): text') 124 | }) 125 | 126 | describe('queryStringify', () => { 127 | it('should return empty string when query is undefined', () => { 128 | expect(queryStringify(undefined)).toBe('') 129 | }) 130 | 131 | it('should convert simple key-value pairs to query string', () => { 132 | const query = { key1: 'value1', key2: 'value2' } 133 | expect(queryStringify(query)).toBe('?key1=value1&key2=value2') 134 | }) 135 | 136 | it('should handle numeric values', () => { 137 | const query = { limit: 10, offset: 20 } 138 | expect(queryStringify(query)).toBe('?limit=10&offset=20') 139 | }) 140 | 141 | it('should handle boolean values', () => { 142 | const query = { active: true, deleted: false } 143 | expect(queryStringify(query)).toBe('?active=true&deleted=false') 144 | }) 145 | 146 | it('should ignore undefined values', () => { 147 | const query = { key1: 'value1', key2: undefined, key3: 'value3' } 148 | expect(queryStringify(query)).toBe('?key1=value1&key3=value3') 149 | }) 150 | 151 | it('should encode special characters properly', () => { 152 | const query = { search: 'hello world', filter: 'category=books' } 153 | expect(queryStringify(query)).toBe('?search=hello+world&filter=category%3Dbooks') 154 | }) 155 | 156 | it('should handle empty object', () => { 157 | expect(queryStringify({})).toBe('?') 158 | }) 159 | }) 160 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import type { CustomCallbackData, FileData } from './types' 2 | 3 | export const CUSTOM_ID_SEPARATOR = ';' 4 | 5 | // type any !!!!!!!!! 6 | export const toJSON = (obj: object) => ('toJSON' in obj && typeof obj.toJSON === 'function' ? obj.toJSON() : obj) 7 | 8 | export const prepareData = >( 9 | data: CustomCallbackData | Record[] | undefined, 10 | ) => { 11 | if (!data) return undefined 12 | if (typeof data === 'string') return { content: data } as unknown as T 13 | if (Array.isArray(data)) return data 14 | const { components, embeds, poll, ...rest } = data 15 | // @ts-expect-error Finally, the type is adjusted using an 'as' clause. 16 | if (components) rest.components = Array.isArray(components) ? components.map(toJSON) : toJSON(components) 17 | // @ts-expect-error Finally, the type is adjusted using an 'as' clause. 18 | if (embeds) rest.embeds = embeds.map(toJSON) 19 | // @ts-expect-error Finally, the type is adjusted using an 'as' clause. 20 | if (poll) rest.poll = toJSON(poll) 21 | return rest as T 22 | } 23 | 24 | export const formData = (data?: object, file?: FileData) => { 25 | const body = new FormData() 26 | if (data && Object.keys(data).length > 0) body.append('payload_json', JSON.stringify(data)) 27 | if (file) (Array.isArray(file) ? file : [file]).forEach((f, i) => body.append(`files[${i}]`, f.blob, f.name)) 28 | return body 29 | } 30 | 31 | /** 32 | * new Error(\`discord-hono(${locate}): ${text}\`) 33 | */ 34 | export const newError = (locate: string, text: string) => new Error(`discord-hono(${locate}): ${text}`) 35 | 36 | export const queryStringify = (query: Record | undefined) => { 37 | if (!query) return '' 38 | const queryMap: Record = {} 39 | for (const [key, value] of Object.entries(query)) { 40 | if (value === undefined) continue 41 | queryMap[key] = String(value) 42 | } 43 | return `?${new URLSearchParams(queryMap).toString()}` 44 | } 45 | 46 | // export const isString = (value: unknown): value is string => typeof value === 'string' || value instanceof String 47 | // export const isArray = (value: unknown) => Array.isArray(value) 48 | // export const toArray = (value: T | T[]) => (isArray(value) ? value : [value]) 49 | -------------------------------------------------------------------------------- /src/verify.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi } from 'vitest' 2 | import { verify } from './verify' 3 | 4 | describe('verify', () => { 5 | const body = 'testBody' 6 | const signature = '1234abcd'.repeat(8) // 32バイトの署名 7 | const timestamp = '1234567890' 8 | const publicKey = '0'.repeat(64) // 32バイトの公開鍵(16進数で64文字) 9 | 10 | it('should return false if body, signature, or timestamp is missing', async () => { 11 | expect(await verify(body, null, timestamp, publicKey)).toBe(false) 12 | expect(await verify(body, signature, null, publicKey)).toBe(false) 13 | expect(await verify('', signature, timestamp, publicKey)).toBe(false) 14 | }) 15 | 16 | it('should throw an error if crypto is undefined', async () => { 17 | vi.stubGlobal('crypto', undefined) 18 | await expect(verify(body, signature, timestamp, publicKey)).rejects.toThrow() 19 | }) 20 | 21 | it('should return the result of subtle.verify', async () => { 22 | vi.stubGlobal('crypto', { 23 | subtle: { 24 | verify: vi.fn().mockResolvedValue(false), 25 | importKey: vi.fn().mockResolvedValue('importedKey'), 26 | }, 27 | }) 28 | expect(await verify(body, signature, timestamp, publicKey)).toBe(false) 29 | }) 30 | 31 | describe('correct parameters', () => { 32 | const mockImportKey = vi.fn().mockResolvedValue('importedKey') 33 | const mockVerify = vi.fn().mockResolvedValue(true) 34 | it('should use window.crypto', async () => { 35 | vi.stubGlobal('window', { 36 | crypto: { 37 | subtle: { 38 | verify: mockVerify, 39 | importKey: mockImportKey, 40 | }, 41 | }, 42 | }) 43 | expect(await verify(body, signature, timestamp, publicKey)).toBe(true) 44 | }) 45 | /* 46 | it('should use globalThis.crypto', async () => { 47 | vi.stubGlobal('globalThis', { 48 | crypto: { 49 | subtle: { 50 | verify: vi.fn().mockResolvedValue(true), 51 | importKey: vi.fn().mockResolvedValue('importedKey'), 52 | }, 53 | }, 54 | }) 55 | expect(await verify(body, signature, timestamp, publicKey)).toBe(true) 56 | }) 57 | */ 58 | it('should use crypto', async () => { 59 | vi.stubGlobal('crypto', { 60 | subtle: { 61 | verify: mockVerify, 62 | importKey: mockImportKey, 63 | }, 64 | }) 65 | const result = await verify(body, signature, timestamp, publicKey) 66 | /* 67 | expect(mockImportKey).toHaveBeenCalledWith('raw', expect.any(Uint8Array), { name: 'Ed25519' }, false, ['verify']) 68 | expect(mockVerify).toHaveBeenCalledWith( 69 | { name: 'Ed25519' }, 70 | 'importedKey', 71 | expect.any(Uint8Array), 72 | expect.any(Uint8Array), 73 | ) 74 | */ 75 | expect(result).toBe(true) 76 | }) 77 | }) 78 | }) 79 | -------------------------------------------------------------------------------- /src/verify.ts: -------------------------------------------------------------------------------- 1 | // Reference 2 | // https://gist.github.com/devsnek/77275f6e3f810a9545440931ed314dc1 3 | // https://github.com/discord/discord-interactions-js/blob/main/src/util.ts 4 | 5 | import { newError } from './utils' 6 | 7 | const hex2bin = (hex: string) => { 8 | const len = hex.length 9 | const bin = new Uint8Array(len >> 1) 10 | for (let i = 0; i < len; i += 2) bin[i >> 1] = Number.parseInt(hex.substring(i, i + 2), 16) 11 | return bin 12 | } 13 | 14 | export const verify = async (body: string, signature: string | null, timestamp: string | null, publicKey: string) => { 15 | // biome-ignore lint: not complicated 16 | if (!body || !signature || !timestamp) return false 17 | const { subtle } = 18 | (typeof window !== 'undefined' && window.crypto) || 19 | (typeof globalThis !== 'undefined' && globalThis.crypto) || 20 | (typeof crypto !== 'undefined' && crypto) || 21 | {} 22 | if (subtle === undefined) throw newError('verify', 'crypto') 23 | return await subtle.verify( 24 | { name: 'Ed25519' }, 25 | await subtle.importKey('raw', hex2bin(publicKey), { name: 'Ed25519' }, false, ['verify']), 26 | hex2bin(signature), 27 | new TextEncoder().encode(timestamp + body), 28 | ) 29 | } 30 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Language and Environment */ 4 | "target": "ES2022", 5 | /* Modules */ 6 | "module": "ESNext", 7 | "moduleResolution": "Bundler", 8 | /* JavaScript Support */ 9 | "checkJs": true, 10 | /* Emit */ 11 | "noEmit": true, 12 | /* Interop Constraints */ 13 | "forceConsistentCasingInFileNames": true, 14 | /* Type Checking */ 15 | "strict": true, 16 | "noFallthroughCasesInSwitch": true, 17 | "noImplicitOverride": true, 18 | "noImplicitReturns": true, 19 | "noPropertyAccessFromIndexSignature": true, 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | "erasableSyntaxOnly": true, 23 | /* Completeness */ 24 | "skipLibCheck": true 25 | }, 26 | "include": ["src"] 27 | } 28 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config' 2 | 3 | export default defineConfig({ 4 | test: { 5 | coverage: { 6 | reporter: ['html', 'json'], 7 | }, 8 | }, 9 | }) 10 | --------------------------------------------------------------------------------