├── .release-please-manifest.json ├── .commitlintrc.json ├── .gitignore ├── release-please-config.json ├── jest.config.js ├── renovate.json ├── tsconfig.json ├── biome.json ├── examples ├── message_components.js ├── express_app_with_bodyparser.js ├── nextjs_api.js ├── gcloud_function.js ├── express_app.js └── modal_example.js ├── LICENSE ├── .github └── workflows │ ├── release-please.yaml │ └── ci.yaml ├── package.json ├── src ├── webhooks.ts ├── util.ts ├── __tests__ │ ├── utils │ │ └── SharedTestUtils.ts │ ├── verifyKey.ts │ ├── verifyWebhookEventMiddleware.ts │ └── verifyKeyMiddleware.ts ├── components.ts └── index.ts ├── CHANGELOG.md ├── CONTRIBUTING.md └── README.md /.release-please-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | ".": "4.4.0" 3 | } 4 | -------------------------------------------------------------------------------- /.commitlintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["@commitlint/config-conventional"] 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | node_modules 3 | dist 4 | 5 | [._]*.s[a-w][a-z] 6 | [._]s[a-w][a-z] 7 | -------------------------------------------------------------------------------- /release-please-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "packages": { 3 | ".": { 4 | "release-type": "node", 5 | "package-name": "discord-interactions" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | roots: ['/src'], 3 | transform: { 4 | '^.+\\.tsx?$': 'ts-jest', 5 | }, 6 | testRegex: '(/__tests__/[^/]*)\\.tsx?$', 7 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], 8 | }; 9 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended", 5 | ":disableDependencyDashboard", 6 | ":preserveSemverRanges" 7 | ], 8 | "ignorePaths": [ 9 | "**/node_modules/**" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "module": "commonjs", 5 | "declaration": true, 6 | "strict": true, 7 | "outDir": "dist", 8 | "sourceMap": true, 9 | "esModuleInterop": true 10 | }, 11 | "include": ["src"], 12 | "exclude": ["src/__tests__"] 13 | } 14 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/2.2.5/schema.json", 3 | "files": { 4 | "includes": ["**/src/**/*.ts", "**/examples/**/*.js"] 5 | }, 6 | "assist": { "actions": { "source": { "organizeImports": "on" } } }, 7 | "linter": { 8 | "enabled": true, 9 | "rules": { 10 | "recommended": true 11 | } 12 | }, 13 | "javascript": { 14 | "formatter": { 15 | "quoteStyle": "single" 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /examples/message_components.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const { 3 | InteractionType, 4 | InteractionResponseFlags, 5 | InteractionResponseType, 6 | verifyKeyMiddleware, 7 | } = require('../dist'); 8 | 9 | const app = express(); 10 | 11 | app.post( 12 | '/interactions', 13 | verifyKeyMiddleware(process.env.CLIENT_PUBLIC_KEY), 14 | (req, res) => { 15 | const interaction = req.body; 16 | if (interaction.type === InteractionType.MESSAGE_COMPONENT) { 17 | res.send({ 18 | type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, 19 | data: { 20 | content: 'Hello, you interacted with a component.', 21 | flags: InteractionResponseFlags.EPHEMERAL, 22 | }, 23 | }); 24 | } 25 | }, 26 | ); 27 | 28 | app.listen(8999, () => { 29 | console.log('Example app listening at http://localhost:8999'); 30 | }); 31 | -------------------------------------------------------------------------------- /examples/express_app_with_bodyparser.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const bodyParser = require('body-parser'); 3 | 4 | const { 5 | InteractionType, 6 | InteractionResponseType, 7 | verifyKeyMiddleware, 8 | } = require('../dist'); 9 | 10 | const app = express(); 11 | 12 | app.post( 13 | '/interactions', 14 | verifyKeyMiddleware(process.env.CLIENT_PUBLIC_KEY), 15 | (req, res) => { 16 | const interaction = req.body; 17 | if (interaction.type === InteractionType.APPLICATION_COMMAND) { 18 | res.send({ 19 | type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, 20 | data: { 21 | content: 'Hello world', 22 | }, 23 | }); 24 | } 25 | }, 26 | ); 27 | 28 | // It's best to set up body-parser so that it does NOT apply to interaction 29 | // routes. 30 | app.use(bodyParser.json()); 31 | 32 | app.listen(8999, () => { 33 | console.log('Example app listening at http://localhost:8999'); 34 | }); 35 | -------------------------------------------------------------------------------- /examples/nextjs_api.js: -------------------------------------------------------------------------------- 1 | // ./pages/api/interaction.js 2 | 3 | const { 4 | InteractionType, 5 | InteractionResponseType, 6 | verifyKeyMiddleware, 7 | } = require('../dist'); 8 | 9 | function runMiddleware(req, res, fn) { 10 | return new Promise((resolve, reject) => { 11 | req.header = (name) => req.headers[name.toLowerCase()]; 12 | req.body = JSON.stringify(req.body); 13 | fn(req, res, (result) => { 14 | if (result instanceof Error) return reject(result); 15 | return resolve(result); 16 | }); 17 | }); 18 | } 19 | 20 | export default async function handler(req, res) { 21 | await runMiddleware( 22 | req, 23 | res, 24 | verifyKeyMiddleware(process.env.CLIENT_PUBLIC_KEY), 25 | ); 26 | 27 | const interaction = req.body; 28 | if (interaction.type === InteractionType.APPLICATION_COMMAND) { 29 | res.send({ 30 | type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, 31 | data: { 32 | content: 'Hello world', 33 | }, 34 | }); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /examples/gcloud_function.js: -------------------------------------------------------------------------------- 1 | const { 2 | InteractionResponseType, 3 | InteractionType, 4 | verifyKey, 5 | } = require('discord-interactions'); 6 | 7 | const CLIENT_PUBLIC_KEY = process.env.CLIENT_PUBLIC_KEY; 8 | 9 | module.exports.myInteraction = async (req, res) => { 10 | // Verify the request 11 | const signature = req.get('X-Signature-Ed25519'); 12 | const timestamp = req.get('X-Signature-Timestamp'); 13 | const isValidRequest = await verifyKey( 14 | req.rawBody, 15 | signature, 16 | timestamp, 17 | CLIENT_PUBLIC_KEY, 18 | ); 19 | if (!isValidRequest) { 20 | return res.status(401).end('Bad request signature'); 21 | } 22 | 23 | // Handle the payload 24 | const interaction = req.body; 25 | if (interaction && interaction.type === InteractionType.APPLICATION_COMMAND) { 26 | res.send({ 27 | type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, 28 | data: { 29 | content: `You used: ${interaction.data.name}`, 30 | }, 31 | }); 32 | } else { 33 | res.send({ 34 | type: InteractionResponseType.PONG, 35 | }); 36 | } 37 | }; 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Ian Webster 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 | -------------------------------------------------------------------------------- /examples/express_app.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const util = require('node:util'); 3 | const { 4 | InteractionType, 5 | InteractionResponseType, 6 | verifyKeyMiddleware, 7 | verifyWebhookEventMiddleware, 8 | } = require('../dist'); 9 | 10 | const app = express(); 11 | 12 | app.post( 13 | '/interactions', 14 | verifyKeyMiddleware(process.env.CLIENT_PUBLIC_KEY), 15 | (req, res) => { 16 | const interaction = req.body; 17 | if (interaction.type === InteractionType.APPLICATION_COMMAND) { 18 | res.send({ 19 | type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, 20 | data: { 21 | content: 'Hello world', 22 | }, 23 | }); 24 | } 25 | }, 26 | ); 27 | 28 | app.post( 29 | '/events', 30 | verifyWebhookEventMiddleware(process.env.CLIENT_PUBLIC_KEY), 31 | (req, _res) => { 32 | console.log('📨 Event Received!'); 33 | console.log( 34 | util.inspect(req.body, { showHidden: false, colors: true, depth: null }), 35 | ); 36 | }, 37 | ); 38 | 39 | // Simple health check, to also make it easy to check if the app is up and running 40 | app.get('/health', (_req, res) => { 41 | res.send('ok'); 42 | }); 43 | 44 | app.listen(8999, () => { 45 | console.log('Example app listening at http://localhost:8999'); 46 | }); 47 | -------------------------------------------------------------------------------- /.github/workflows/release-please.yaml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - main 5 | name: release 6 | env: 7 | NODE: 22 8 | jobs: 9 | release-please: 10 | if: github.repository == 'discord/discord-interactions-js' 11 | runs-on: ubuntu-latest 12 | permissions: 13 | contents: write 14 | pull-requests: write 15 | outputs: 16 | release_created: ${{ steps.release.outputs.release_created }} 17 | tag_name: ${{ steps.release.outputs.tag_name }} 18 | steps: 19 | - uses: googleapis/release-please-action@v4 20 | id: release 21 | with: 22 | token: ${{ secrets.GITHUB_TOKEN }} 23 | publish: 24 | if: needs.release-please.outputs.release_created 25 | runs-on: ubuntu-latest 26 | needs: release-please 27 | permissions: 28 | contents: write 29 | id-token: write 30 | steps: 31 | - uses: actions/checkout@v5 32 | - uses: actions/setup-node@v6 33 | with: 34 | node-version: ${{ env.NODE }} 35 | cache: npm 36 | registry-url: 'https://registry.npmjs.org' 37 | - run: npm install -g npm@latest 38 | - run: npm ci 39 | - run: npm run build 40 | - run: npm publish --provenance --access public 41 | env: 42 | NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 43 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "discord-interactions", 3 | "version": "4.4.0", 4 | "description": "Helpers for discord interactions", 5 | "main": "dist/index.js", 6 | "license": "MIT", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/discord/discord-interactions-js.git" 10 | }, 11 | "engines": { 12 | "node": ">=18.4.0" 13 | }, 14 | "bugs": { 15 | "url": "https://github.com/discord/discord-interactions-js/issues" 16 | }, 17 | "homepage": "https://github.com/discord/discord-interactions-js", 18 | "files": [ 19 | "dist/**/*" 20 | ], 21 | "types": "dist/index.d.ts", 22 | "keywords": [ 23 | "discord" 24 | ], 25 | "scripts": { 26 | "prepare": "npm run build", 27 | "build": "tsc", 28 | "build:watch": "tsc --watch", 29 | "fix": "biome check --apply .", 30 | "lint": "biome check .", 31 | "test": "jest --verbose" 32 | }, 33 | "devDependencies": { 34 | "@biomejs/biome": "^2.2.5", 35 | "@commitlint/cli": "^20.0.0", 36 | "@commitlint/config-conventional": "^20.0.0", 37 | "@types/express": "^4.17.9", 38 | "@types/jest": "^30.0.0", 39 | "@types/node": "^24.0.0", 40 | "body-parser": "^2.0.0", 41 | "express": "^4.17.1", 42 | "jest": "^30.2.0", 43 | "ts-jest": "^29.4.4", 44 | "typescript": "^5.0.0" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /examples/modal_example.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const { 3 | InteractionType, 4 | InteractionResponseType, 5 | verifyKeyMiddleware, 6 | } = require('../dist'); 7 | 8 | const app = express(); 9 | 10 | app.post( 11 | '/interactions', 12 | verifyKeyMiddleware(process.env.CLIENT_PUBLIC_KEY), 13 | (req, res) => { 14 | const interaction = req.body; 15 | if (interaction.type === InteractionType.APPLICATION_COMMAND) { 16 | res.send({ 17 | type: InteractionResponseType.MODAL, 18 | data: { 19 | title: 'Test', 20 | custom_id: 'test-modal', 21 | components: [ 22 | { 23 | type: 1, 24 | components: [ 25 | { 26 | type: 4, 27 | style: 1, 28 | label: 'Short Input', 29 | custom_id: 'short-input', 30 | placeholder: 'Short Input', 31 | }, 32 | ], 33 | }, 34 | { 35 | type: 1, 36 | components: [ 37 | { 38 | type: 4, 39 | style: 1, 40 | label: 'Paragraph Input', 41 | custom_id: 'paragraph-input', 42 | placeholder: 'Paragraph Input', 43 | required: false, 44 | }, 45 | ], 46 | }, 47 | ], 48 | }, 49 | }); 50 | } 51 | }, 52 | ); 53 | 54 | app.listen(8999, () => { 55 | console.log('Example app listening at http://localhost:8999'); 56 | }); 57 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - main 5 | pull_request: 6 | pull_request_target: 7 | workflow_dispatch: 8 | name: ci 9 | env: 10 | NODE: 22 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | node: [18, 20, 22] 18 | steps: 19 | - uses: actions/checkout@v5 20 | - uses: actions/setup-node@v6 21 | with: 22 | node-version: ${{ matrix.node }} 23 | cache: npm 24 | - run: npm ci 25 | - run: npm test 26 | lint: 27 | runs-on: ubuntu-latest 28 | steps: 29 | - uses: actions/checkout@v5 30 | - uses: actions/setup-node@v6 31 | with: 32 | node-version: ${{ env.NODE }} 33 | cache: npm 34 | - run: npm ci 35 | - run: npm run lint 36 | conventional-commits: 37 | runs-on: ubuntu-latest 38 | if: github.event_name == 'pull_request' 39 | steps: 40 | - uses: actions/checkout@v5 41 | with: 42 | fetch-depth: 0 43 | - uses: actions/setup-node@v6 44 | with: 45 | node-version: ${{ env.NODE }} 46 | cache: npm 47 | - run: npm ci 48 | - name: Validate PR commits with commitlint 49 | run: npx commitlint --from ${{ github.event.pull_request.base.sha }} --to ${{ github.event.pull_request.head.sha }} --verbose 50 | -------------------------------------------------------------------------------- /src/webhooks.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @see {@link https://discord.com/developers/docs/events/webhook-events#webhook-types} 3 | */ 4 | export enum WebhookType { 5 | /** 6 | * A ping. 7 | */ 8 | PING = 0, 9 | /** 10 | * A webhook event. 11 | */ 12 | EVENT = 1, 13 | } 14 | 15 | /** 16 | * Events to which an app can subscribe to 17 | * @see {@link https://discord.com/developers/docs/events/webhook-events#event-types} 18 | */ 19 | export enum WebhookEventType { 20 | /** 21 | * Event sent when an app was authorized by a user to a server or their account. 22 | */ 23 | APPLICATION_AUTHORIZED = 'APPLICATION_AUTHORIZED', 24 | /** 25 | * Event sent when an app was deauthorized by a user. 26 | */ 27 | APPLICATION_DEAUTHORIZED = 'APPLICATION_DEAUTHORIZED', 28 | /** 29 | * Event sent when an entitlement was created. 30 | */ 31 | ENTITLEMENT_CREATE = 'ENTITLEMENT_CREATE', 32 | /** 33 | * Event sent when a user was added to a Quest. 34 | */ 35 | QUEST_USER_ENROLLMENT = 'QUEST_USER_ENROLLMENT', 36 | /** 37 | * Event sent when a message is created in a lobby. 38 | */ 39 | LOBBY_MESSAGE_CREATE = 'LOBBY_MESSAGE_CREATE', 40 | /** 41 | * Event sent when a message is updated in a lobby. 42 | */ 43 | LOBBY_MESSAGE_UPDATE = 'LOBBY_MESSAGE_UPDATE', 44 | /** 45 | * Event sent when a message is deleted from a lobby. 46 | */ 47 | LOBBY_MESSAGE_DELETE = 'LOBBY_MESSAGE_DELETE', 48 | /** 49 | * Event sent when a direct message is created during an active Social SDK session. 50 | */ 51 | GAME_DIRECT_MESSAGE_CREATE = 'GAME_DIRECT_MESSAGE_CREATE', 52 | /** 53 | * Event sent when a direct message is updated during an active Social SDK session. 54 | */ 55 | GAME_DIRECT_MESSAGE_UPDATE = 'GAME_DIRECT_MESSAGE_UPDATE', 56 | /** 57 | * Event sent when a direct message is deleted during an active Social SDK session. 58 | */ 59 | GAME_DIRECT_MESSAGE_DELETE = 'GAME_DIRECT_MESSAGE_DELETE', 60 | } 61 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Based on environment, get a reference to the Web Crypto API's SubtleCrypto interface. 3 | * @returns An implementation of the Web Crypto API's SubtleCrypto interface. 4 | */ 5 | function getSubtleCrypto(): SubtleCrypto { 6 | if (typeof window !== 'undefined' && window.crypto) { 7 | return window.crypto.subtle; 8 | } 9 | if (typeof globalThis !== 'undefined' && globalThis.crypto) { 10 | return globalThis.crypto.subtle; 11 | } 12 | if (typeof crypto !== 'undefined') { 13 | return crypto.subtle; 14 | } 15 | if (typeof require === 'function') { 16 | // Cloudflare Workers are doing what appears to be a regex check to look and 17 | // warn for this pattern. We should never get here in a Cloudflare Worker, so 18 | // I am being coy to avoid detection and a warning. 19 | const cryptoPackage = 'node:crypto'; 20 | const crypto = require(cryptoPackage); 21 | return crypto.webcrypto.subtle; 22 | } 23 | throw new Error('No Web Crypto API implementation found'); 24 | } 25 | 26 | export const subtleCrypto = getSubtleCrypto(); 27 | 28 | /** 29 | * Converts different types to Uint8Array. 30 | * 31 | * @param value - Value to convert. Strings are parsed as hex. 32 | * @param format - Format of value. Valid options: 'hex'. Defaults to utf-8. 33 | * @returns Value in Uint8Array form. 34 | */ 35 | export function valueToUint8Array( 36 | value: Uint8Array | ArrayBuffer | Buffer | string, 37 | format?: string, 38 | ): Uint8Array { 39 | if (value == null) { 40 | return new Uint8Array(); 41 | } 42 | if (typeof value === 'string') { 43 | if (format === 'hex') { 44 | const matches = value.match(/.{1,2}/g); 45 | if (matches == null) { 46 | throw new Error('Value is not a valid hex string'); 47 | } 48 | const hexVal = matches.map((byte: string) => Number.parseInt(byte, 16)); 49 | return new Uint8Array(hexVal); 50 | } 51 | 52 | return new TextEncoder().encode(value); 53 | } 54 | try { 55 | if (Buffer.isBuffer(value)) { 56 | return new Uint8Array(value); 57 | } 58 | } catch (_ex) { 59 | // Runtime doesn't have Buffer 60 | } 61 | if (value instanceof ArrayBuffer) { 62 | return new Uint8Array(value); 63 | } 64 | if (value instanceof Uint8Array) { 65 | return value; 66 | } 67 | throw new Error( 68 | 'Unrecognized value type, must be one of: string, Buffer, ArrayBuffer, Uint8Array', 69 | ); 70 | } 71 | 72 | /** 73 | * Merge two arrays. 74 | * 75 | * @param arr1 - First array 76 | * @param arr2 - Second array 77 | * @returns Concatenated arrays 78 | */ 79 | export function concatUint8Arrays( 80 | arr1: Uint8Array, 81 | arr2: Uint8Array, 82 | ): Uint8Array { 83 | const merged = new Uint8Array(arr1.length + arr2.length); 84 | merged.set(arr1); 85 | merged.set(arr2, arr1.length); 86 | return merged; 87 | } 88 | -------------------------------------------------------------------------------- /src/__tests__/utils/SharedTestUtils.ts: -------------------------------------------------------------------------------- 1 | import { subtleCrypto } from '../../util'; 2 | 3 | // Example PING request body 4 | export const pingRequestBody = JSON.stringify({ 5 | id: '787053080478613555', 6 | token: 'ThisIsATokenFromDiscordThatIsVeryLong', 7 | type: 1, 8 | version: 1, 9 | }); 10 | // Example APPLICATION_COMMAND request body 11 | export const applicationCommandRequestBody = JSON.stringify({ 12 | id: '787053080478613556', 13 | token: 'ThisIsATokenFromDiscordThatIsVeryLong', 14 | type: 2, 15 | version: 1, 16 | data: { 17 | id: '787053080478613554', 18 | name: 'test', 19 | }, 20 | }); 21 | // Example MESSAGE_COMPONENT request body 22 | export const messageComponentRequestBody = JSON.stringify({ 23 | id: '787053080478613555', 24 | token: 'ThisIsATokenFromDiscordThatIsVeryLong', 25 | type: 3, 26 | version: 1, 27 | data: { 28 | custom_id: 'test', 29 | component_type: 2, 30 | }, 31 | }); 32 | // Example APPLICATION_COMMAND_AUTOCOMPLETE request body 33 | export const autocompleteRequestBody = JSON.stringify({ 34 | id: '787053080478613555', 35 | token: 'ThisIsATokenFromDiscordThatIsVeryLong', 36 | type: 4, 37 | version: 1, 38 | data: { 39 | id: '787053080478613554', 40 | name: 'test', 41 | type: 1, 42 | version: '787053080478613554', 43 | options: [ 44 | { 45 | type: 3, 46 | name: 'option', 47 | value: 'first_option', 48 | focused: true, 49 | }, 50 | ], 51 | }, 52 | }); 53 | 54 | export async function generateKeyPair() { 55 | const keyPair = await subtleCrypto.generateKey( 56 | { 57 | name: 'ed25519', 58 | namedCurve: 'ed25519', 59 | }, 60 | true, 61 | ['sign', 'verify'], 62 | ); 63 | return keyPair; 64 | } 65 | 66 | export type SignedRequest = { 67 | body: string; 68 | signature: string; 69 | timestamp: string; 70 | }; 71 | export type ExampleRequestResponse = { 72 | status: number; 73 | body: string; 74 | }; 75 | 76 | export async function signRequestWithKeyPair( 77 | body: string, 78 | privateKey: CryptoKey, 79 | ) { 80 | const timestamp = String(Math.round(Date.now() / 1000)); 81 | const signature = await subtleCrypto.sign( 82 | { 83 | name: 'ed25519', 84 | }, 85 | privateKey, 86 | Uint8Array.from(Buffer.concat([Buffer.from(timestamp), Buffer.from(body)])), 87 | ); 88 | return { 89 | body, 90 | signature: Buffer.from(signature).toString('hex'), 91 | timestamp, 92 | }; 93 | } 94 | 95 | export async function sendExampleRequest( 96 | url: string, 97 | headers: { [key: string]: string }, 98 | body: string, 99 | ): Promise { 100 | const response = await fetch(url, { 101 | method: 'POST', 102 | headers, 103 | body, 104 | }); 105 | return { 106 | status: response.status, 107 | body: await response.text(), 108 | }; 109 | } 110 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [4.4.0](https://github.com/discord/discord-interactions-js/compare/discord-interactions-v4.3.0...discord-interactions-v4.4.0) (2025-10-09) 4 | 5 | 6 | ### Features 7 | 8 | * add ButtonStyleTypes.Premium ([#55](https://github.com/discord/discord-interactions-js/issues/55)) ([321f55e](https://github.com/discord/discord-interactions-js/commit/321f55e1390ef72f38c47b5d5bfed99e2fc4fdd8)) 9 | * add webhook enums ([#84](https://github.com/discord/discord-interactions-js/issues/84)) ([72dd54c](https://github.com/discord/discord-interactions-js/commit/72dd54ca26dbcec037fa7c091d26852c91471ccd)) 10 | * add webhook event middleware and expand Discord webhook support ([#100](https://github.com/discord/discord-interactions-js/issues/100)) ([64d80fb](https://github.com/discord/discord-interactions-js/commit/64d80fbc5da5c2aced288891d96abb8cc3412734)) 11 | * export addition types including ActionRow ([#40](https://github.com/discord/discord-interactions-js/issues/40)) ([748e241](https://github.com/discord/discord-interactions-js/commit/748e2415c5282da25ccf4fea47323a3c2dd3e6be)) 12 | * Select menus in modals, and other fixes ([#96](https://github.com/discord/discord-interactions-js/issues/96)) ([87eb06a](https://github.com/discord/discord-interactions-js/commit/87eb06ae14bd57f9894b673057e8746c04a88898)) 13 | 14 | 15 | ### Bug Fixes 16 | 17 | * **deps:** remove dependency on node-fetch ([#70](https://github.com/discord/discord-interactions-js/issues/70)) ([a5710cb](https://github.com/discord/discord-interactions-js/commit/a5710cb497656e79b7e1dc02cdb6ff379556981f)) 18 | * Fix types to take into account selects ([#86](https://github.com/discord/discord-interactions-js/issues/86)) ([0e15669](https://github.com/discord/discord-interactions-js/commit/0e156691ff40af420e43a11cbb9cb3e29b461f17)) 19 | * prevent stalling requests ([#10](https://github.com/discord/discord-interactions-js/issues/10)) ([c3965c3](https://github.com/discord/discord-interactions-js/commit/c3965c39a8ddc584a9f42e39599a4f19b885aeb8)) 20 | * Remove console.error on verifyKey failure; simply return false ([#46](https://github.com/discord/discord-interactions-js/issues/46)) ([f341dd4](https://github.com/discord/discord-interactions-js/commit/f341dd4337d78060c8411ba6c6aeec0a210c2fd0)) 21 | * remove dependency on tweetnacl ([#73](https://github.com/discord/discord-interactions-js/issues/73)) ([69499c9](https://github.com/discord/discord-interactions-js/commit/69499c93931a11a498ab0b42aee0c49081b8f6a6)) 22 | * reset version no ([#112](https://github.com/discord/discord-interactions-js/issues/112)) ([11cfbe3](https://github.com/discord/discord-interactions-js/commit/11cfbe36b4c1407433739992c49af757c3a1bf9c)) 23 | * trigger a release ([#107](https://github.com/discord/discord-interactions-js/issues/107)) ([73c4a11](https://github.com/discord/discord-interactions-js/commit/73c4a11d449801b3eb22330e76cfa358f16ab58e)) 24 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to discord-interactions-js 2 | 3 | Thank you for your interest in contributing to discord-interactions-js! 4 | 5 | ## Development Setup 6 | 7 | 1. Fork and clone the repository 8 | 2. Install dependencies: 9 | ```bash 10 | npm install 11 | ``` 12 | 3. Build the project: 13 | ```bash 14 | npm run build 15 | ``` 16 | 4. Run tests: 17 | ```bash 18 | npm test 19 | ``` 20 | 5. Run linting: 21 | ```bash 22 | npm run lint 23 | ``` 24 | 25 | ## Code Quality 26 | 27 | - We use [Biome](https://biomejs.dev/) for linting and formatting 28 | - Run `npm run fix` to automatically fix linting issues 29 | - All tests must pass before merging 30 | - Code is tested against Node.js 18, 20, and 22 31 | 32 | ## Release Process 33 | 34 | This project uses [release-please](https://github.com/googleapis/release-please) to automate releases. The process is fully automated based on conventional commit messages. 35 | 36 | ### Conventional Commits 37 | 38 | We follow the [Conventional Commits](https://www.conventionalcommits.org/) specification. Your commit messages should be structured as follows: 39 | 40 | ``` 41 | : 42 | 43 | [optional body] 44 | 45 | [optional footer(s)] 46 | ``` 47 | 48 | **Common types:** 49 | - `feat:` - A new feature (triggers a minor version bump) 50 | - `fix:` - A bug fix (triggers a patch version bump) 51 | - `docs:` - Documentation changes 52 | - `chore:` - Maintenance tasks, dependency updates 53 | - `refactor:` - Code refactoring without behavior changes 54 | - `test:` - Adding or updating tests 55 | - `perf:` - Performance improvements 56 | 57 | **Breaking changes:** 58 | Add `BREAKING CHANGE:` in the commit body or append `!` after the type to trigger a major version bump: 59 | ``` 60 | feat!: change API signature 61 | 62 | BREAKING CHANGE: The verifyKey function now requires a second parameter 63 | ``` 64 | 65 | ### How Releases Work 66 | 67 | 1. **Development**: 68 | - Create a PR with your changes 69 | - Use conventional commit messages in your commits 70 | - Once approved, merge to `main` 71 | 72 | 2. **Automated Release PR**: 73 | - release-please automatically creates/updates a "release PR" 74 | - This PR aggregates all changes since the last release 75 | - It updates the version in `package.json` and generates/updates `CHANGELOG.md` 76 | - The PR stays open until you're ready to release 77 | 78 | 3. **Publishing**: 79 | - When ready to release, merge the release PR 80 | - release-please creates a GitHub release with the new version tag 81 | - The package is automatically published to npm with provenance 82 | - A new release PR will be created for the next release cycle 83 | 84 | ### Release Notes 85 | 86 | Release notes are automatically generated from commit messages. To ensure high-quality release notes: 87 | - Write clear, descriptive commit messages 88 | - Focus on the "what" and "why" in the description 89 | - Use the commit body for additional context if needed 90 | 91 | ### Manual Version Control 92 | 93 | In rare cases where you need to manually adjust versions: 94 | 1. Edit `.release-please-manifest.json` to set the desired version 95 | 2. Commit and push to `main` 96 | 3. release-please will use this as the base for the next release 97 | 98 | ## Questions? 99 | 100 | If you have questions about the contributing process, feel free to open an issue for discussion. 101 | -------------------------------------------------------------------------------- /src/__tests__/verifyKey.ts: -------------------------------------------------------------------------------- 1 | import { verifyKey } from '../index'; 2 | import { 3 | generateKeyPair, 4 | pingRequestBody, 5 | signRequestWithKeyPair, 6 | } from './utils/SharedTestUtils'; 7 | 8 | describe('verify key method', () => { 9 | let validKeyPair: CryptoKeyPair; 10 | let invalidKeyPair: CryptoKeyPair; 11 | 12 | beforeAll(async () => { 13 | validKeyPair = await generateKeyPair(); 14 | invalidKeyPair = await generateKeyPair(); 15 | }); 16 | 17 | it('valid ping request', async () => { 18 | // Sign and verify a valid ping request 19 | const signedRequest = await signRequestWithKeyPair( 20 | pingRequestBody, 21 | validKeyPair.privateKey, 22 | ); 23 | const isValid = await verifyKey( 24 | signedRequest.body, 25 | signedRequest.signature, 26 | signedRequest.timestamp, 27 | validKeyPair.publicKey, 28 | ); 29 | expect(isValid).toBe(true); 30 | }); 31 | 32 | it('valid application command', async () => { 33 | // Sign and verify a valid application command request 34 | const signedRequest = await signRequestWithKeyPair( 35 | pingRequestBody, 36 | validKeyPair.privateKey, 37 | ); 38 | const isValid = await verifyKey( 39 | signedRequest.body, 40 | signedRequest.signature, 41 | signedRequest.timestamp, 42 | validKeyPair.publicKey, 43 | ); 44 | expect(isValid).toBe(true); 45 | }); 46 | 47 | it('valid message component', async () => { 48 | // Sign and verify a valid message component request 49 | const signedRequest = await signRequestWithKeyPair( 50 | pingRequestBody, 51 | validKeyPair.privateKey, 52 | ); 53 | const isValid = await verifyKey( 54 | signedRequest.body, 55 | signedRequest.signature, 56 | signedRequest.timestamp, 57 | validKeyPair.publicKey, 58 | ); 59 | expect(isValid).toBe(true); 60 | }); 61 | 62 | it('valid autocomplete', async () => { 63 | // Sign and verify a valid autocomplete request 64 | const signedRequest = await signRequestWithKeyPair( 65 | pingRequestBody, 66 | validKeyPair.privateKey, 67 | ); 68 | const isValid = await verifyKey( 69 | signedRequest.body, 70 | signedRequest.signature, 71 | signedRequest.timestamp, 72 | validKeyPair.publicKey, 73 | ); 74 | expect(isValid).toBe(true); 75 | }); 76 | 77 | it('invalid key', async () => { 78 | // Sign a request with a different private key and verify with the valid public key 79 | const signedRequest = await signRequestWithKeyPair( 80 | pingRequestBody, 81 | invalidKeyPair.privateKey, 82 | ); 83 | const isValid = await verifyKey( 84 | signedRequest.body, 85 | signedRequest.signature, 86 | signedRequest.timestamp, 87 | validKeyPair.publicKey, 88 | ); 89 | expect(isValid).toBe(false); 90 | }); 91 | 92 | it('invalid body', async () => { 93 | // Sign a valid request and verify with an invalid body 94 | const signedRequest = await signRequestWithKeyPair( 95 | pingRequestBody, 96 | validKeyPair.privateKey, 97 | ); 98 | const isValid = await verifyKey( 99 | 'example invalid body', 100 | signedRequest.signature, 101 | signedRequest.timestamp, 102 | validKeyPair.publicKey, 103 | ); 104 | expect(isValid).toBe(false); 105 | }); 106 | 107 | it('invalid signature', async () => { 108 | // Sign a valid request and verify with an invalid signature 109 | const signedRequest = await signRequestWithKeyPair( 110 | pingRequestBody, 111 | validKeyPair.privateKey, 112 | ); 113 | const isValid = await verifyKey( 114 | signedRequest.body, 115 | 'example invalid signature', 116 | signedRequest.timestamp, 117 | validKeyPair.publicKey, 118 | ); 119 | expect(isValid).toBe(false); 120 | }); 121 | 122 | it('invalid timestamp', async () => { 123 | // Sign a valid request and verify with an invalid timestamp 124 | const signedRequest = await signRequestWithKeyPair( 125 | pingRequestBody, 126 | validKeyPair.privateKey, 127 | ); 128 | const isValid = await verifyKey( 129 | signedRequest.body, 130 | signedRequest.signature, 131 | String(Math.round(Date.now() / 1000) - 10000), 132 | validKeyPair.publicKey, 133 | ); 134 | expect(isValid).toBe(false); 135 | }); 136 | 137 | it('supports array buffers', async () => { 138 | const signedRequest = await signRequestWithKeyPair( 139 | pingRequestBody, 140 | validKeyPair.privateKey, 141 | ); 142 | const encoder = new TextEncoder(); 143 | const bodyEncoded = encoder.encode(signedRequest.body); 144 | const isValid = await verifyKey( 145 | bodyEncoded.buffer, 146 | signedRequest.signature, 147 | signedRequest.timestamp, 148 | validKeyPair.publicKey, 149 | ); 150 | expect(isValid).toBe(true); 151 | }); 152 | 153 | it('invalid body data type', async () => { 154 | const signedRequest = await signRequestWithKeyPair( 155 | pingRequestBody, 156 | validKeyPair.privateKey, 157 | ); 158 | const invalidBody = {} as unknown as string; 159 | const isValid = await verifyKey( 160 | invalidBody, 161 | signedRequest.signature, 162 | signedRequest.timestamp, 163 | validKeyPair.publicKey, 164 | ); 165 | expect(isValid).toBe(false); 166 | }); 167 | }); 168 | -------------------------------------------------------------------------------- /src/components.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * The type of component 3 | * @see {@link https://discord.com/developers/docs/components/reference#what-is-a-component} 4 | */ 5 | export enum MessageComponentTypes { 6 | ACTION_ROW = 1, 7 | BUTTON = 2, 8 | STRING_SELECT = 3, 9 | INPUT_TEXT = 4, 10 | USER_SELECT = 5, 11 | ROLE_SELECT = 6, 12 | MENTIONABLE_SELECT = 7, 13 | CHANNEL_SELECT = 8, 14 | SECTION = 9, 15 | TEXT_DISPLAY = 10, 16 | THUMBNAIL = 11, 17 | MEDIA_GALLERY = 12, 18 | FILE = 13, 19 | SEPARATOR = 14, 20 | CONTAINER = 17, 21 | LABEL = 18, 22 | } 23 | 24 | export type MessageComponent = 25 | | ActionRow 26 | | Button 27 | | StringSelect 28 | | UserSelect 29 | | RoleSelect 30 | | MentionableSelect 31 | | ChannelSelect 32 | | TextInput 33 | | Section 34 | | TextDisplay 35 | | Thumbnail 36 | | MediaGallery 37 | | FileComponent 38 | | Separator 39 | | Container 40 | | Label; 41 | 42 | export enum ButtonStyleTypes { 43 | PRIMARY = 1, 44 | SECONDARY = 2, 45 | SUCCESS = 3, 46 | DANGER = 4, 47 | LINK = 5, 48 | PREMIUM = 6, 49 | } 50 | 51 | interface BaseComponent { 52 | type: MessageComponentTypes; 53 | id?: number; 54 | } 55 | 56 | interface BaseButton extends BaseComponent { 57 | disabled?: boolean; 58 | type: MessageComponentTypes.BUTTON; 59 | } 60 | 61 | interface LabeledButton extends BaseButton { 62 | emoji?: Pick; 63 | label: string; 64 | } 65 | 66 | interface PremiumButton extends BaseButton { 67 | sku_id: string; 68 | style: ButtonStyleTypes.PREMIUM; 69 | } 70 | 71 | interface LinkButton extends LabeledButton { 72 | url: string; 73 | style: ButtonStyleTypes.LINK; 74 | } 75 | 76 | interface CustomButton extends LabeledButton { 77 | custom_id: string; 78 | style: 79 | | ButtonStyleTypes.PRIMARY 80 | | ButtonStyleTypes.SECONDARY 81 | | ButtonStyleTypes.SUCCESS 82 | | ButtonStyleTypes.DANGER; 83 | } 84 | 85 | /** 86 | * Button component 87 | * @see {@link https://discord.com/developers/docs/components/reference#button} 88 | */ 89 | export type Button = CustomButton | LinkButton | PremiumButton; 90 | 91 | /** 92 | * Action row component 93 | * @see {@link https://discord.com/developers/docs/components/reference#action-row} 94 | */ 95 | export type ActionRow = BaseComponent & { 96 | type: MessageComponentTypes.ACTION_ROW; 97 | components: Array< 98 | | Button 99 | | StringSelect 100 | | UserSelect 101 | | RoleSelect 102 | | MentionableSelect 103 | | ChannelSelect 104 | | TextInput 105 | >; 106 | }; 107 | 108 | export type SelectComponentType = 109 | | MessageComponentTypes.STRING_SELECT 110 | | MessageComponentTypes.USER_SELECT 111 | | MessageComponentTypes.ROLE_SELECT 112 | | MessageComponentTypes.MENTIONABLE_SELECT 113 | | MessageComponentTypes.CHANNEL_SELECT; 114 | 115 | // This parent type is to simplify the individual selects while keeping descriptive generated type hints 116 | export type SelectMenu = BaseComponent & { 117 | type: T; 118 | custom_id: string; 119 | placeholder?: string; 120 | min_values?: number; 121 | max_values?: number; 122 | disabled?: boolean; 123 | required?: boolean; 124 | }; 125 | 126 | /** 127 | * Text select menu component 128 | * @see {@link https://discord.com/developers/docs/components/reference#string-select} 129 | */ 130 | export type StringSelect = SelectMenu & { 131 | options: StringSelectOption[]; 132 | }; 133 | 134 | export type StringSelectOption = { 135 | label: string; 136 | value: string; 137 | description?: string; 138 | emoji?: Pick; 139 | default?: boolean; 140 | }; 141 | 142 | /** 143 | * User select menu component 144 | * @see {@link https://discord.com/developers/docs/components/reference#user-select} 145 | */ 146 | export type UserSelect = SelectMenu; 147 | 148 | /** 149 | * Role select menu component 150 | * @see {@link https://discord.com/developers/docs/components/reference#role-select} 151 | */ 152 | export type RoleSelect = SelectMenu; 153 | 154 | /** 155 | * Mentionable (role & user) select menu component 156 | * @see {@link https://discord.com/developers/docs/components/reference#mentionable-select} 157 | */ 158 | export type MentionableSelect = 159 | SelectMenu; 160 | 161 | /** 162 | * Channel select menu component 163 | * @see {@link https://discord.com/developers/docs/components/reference#channel-select} 164 | */ 165 | export type ChannelSelect = SelectMenu & { 166 | channel_types?: ChannelTypes[]; 167 | }; 168 | 169 | export enum ChannelTypes { 170 | GUILD_TEXT = 0, 171 | DM = 1, 172 | GUILD_VOICE = 2, 173 | GROUP_DM = 3, 174 | GUILD_CATEGORY = 4, 175 | GUILD_ANNOUNCEMENT = 5, 176 | GUILD_STORE = 6, 177 | ANNOUNCEMENT_THREAD = 10, 178 | PUBLIC_THREAD = 11, 179 | PRIVATE_THREAD = 12, 180 | GUILD_STAGE_VOICE = 13, 181 | GUILD_DIRECTORY = 14, 182 | GUILD_FORUM = 15, 183 | GUILD_MEDIA = 16, 184 | } 185 | 186 | /** 187 | * Text input component 188 | * @see {@link https://discord.com/developers/docs/components/reference#text-input} 189 | */ 190 | export type TextInput = { 191 | type: MessageComponentTypes.INPUT_TEXT; 192 | custom_id: string; 193 | style: TextStyleTypes.SHORT | TextStyleTypes.PARAGRAPH; 194 | label?: string; 195 | min_length?: number; 196 | max_length?: number; 197 | required?: boolean; 198 | value?: string; 199 | placeholder?: string; 200 | }; 201 | 202 | /** @deprecated `InputText` has been renamed to `TextInput` */ 203 | export type InputText = TextInput; 204 | 205 | export enum TextStyleTypes { 206 | SHORT = 1, 207 | PARAGRAPH = 2, 208 | } 209 | 210 | export type EmojiInfo = { 211 | name: string | undefined; 212 | id: string | undefined; 213 | // Should define the user object in future 214 | // biome-ignore lint/suspicious/noExplicitAny: user object not fully typed yet 215 | user?: { [key: string]: any }; 216 | roles?: string[]; 217 | require_colons?: boolean; 218 | managed?: boolean; 219 | available?: boolean; 220 | animated?: boolean; 221 | }; 222 | 223 | /** 224 | * Section component 225 | * @see {@link https://discord.com/developers/docs/components/reference#section} 226 | */ 227 | export interface Section extends BaseComponent { 228 | type: MessageComponentTypes.SECTION; 229 | components: TextDisplay[] & { length: 1 | 2 | 3 }; 230 | accessory: Thumbnail | Button; 231 | } 232 | 233 | /** 234 | * Text display component 235 | * @see {@link https://discord.com/developers/docs/components/reference#text-display} 236 | */ 237 | export interface TextDisplay extends BaseComponent { 238 | type: MessageComponentTypes.TEXT_DISPLAY; 239 | content: string; 240 | } 241 | 242 | /** 243 | * Thumbnail component 244 | * @see {@link https://discord.com/developers/docs/components/reference#thumbnail} 245 | */ 246 | export interface Thumbnail extends BaseComponent { 247 | type: MessageComponentTypes.THUMBNAIL; 248 | media: UnfurledMediaItem; 249 | description?: string; 250 | spoiler?: boolean; 251 | } 252 | 253 | /** 254 | * Media gallery component 255 | * @see {@link https://discord.com/developers/docs/components/reference#media-gallery} 256 | */ 257 | export interface MediaGallery extends BaseComponent { 258 | type: MessageComponentTypes.MEDIA_GALLERY; 259 | items: Array; 260 | } 261 | 262 | export interface MediaGalleryItem { 263 | media: UnfurledMediaItem; 264 | description?: string; 265 | spoiler?: boolean; 266 | } 267 | 268 | /** 269 | * File component 270 | * @see {@link https://discord.com/developers/docs/components/reference#file} 271 | */ 272 | export interface FileComponent extends BaseComponent { 273 | type: MessageComponentTypes.FILE; 274 | file: UnfurledMediaItem; 275 | spoiler?: boolean; 276 | } 277 | 278 | /** 279 | * Separator component 280 | * @see {@link https://discord.com/developers/docs/components/reference#separator} 281 | */ 282 | export interface Separator extends BaseComponent { 283 | type: MessageComponentTypes.SEPARATOR; 284 | divider?: boolean; 285 | spacing?: SeparatorSpacingTypes; 286 | } 287 | 288 | export enum SeparatorSpacingTypes { 289 | SMALL = 1, 290 | LARGE = 2, 291 | } 292 | 293 | /** 294 | * Container component 295 | * @see {@link https://discord.com/developers/docs/components/reference#container} 296 | */ 297 | export interface Container extends BaseComponent { 298 | type: MessageComponentTypes.CONTAINER; 299 | components: Array; 300 | accent_color?: number | null; 301 | spoiler?: boolean; 302 | } 303 | 304 | /** 305 | * Label component 306 | * @see {@link https://discord.com/developers/docs/components/reference#label} 307 | */ 308 | export interface Label extends BaseComponent { 309 | type: MessageComponentTypes.LABEL; 310 | label: string; 311 | description?: string; 312 | component: StringSelect | TextInput; 313 | } 314 | 315 | export interface UnfurledMediaItem { 316 | url: string; 317 | proxy_url?: string; 318 | height?: number | null; 319 | width?: number | null; 320 | content_type?: string; 321 | } 322 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import type { NextFunction, Request, Response } from 'express'; 2 | import { concatUint8Arrays, subtleCrypto, valueToUint8Array } from './util'; 3 | import { WebhookType } from './webhooks'; 4 | 5 | /** 6 | * The type of interaction this request is. 7 | */ 8 | export enum InteractionType { 9 | /** 10 | * A ping. 11 | */ 12 | PING = 1, 13 | /** 14 | * A command invocation. 15 | */ 16 | APPLICATION_COMMAND = 2, 17 | /** 18 | * Usage of a message's component. 19 | */ 20 | MESSAGE_COMPONENT = 3, 21 | /** 22 | * An interaction sent when an application command option is filled out. 23 | */ 24 | APPLICATION_COMMAND_AUTOCOMPLETE = 4, 25 | /** 26 | * An interaction sent when a modal is submitted. 27 | */ 28 | MODAL_SUBMIT = 5, 29 | } 30 | 31 | /** 32 | * The type of response that is being sent. 33 | */ 34 | export enum InteractionResponseType { 35 | /** 36 | * Acknowledge a `PING`. 37 | */ 38 | PONG = 1, 39 | /** 40 | * Respond with a message, showing the user's input. 41 | */ 42 | CHANNEL_MESSAGE_WITH_SOURCE = 4, 43 | /** 44 | * Acknowledge a command without sending a message, showing the user's input. Requires follow-up. 45 | */ 46 | DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE = 5, 47 | /** 48 | * Acknowledge an interaction and edit the original message that contains the component later; the user does not see a loading state. 49 | */ 50 | DEFERRED_UPDATE_MESSAGE = 6, 51 | /** 52 | * Edit the message the component was attached to. 53 | */ 54 | UPDATE_MESSAGE = 7, 55 | /* 56 | * Callback for an app to define the results to the user. 57 | */ 58 | APPLICATION_COMMAND_AUTOCOMPLETE_RESULT = 8, 59 | /* 60 | * Respond with a modal. 61 | */ 62 | MODAL = 9, 63 | /* 64 | * Respond with an upgrade prompt. 65 | */ 66 | PREMIUM_REQUIRED = 10, 67 | 68 | /** 69 | * Launch an Activity. 70 | */ 71 | LAUNCH_ACTIVITY = 12, 72 | } 73 | 74 | /** 75 | * Flags that can be included in an Interaction Response. 76 | */ 77 | export enum InteractionResponseFlags { 78 | /** 79 | * Show the message only to the user that performed the interaction. Message 80 | * does not persist between sessions. 81 | */ 82 | EPHEMERAL = 1 << 6, 83 | 84 | /** 85 | * Allows you to create fully component-driven messages 86 | * @see {@link https://discord.com/developers/docs/components/reference} 87 | */ 88 | IS_COMPONENTS_V2 = 1 << 15, 89 | } 90 | 91 | /** 92 | * Validates a payload from Discord against its signature and key. 93 | * 94 | * @param rawBody - The raw payload data 95 | * @param signature - The signature from the `X-Signature-Ed25519` header 96 | * @param timestamp - The timestamp from the `X-Signature-Timestamp` header 97 | * @param clientPublicKey - The public key from the Discord developer dashboard 98 | * @returns Whether or not validation was successful 99 | */ 100 | export async function verifyKey( 101 | rawBody: Uint8Array | ArrayBuffer | Buffer | string, 102 | signature: string, 103 | timestamp: string, 104 | clientPublicKey: string | CryptoKey, 105 | ): Promise { 106 | try { 107 | const timestampData = valueToUint8Array(timestamp); 108 | const bodyData = valueToUint8Array(rawBody); 109 | const message = concatUint8Arrays(timestampData, bodyData); 110 | const publicKey = 111 | typeof clientPublicKey === 'string' 112 | ? await subtleCrypto.importKey( 113 | 'raw', 114 | valueToUint8Array(clientPublicKey, 'hex'), 115 | { 116 | name: 'ed25519', 117 | namedCurve: 'ed25519', 118 | }, 119 | false, 120 | ['verify'], 121 | ) 122 | : clientPublicKey; 123 | const isValid = await subtleCrypto.verify( 124 | { 125 | name: 'ed25519', 126 | }, 127 | publicKey, 128 | valueToUint8Array(signature, 'hex'), 129 | message, 130 | ); 131 | return isValid; 132 | } catch (_ex) { 133 | return false; 134 | } 135 | } 136 | 137 | /** 138 | * Creates a middleware function for use in Express-compatible web servers for verifying Interaction Webhooks. 139 | * 140 | * @param clientPublicKey - The public key from the Discord developer dashboard 141 | * @returns The middleware function 142 | */ 143 | export function verifyKeyMiddleware( 144 | clientPublicKey: string, 145 | ): (req: Request, res: Response, next: NextFunction) => void { 146 | if (!clientPublicKey) { 147 | throw new Error('You must specify a Discord client public key'); 148 | } 149 | 150 | return async (req: Request, res: Response, next: NextFunction) => { 151 | const timestamp = req.header('X-Signature-Timestamp') || ''; 152 | const signature = req.header('X-Signature-Ed25519') || ''; 153 | 154 | if (!timestamp || !signature) { 155 | res.statusCode = 401; 156 | res.end('[discord-interactions] Invalid signature'); 157 | return; 158 | } 159 | 160 | async function onBodyComplete(rawBody: Buffer) { 161 | const isValid = await verifyKey( 162 | rawBody, 163 | signature, 164 | timestamp, 165 | clientPublicKey, 166 | ); 167 | if (!isValid) { 168 | res.statusCode = 401; 169 | res.end('[discord-interactions] Invalid signature'); 170 | return; 171 | } 172 | 173 | const body = JSON.parse(rawBody.toString('utf-8')) || {}; 174 | if (body.type === InteractionType.PING) { 175 | res.setHeader('Content-Type', 'application/json'); 176 | res.end( 177 | JSON.stringify({ 178 | type: InteractionResponseType.PONG, 179 | }), 180 | ); 181 | return; 182 | } 183 | 184 | req.body = body; 185 | next(); 186 | } 187 | 188 | if (req.body) { 189 | if (Buffer.isBuffer(req.body)) { 190 | await onBodyComplete(req.body); 191 | } else if (typeof req.body === 'string') { 192 | await onBodyComplete(Buffer.from(req.body, 'utf-8')); 193 | } else { 194 | console.warn( 195 | '[discord-interactions]: req.body was tampered with, probably by some other middleware. We recommend disabling middleware for interaction routes so that req.body is a raw buffer.', 196 | ); 197 | // Attempt to reconstruct the raw buffer. This works but is risky 198 | // because it depends on JSON.stringify matching the Discord backend's 199 | // JSON serialization. 200 | await onBodyComplete(Buffer.from(JSON.stringify(req.body), 'utf-8')); 201 | } 202 | } else { 203 | const chunks: Array = []; 204 | req.on('data', (chunk) => { 205 | chunks.push(chunk); 206 | }); 207 | req.on('end', async () => { 208 | const rawBody = Buffer.concat(chunks); 209 | await onBodyComplete(rawBody); 210 | }); 211 | } 212 | }; 213 | } 214 | 215 | /** 216 | * Creates a middleware function for use in Express-compatible web servers for verifying Event Webhooks. 217 | * 218 | * @param clientPublicKey - The public key from the Discord developer dashboard 219 | * @returns The middleware function 220 | */ 221 | export function verifyWebhookEventMiddleware( 222 | clientPublicKey: string, 223 | ): (req: Request, res: Response, next: NextFunction) => void { 224 | if (!clientPublicKey) { 225 | throw new Error('You must specify a Discord client public key'); 226 | } 227 | 228 | return async (req: Request, res: Response, next: NextFunction) => { 229 | const timestamp = req.header('X-Signature-Timestamp') || ''; 230 | const signature = req.header('X-Signature-Ed25519') || ''; 231 | 232 | if (!timestamp || !signature) { 233 | res.statusCode = 401; 234 | res.end('[discord-interactions] Invalid signature'); 235 | return; 236 | } 237 | 238 | async function onBodyComplete(rawBody: Buffer) { 239 | const isValid = await verifyKey( 240 | rawBody, 241 | signature, 242 | timestamp, 243 | clientPublicKey, 244 | ); 245 | if (!isValid) { 246 | res.statusCode = 401; 247 | res.end('[discord-interactions] Invalid signature'); 248 | return; 249 | } 250 | 251 | const body = JSON.parse(rawBody.toString('utf-8')) || {}; 252 | 253 | if (body.type === WebhookType.PING) { 254 | res.statusCode = 204; 255 | res.end(); 256 | return; 257 | } 258 | 259 | req.body = body; 260 | 261 | // return 204 status, and an empty body 262 | res.statusCode = 204; 263 | res.end(); 264 | 265 | // process the body data 266 | next(); 267 | } 268 | 269 | if (req.body) { 270 | if (Buffer.isBuffer(req.body)) { 271 | await onBodyComplete(req.body); 272 | } else if (typeof req.body === 'string') { 273 | await onBodyComplete(Buffer.from(req.body, 'utf-8')); 274 | } else { 275 | console.warn( 276 | '[discord-interactions]: req.body was tampered with, probably by some other middleware. We recommend disabling middleware for webhook event routes so that req.body is a raw buffer.', 277 | ); 278 | // Attempt to reconstruct the raw buffer. This works but is risky 279 | // because it depends on JSON.stringify matching the Discord backend's 280 | // JSON serialization. 281 | await onBodyComplete(Buffer.from(JSON.stringify(req.body), 'utf-8')); 282 | } 283 | } else { 284 | const chunks: Array = []; 285 | req.on('data', (chunk) => { 286 | chunks.push(chunk); 287 | }); 288 | req.on('end', async () => { 289 | const rawBody = Buffer.concat(chunks); 290 | await onBodyComplete(rawBody); 291 | }); 292 | } 293 | }; 294 | } 295 | 296 | export * from './components'; 297 | export * from './webhooks'; 298 | -------------------------------------------------------------------------------- /src/__tests__/verifyWebhookEventMiddleware.ts: -------------------------------------------------------------------------------- 1 | import type * as http from 'node:http'; 2 | import type { AddressInfo } from 'node:net'; 3 | import type { Request, Response } from 'express'; 4 | import express from 'express'; 5 | import { verifyWebhookEventMiddleware } from '../index'; 6 | import { subtleCrypto } from '../util'; 7 | import { WebhookEventType, WebhookType } from '../webhooks'; 8 | import { 9 | generateKeyPair, 10 | sendExampleRequest, 11 | signRequestWithKeyPair, 12 | } from './utils/SharedTestUtils'; 13 | 14 | const expressApp = express(); 15 | 16 | // Example webhook ping request body 17 | const webhookPingRequestBody = JSON.stringify({ 18 | type: WebhookType.PING, 19 | }); 20 | 21 | // Example webhook event request body 22 | const webhookEventRequestBody = JSON.stringify({ 23 | type: WebhookType.EVENT, 24 | event: { 25 | type: WebhookEventType.APPLICATION_AUTHORIZED, 26 | data: { 27 | user: { 28 | id: '123456789', 29 | username: 'testuser', 30 | }, 31 | guild_id: '987654321', 32 | }, 33 | }, 34 | }); 35 | 36 | let requestBody: unknown; 37 | let expressAppServer: http.Server; 38 | let exampleWebhookUrl: string; 39 | 40 | describe('verify webhook event middleware', () => { 41 | let validKeyPair: CryptoKeyPair; 42 | let invalidKeyPair: CryptoKeyPair; 43 | 44 | afterEach(() => { 45 | requestBody = undefined; 46 | jest.restoreAllMocks(); 47 | }); 48 | 49 | beforeAll(async () => { 50 | validKeyPair = await generateKeyPair(); 51 | invalidKeyPair = await generateKeyPair(); 52 | const rawPublicKey = Buffer.from( 53 | await subtleCrypto.exportKey('raw', validKeyPair.publicKey), 54 | ).toString('hex'); 55 | 56 | expressApp.post( 57 | '/webhook', 58 | (req, _res, next) => { 59 | if (requestBody) { 60 | req.body = requestBody; 61 | } 62 | next(); 63 | }, 64 | verifyWebhookEventMiddleware(rawPublicKey), 65 | (_req: Request, res: Response) => { 66 | // This handler will be reached but the response has already been sent 67 | // by the middleware with 204 status 68 | if (!res.headersSent) { 69 | res.send({ processed: true }); 70 | } 71 | }, 72 | ); 73 | 74 | await new Promise((resolve) => { 75 | expressAppServer = expressApp.listen(0); 76 | resolve(); 77 | }); 78 | exampleWebhookUrl = `http://localhost:${ 79 | (expressAppServer.address() as AddressInfo).port 80 | }/webhook`; 81 | }); 82 | 83 | it('valid webhook ping', async () => { 84 | // Sign and verify a valid webhook ping request 85 | const signedRequest = await signRequestWithKeyPair( 86 | webhookPingRequestBody, 87 | validKeyPair.privateKey, 88 | ); 89 | const exampleRequestResponse = await sendExampleRequest( 90 | exampleWebhookUrl, 91 | { 92 | 'x-signature-ed25519': signedRequest.signature, 93 | 'x-signature-timestamp': signedRequest.timestamp, 94 | 'content-type': 'application/json', 95 | }, 96 | signedRequest.body, 97 | ); 98 | expect(exampleRequestResponse.status).toBe(204); 99 | expect(exampleRequestResponse.body).toBe(''); 100 | }); 101 | 102 | it('valid webhook event', async () => { 103 | // Sign and verify a valid webhook event request 104 | const signedRequest = await signRequestWithKeyPair( 105 | webhookEventRequestBody, 106 | validKeyPair.privateKey, 107 | ); 108 | const exampleRequestResponse = await sendExampleRequest( 109 | exampleWebhookUrl, 110 | { 111 | 'x-signature-ed25519': signedRequest.signature, 112 | 'x-signature-timestamp': signedRequest.timestamp, 113 | 'content-type': 'application/json', 114 | }, 115 | signedRequest.body, 116 | ); 117 | expect(exampleRequestResponse.status).toBe(204); 118 | expect(exampleRequestResponse.body).toBe(''); 119 | }); 120 | 121 | it('invalid key', async () => { 122 | // Sign a request with a different private key and verify with the valid public key 123 | const signedRequest = await signRequestWithKeyPair( 124 | webhookPingRequestBody, 125 | invalidKeyPair.privateKey, 126 | ); 127 | const exampleRequestResponse = await sendExampleRequest( 128 | exampleWebhookUrl, 129 | { 130 | 'x-signature-ed25519': signedRequest.signature, 131 | 'x-signature-timestamp': signedRequest.timestamp, 132 | 'content-type': 'application/json', 133 | }, 134 | signedRequest.body, 135 | ); 136 | expect(exampleRequestResponse.status).toBe(401); 137 | }); 138 | 139 | it('invalid body', async () => { 140 | // Sign a valid request and verify with an invalid body 141 | const signedRequest = await signRequestWithKeyPair( 142 | webhookPingRequestBody, 143 | validKeyPair.privateKey, 144 | ); 145 | const exampleRequestResponse = await sendExampleRequest( 146 | exampleWebhookUrl, 147 | { 148 | 'x-signature-ed25519': signedRequest.signature, 149 | 'x-signature-timestamp': signedRequest.timestamp, 150 | 'content-type': 'application/json', 151 | }, 152 | 'example invalid body', 153 | ); 154 | expect(exampleRequestResponse.status).toBe(401); 155 | }); 156 | 157 | it('invalid signature', async () => { 158 | // Sign a valid request and verify with an invalid signature 159 | const signedRequest = await signRequestWithKeyPair( 160 | webhookPingRequestBody, 161 | validKeyPair.privateKey, 162 | ); 163 | const exampleRequestResponse = await sendExampleRequest( 164 | exampleWebhookUrl, 165 | { 166 | 'x-signature-ed25519': 'example invalid signature', 167 | 'x-signature-timestamp': signedRequest.timestamp, 168 | 'content-type': 'application/json', 169 | }, 170 | signedRequest.body, 171 | ); 172 | expect(exampleRequestResponse.status).toBe(401); 173 | }); 174 | 175 | it('invalid timestamp', async () => { 176 | // Sign a valid request and verify with an invalid timestamp 177 | const signedRequest = await signRequestWithKeyPair( 178 | webhookPingRequestBody, 179 | validKeyPair.privateKey, 180 | ); 181 | const exampleRequestResponse = await sendExampleRequest( 182 | exampleWebhookUrl, 183 | { 184 | 'x-signature-ed25519': signedRequest.signature, 185 | 'x-signature-timestamp': String(Math.round(Date.now() / 1000) - 10000), 186 | 'content-type': 'application/json', 187 | }, 188 | signedRequest.body, 189 | ); 190 | expect(exampleRequestResponse.status).toBe(401); 191 | }); 192 | 193 | it('missing headers', async () => { 194 | // Send a request without required headers 195 | const exampleRequestResponse = await sendExampleRequest( 196 | exampleWebhookUrl, 197 | { 198 | 'content-type': 'application/json', 199 | }, 200 | webhookPingRequestBody, 201 | ); 202 | expect(exampleRequestResponse.status).toBe(401); 203 | }); 204 | 205 | it('missing public key', async () => { 206 | expect(() => verifyWebhookEventMiddleware('')).toThrow( 207 | 'You must specify a Discord client public key', 208 | ); 209 | }); 210 | 211 | it('handles string bodies from middleware', async () => { 212 | const signedRequest = await signRequestWithKeyPair( 213 | webhookPingRequestBody, 214 | validKeyPair.privateKey, 215 | ); 216 | requestBody = signedRequest.body; 217 | const exampleRequestResponse = await sendExampleRequest( 218 | exampleWebhookUrl, 219 | { 220 | 'x-signature-ed25519': signedRequest.signature, 221 | 'x-signature-timestamp': signedRequest.timestamp, 222 | 'content-type': 'application/json', 223 | }, 224 | '', 225 | ); 226 | expect(exampleRequestResponse.status).toBe(204); 227 | }); 228 | 229 | it('warns on unknown bodies from middleware', async () => { 230 | const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => { 231 | return; 232 | }); 233 | const signedRequest = await signRequestWithKeyPair( 234 | webhookPingRequestBody, 235 | validKeyPair.privateKey, 236 | ); 237 | requestBody = JSON.parse(signedRequest.body); 238 | const exampleRequestResponse = await sendExampleRequest( 239 | exampleWebhookUrl, 240 | { 241 | 'x-signature-ed25519': signedRequest.signature, 242 | 'x-signature-timestamp': signedRequest.timestamp, 243 | 'content-type': 'application/json', 244 | }, 245 | '', 246 | ); 247 | expect(exampleRequestResponse.status).toBe(204); 248 | expect(warnSpy).toHaveBeenCalled(); 249 | }); 250 | 251 | it('handles route handler attempting to modify already-sent response', async () => { 252 | // Create a separate test app with a route handler that tries to modify the response 253 | const testApp = express(); 254 | const rawPublicKey = Buffer.from( 255 | await subtleCrypto.exportKey('raw', validKeyPair.publicKey), 256 | ).toString('hex'); 257 | 258 | // Spy on console.error to catch any errors 259 | const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => { 260 | return; 261 | }); 262 | 263 | testApp.post( 264 | '/webhook-test', 265 | verifyWebhookEventMiddleware(rawPublicKey), 266 | (_req: Request, res: Response) => { 267 | // This handler tries to send a response after middleware already sent one 268 | try { 269 | res.json({ shouldFail: true }); 270 | } catch (error) { 271 | // Catch any errors from trying to modify already-sent response 272 | console.error('Error in route handler:', error); 273 | } 274 | }, 275 | ); 276 | 277 | const testServer = testApp.listen(0); 278 | const testUrl = `http://localhost:${ 279 | (testServer.address() as AddressInfo).port 280 | }/webhook-test`; 281 | 282 | const signedRequest = await signRequestWithKeyPair( 283 | webhookEventRequestBody, 284 | validKeyPair.privateKey, 285 | ); 286 | 287 | const response = await sendExampleRequest( 288 | testUrl, 289 | { 290 | 'x-signature-ed25519': signedRequest.signature, 291 | 'x-signature-timestamp': signedRequest.timestamp, 292 | 'content-type': 'application/json', 293 | }, 294 | signedRequest.body, 295 | ); 296 | 297 | // The middleware should still return 204, regardless of what the handler tries to do 298 | expect(response.status).toBe(204); 299 | expect(response.body).toBe(''); 300 | 301 | testServer.close(); 302 | errorSpy.mockRestore(); 303 | }); 304 | }); 305 | 306 | afterAll(() => { 307 | if (expressAppServer) { 308 | expressAppServer.close(); 309 | } 310 | }); 311 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # discord-interactions 2 | 3 | --- 4 | [![version](https://img.shields.io/npm/v/discord-interactions.svg)](https://www.npmjs.com/package/discord-interactions) 5 | [![ci](https://github.com/discord/discord-interactions-js/actions/workflows/ci.yaml/badge.svg)](https://github.com/discord/discord-interactions-js/actions/workflows/ci.yaml) 6 | ![Downloads](https://img.shields.io/npm/dt/discord-interactions) 7 | 8 | Types and helper functions that may come in handy when you implement a Discord webhook. 9 | 10 | ## Overview 11 | 12 | This library provides a simple interface for working with Discord webhooks, including both slash command interactions 13 | and webhook events. You can build applications that: 14 | 15 | - Handle [Interactions](https://discord.com/developers/docs/interactions/overview) when users send commands to your app 16 | - Process [Webhook Events](https://discord.com/developers/docs/events/webhook-events) to receive real-time notifications 17 | about activities in your Discord server 18 | 19 | When users interact with your application or when events occur in your Discord server, Discord will send HTTP requests 20 | to your web application. This library makes it easier to: 21 | 22 | - Verify that requests to your endpoint are actually coming from Discord (for both interactions and events) 23 | - Integrate verification with web frameworks that 24 | use [connect middleware](https://expressjs.com/en/guide/using-middleware.html) (like express) 25 | - Use lightweight enums and TypeScript types to aid in handling request payloads and responses 26 | - Process different types of webhook payloads with type-safe interfaces 27 | 28 | To learn more about building on Discord, see [https://discord.dev](https://discord.dev). 29 | 30 | ## Installation 31 | 32 | ```sh 33 | npm install discord-interactions 34 | ``` 35 | 36 | ## Interactions Usage 37 | 38 | Use the `InteractionType` and `InteractionResponseType` enums to figure out how to respond to an interactions' webhook. 39 | 40 | Use `verifyKey` to check a request signature: 41 | 42 | ```js 43 | const signature = req.get('X-Signature-Ed25519'); 44 | const timestamp = req.get('X-Signature-Timestamp'); 45 | const isValidRequest = await verifyKey(req.rawBody, signature, timestamp, 'MY_CLIENT_PUBLIC_KEY'); 46 | if (!isValidRequest) { 47 | return res.status(401).end('Bad request signature'); 48 | } 49 | ``` 50 | 51 | Note that `req.rawBody` must be populated by a middleware (it is also set by some cloud function providers). 52 | 53 | If you're using an express-like API, you can simplify things by using the `verifyKeyMiddleware`. For example: 54 | 55 | ```js 56 | app.post('/interactions', verifyKeyMiddleware('MY_CLIENT_PUBLIC_KEY'), (req, res) => { 57 | const message = req.body; 58 | if (message.type === InteractionType.APPLICATION_COMMAND) { 59 | res.send({ 60 | type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, 61 | data: { 62 | content: 'Hello world', 63 | }, 64 | }); 65 | } 66 | }); 67 | ``` 68 | 69 | Make sure that you do not use other middlewares like `body-parser`, which tamper with the request body, for interaction routes. 70 | 71 | ### Interaction Types 72 | 73 | The following enumerations are available to help working with interaction requests and responses. For more details, see the [examples](/examples/). 74 | 75 | | | | 76 | |----------------------------|---------------------------------------------------------------------------| 77 | | `InteractionType` | An enum of interaction types that can be POSTed to your webhook endpoint. | 78 | | `InteractionResponseType` | An enum of response types you may provide in reply to Discord's webhook. | 79 | | `InteractionResponseFlags` | An enum of flags you can set on your response data. | 80 | 81 | ### Message Components 82 | 83 | This library contains lightweight TypeScript types and enums that are helpful when working with [Message Components](https://discord.com/developers/docs/components/reference). 84 | 85 | | | | 86 | |-------------------------|---------------------------------------------------------------------------------------------------------------------------------------| 87 | | `MessageComponentTypes` | An enum of message component types that can be used in messages and modals. | 88 | | `ActionRow` | Type for [Action Rows](https://discord.com/developers/docs/components/reference#action-row) | 89 | | `Button` | Type for [Buttons](https://discord.com/developers/docs/components/reference#button) | 90 | | `ButtonStyleTypes` | Enum for [Button Styles](https://discord.com/developers/docs/components/reference#button-button-styles) | 91 | | `StringSelect` | Type for [String Selects](https://discord.com/developers/docs/components/reference#string-select) | 92 | | `StringSelectOption` | Type for [String Select Options](https://discord.com/developers/docs/components/reference#string-select-select-option-structure) | 93 | | `UserSelect` | Type for [User Selects](https://discord.com/developers/docs/components/reference#user-select) | 94 | | `RoleSelect` | Type for [Role Selects](https://discord.com/developers/docs/components/reference#role-select) | 95 | | `MentionableSelect` | Type for [Mentionable Selects](https://discord.com/developers/docs/components/reference#mentionable-select) | 96 | | `ChannelSelect` | Type for [Channel Selects](https://discord.com/developers/docs/components/reference#channel-select) | 97 | | `InputText` | Type for [Text Inputs](https://discord.com/developers/docs/components/reference#text-input) | 98 | | `TextStyleTypes` | Enum for [Text Style Types](https://discord.com/developers/docs/components/reference#text-input-text-input-styles) | 99 | | `Section` | Type for [Sections](https://discord.com/developers/docs/components/reference#section) | 100 | | `TextDisplay` | Type for [Text Displays](https://discord.com/developers/docs/components/reference#text-display) | 101 | | `Thumbnail` | Type for [Thumbnails](https://discord.com/developers/docs/components/reference#thumbnail) | 102 | | `MediaGallery` | Type for [Media Galleries](https://discord.com/developers/docs/components/reference#media-gallery) | 103 | | `MediaGalleryItem` | Type for [Media Gallery Item](https://discord.com/developers/docs/components/reference#media-gallery-media-gallery-item-structure) | 104 | | `FileComponent` | Type for [File Components](https://discord.com/developers/docs/components/reference#file) | 105 | | `Separator` | Type for [Separators](https://discord.com/developers/docs/components/reference#separator) | 106 | | `Container` | Type for [Containers](https://discord.com/developers/docs/components/reference#container) | 107 | | `UnfurledMediaItem` | Type for [Unfurled Media Item](https://discord.com/developers/docs/components/reference#unfurled-media-item-structure) | 108 | 109 | 110 | The following enumerations are available to help working with Webhook events. For more details, see the 111 | [examples](/examples/). 112 | 113 | ## Webhook Event Usage 114 | 115 | Use the `WebhookType` and `WebhookEventType` enums to figure out how to process an event webhook. 116 | 117 | Use `verifyKey` to check a request signature (same as above): 118 | 119 | ```js 120 | const signature = req.get('X-Signature-Ed25519'); 121 | const timestamp = req.get('X-Signature-Timestamp'); 122 | const isValidRequest = await verifyKey(req.rawBody, signature, timestamp, 'MY_CLIENT_PUBLIC_KEY'); 123 | if (!isValidRequest) { 124 | return res.status(401).end('Bad request signature'); 125 | } 126 | ``` 127 | 128 | Note that `req.rawBody` must be populated by a middleware (it is also set by some cloud function providers). 129 | 130 | If you're using an express-like API, you can simplify things by using the `verifyWebhookEventMiddleware`. For example: 131 | 132 | ```javascript 133 | app.post( 134 | '/events', 135 | verifyWebhookEventMiddleware(process.env.CLIENT_PUBLIC_KEY), 136 | (req, res) => { 137 | console.log("📨 Event Received!") 138 | console.log(req.body); 139 | }, 140 | ); 141 | ``` 142 | 143 | ### Webhook Event Types 144 | 145 | The following enumerations are available to help working with interaction requests and responses. 146 | For more details, see the [express example](/examples/express_app.js). 147 | 148 | | | | 149 | |--------------------|---------------------------------------------------------------------------| 150 | | `WebhookType` | An enum of interaction types that can be POSTed to your webhook endpoint. | 151 | | `WebhookEventType` | An enum of response types you may provide in reply to Discord's webhook. | 152 | 153 | For a complete list of available TypeScript types, check out [discord-api-types](https://www.npmjs.com/package/discord-api-types) package. 154 | 155 | ## Running the Examples 156 | 157 | To run the examples: 158 | 159 | ```shell 160 | npm run build 161 | export CLIENT_PUBLIC_KEY=${Your Discord App Public Key} 162 | node examples/express_app.js # or choose a different example 163 | ``` 164 | 165 | ## Learning more 166 | 167 | To learn more about the Discord API, visit [https://discord.dev](https://discord.dev). 168 | -------------------------------------------------------------------------------- /src/__tests__/verifyKeyMiddleware.ts: -------------------------------------------------------------------------------- 1 | import type * as http from 'node:http'; 2 | import type { AddressInfo } from 'node:net'; 3 | import type { Request, Response } from 'express'; 4 | import express from 'express'; 5 | import { 6 | InteractionResponseFlags, 7 | InteractionResponseType, 8 | InteractionType, 9 | verifyKeyMiddleware, 10 | } from '../index'; 11 | import { subtleCrypto } from '../util'; 12 | import { 13 | applicationCommandRequestBody, 14 | autocompleteRequestBody, 15 | generateKeyPair, 16 | messageComponentRequestBody, 17 | pingRequestBody, 18 | sendExampleRequest, 19 | signRequestWithKeyPair, 20 | } from './utils/SharedTestUtils'; 21 | 22 | const expressApp = express(); 23 | 24 | const exampleApplicationCommandResponse = { 25 | type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, 26 | data: { 27 | content: 'Hello world', 28 | }, 29 | }; 30 | 31 | const exampleMessageComponentResponse = { 32 | type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, 33 | data: { 34 | content: 'Hello, you interacted with a component.', 35 | flags: InteractionResponseFlags.EPHEMERAL, 36 | }, 37 | }; 38 | 39 | const exampleAutocompleteResponse = { 40 | type: InteractionResponseType.APPLICATION_COMMAND_AUTOCOMPLETE_RESULT, 41 | data: { 42 | choices: [ 43 | { 44 | name: 'The first option', 45 | value: 'first_option', 46 | }, 47 | ], 48 | }, 49 | }; 50 | 51 | let requestBody: unknown; 52 | let expressAppServer: http.Server; 53 | let exampleInteractionsUrl: string; 54 | 55 | describe('verify key middleware', () => { 56 | let validKeyPair: CryptoKeyPair; 57 | let invalidKeyPair: CryptoKeyPair; 58 | 59 | afterEach(() => { 60 | requestBody = undefined; 61 | jest.restoreAllMocks(); 62 | }); 63 | 64 | beforeAll(async () => { 65 | validKeyPair = await generateKeyPair(); 66 | invalidKeyPair = await generateKeyPair(); 67 | const rawPublicKey = Buffer.from( 68 | await subtleCrypto.exportKey('raw', validKeyPair.publicKey), 69 | ).toString('hex'); 70 | 71 | expressApp.post( 72 | '/interactions', 73 | (req, _res, next) => { 74 | if (requestBody) { 75 | req.body = requestBody; 76 | } 77 | next(); 78 | }, 79 | verifyKeyMiddleware(rawPublicKey), 80 | (req: Request, res: Response) => { 81 | const interaction = req.body; 82 | if (interaction.type === InteractionType.APPLICATION_COMMAND) { 83 | res.send(exampleApplicationCommandResponse); 84 | } else if (interaction.type === InteractionType.MESSAGE_COMPONENT) { 85 | res.send(exampleMessageComponentResponse); 86 | } else if ( 87 | interaction.type === InteractionType.APPLICATION_COMMAND_AUTOCOMPLETE 88 | ) { 89 | res.send(exampleAutocompleteResponse); 90 | } 91 | }, 92 | ); 93 | 94 | await new Promise((resolve) => { 95 | expressAppServer = expressApp.listen(0); 96 | resolve(); 97 | }); 98 | exampleInteractionsUrl = `http://localhost:${ 99 | (expressAppServer.address() as AddressInfo).port 100 | }/interactions`; 101 | }); 102 | 103 | it('valid ping', async () => { 104 | // Sign and verify a valid ping request 105 | const signedRequest = await signRequestWithKeyPair( 106 | pingRequestBody, 107 | validKeyPair.privateKey, 108 | ); 109 | const exampleRequestResponse = await sendExampleRequest( 110 | exampleInteractionsUrl, 111 | { 112 | 'x-signature-ed25519': signedRequest.signature, 113 | 'x-signature-timestamp': signedRequest.timestamp, 114 | 'content-type': 'application/json', 115 | }, 116 | signedRequest.body, 117 | ); 118 | expect(exampleRequestResponse.status).toBe(200); 119 | }); 120 | 121 | it('valid application command', async () => { 122 | // Sign and verify a valid application command request 123 | const signedRequest = await signRequestWithKeyPair( 124 | applicationCommandRequestBody, 125 | validKeyPair.privateKey, 126 | ); 127 | const exampleRequestResponse = await sendExampleRequest( 128 | exampleInteractionsUrl, 129 | { 130 | 'x-signature-ed25519': signedRequest.signature, 131 | 'x-signature-timestamp': signedRequest.timestamp, 132 | 'content-type': 'application/json', 133 | }, 134 | signedRequest.body, 135 | ); 136 | const exampleRequestResponseBody = JSON.parse(exampleRequestResponse.body); 137 | expect(exampleRequestResponseBody).toStrictEqual( 138 | exampleApplicationCommandResponse, 139 | ); 140 | }); 141 | 142 | it('valid message component', async () => { 143 | // Sign and verify a valid message component request 144 | const signedRequest = await signRequestWithKeyPair( 145 | messageComponentRequestBody, 146 | validKeyPair.privateKey, 147 | ); 148 | const exampleRequestResponse = await sendExampleRequest( 149 | exampleInteractionsUrl, 150 | { 151 | 'x-signature-ed25519': signedRequest.signature, 152 | 'x-signature-timestamp': signedRequest.timestamp, 153 | 'content-type': 'application/json', 154 | }, 155 | signedRequest.body, 156 | ); 157 | const exampleRequestResponseBody = JSON.parse(exampleRequestResponse.body); 158 | expect(exampleRequestResponseBody).toStrictEqual( 159 | exampleMessageComponentResponse, 160 | ); 161 | }); 162 | 163 | it('valid autocomplete', async () => { 164 | // Sign and verify a valid autocomplete request 165 | const signedRequest = await signRequestWithKeyPair( 166 | autocompleteRequestBody, 167 | validKeyPair.privateKey, 168 | ); 169 | const exampleRequestResponse = await sendExampleRequest( 170 | exampleInteractionsUrl, 171 | { 172 | 'x-signature-ed25519': signedRequest.signature, 173 | 'x-signature-timestamp': signedRequest.timestamp, 174 | 'content-type': 'application/json', 175 | }, 176 | signedRequest.body, 177 | ); 178 | const exampleRequestResponseBody = JSON.parse(exampleRequestResponse.body); 179 | expect(exampleRequestResponseBody).toStrictEqual( 180 | exampleAutocompleteResponse, 181 | ); 182 | }); 183 | 184 | it('invalid key', async () => { 185 | // Sign a request with a different private key and verify with the valid public key 186 | const signedRequest = await signRequestWithKeyPair( 187 | pingRequestBody, 188 | invalidKeyPair.privateKey, 189 | ); 190 | const exampleRequestResponse = await sendExampleRequest( 191 | exampleInteractionsUrl, 192 | { 193 | 'x-signature-ed25519': signedRequest.signature, 194 | 'x-signature-timestamp': signedRequest.timestamp, 195 | 'content-type': 'application/json', 196 | }, 197 | signedRequest.body, 198 | ); 199 | expect(exampleRequestResponse.status).toBe(401); 200 | }); 201 | 202 | it('invalid body', async () => { 203 | // Sign a valid request and verify with an invalid body 204 | const signedRequest = await signRequestWithKeyPair( 205 | pingRequestBody, 206 | validKeyPair.privateKey, 207 | ); 208 | const exampleRequestResponse = await sendExampleRequest( 209 | exampleInteractionsUrl, 210 | { 211 | 'x-signature-ed25519': signedRequest.signature, 212 | 'x-signature-timestamp': signedRequest.timestamp, 213 | 'content-type': 'application/json', 214 | }, 215 | 'example invalid body', 216 | ); 217 | expect(exampleRequestResponse.status).toBe(401); 218 | }); 219 | 220 | it('invalid signature', async () => { 221 | // Sign a valid request and verify with an invalid signature 222 | const signedRequest = await signRequestWithKeyPair( 223 | pingRequestBody, 224 | validKeyPair.privateKey, 225 | ); 226 | const exampleRequestResponse = await sendExampleRequest( 227 | exampleInteractionsUrl, 228 | { 229 | 'x-signature-ed25519': 'example invalid signature', 230 | 'x-signature-timestamp': signedRequest.timestamp, 231 | 'content-type': 'application/json', 232 | }, 233 | signedRequest.body, 234 | ); 235 | expect(exampleRequestResponse.status).toBe(401); 236 | }); 237 | 238 | it('invalid timestamp', async () => { 239 | // Sign a valid request and verify with an invalid timestamp 240 | const signedRequest = await signRequestWithKeyPair( 241 | pingRequestBody, 242 | validKeyPair.privateKey, 243 | ); 244 | const exampleRequestResponse = await sendExampleRequest( 245 | exampleInteractionsUrl, 246 | { 247 | 'x-signature-ed25519': signedRequest.signature, 248 | 'x-signature-timestamp': String(Math.round(Date.now() / 1000) - 10000), 249 | 'content-type': 'application/json', 250 | }, 251 | signedRequest.body, 252 | ); 253 | expect(exampleRequestResponse.status).toBe(401); 254 | }); 255 | 256 | it('missing headers', async () => { 257 | // Sign a valid request and verify with an invalid timestamp 258 | const exampleRequestResponse = await sendExampleRequest( 259 | exampleInteractionsUrl, 260 | { 261 | 'content-type': 'application/json', 262 | }, 263 | exampleInteractionsUrl, 264 | ); 265 | expect(exampleRequestResponse.status).toBe(401); 266 | }); 267 | 268 | it('missing public key', async () => { 269 | expect(() => verifyKeyMiddleware('')).toThrow( 270 | 'You must specify a Discord client public key', 271 | ); 272 | }); 273 | 274 | it('handles string bodies from middleware', async () => { 275 | const signedRequest = await signRequestWithKeyPair( 276 | pingRequestBody, 277 | validKeyPair.privateKey, 278 | ); 279 | requestBody = signedRequest.body; 280 | const exampleRequestResponse = await sendExampleRequest( 281 | exampleInteractionsUrl, 282 | { 283 | 'x-signature-ed25519': signedRequest.signature, 284 | 'x-signature-timestamp': signedRequest.timestamp, 285 | 'content-type': 'application/json', 286 | }, 287 | '', 288 | ); 289 | expect(exampleRequestResponse.status).toBe(200); 290 | }); 291 | 292 | it('warns on unknown bodies from middleware', async () => { 293 | const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => { 294 | return; 295 | }); 296 | const signedRequest = await signRequestWithKeyPair( 297 | pingRequestBody, 298 | validKeyPair.privateKey, 299 | ); 300 | requestBody = JSON.parse(signedRequest.body); 301 | const exampleRequestResponse = await sendExampleRequest( 302 | exampleInteractionsUrl, 303 | { 304 | 'x-signature-ed25519': signedRequest.signature, 305 | 'x-signature-timestamp': signedRequest.timestamp, 306 | 'content-type': 'application/json', 307 | }, 308 | '', 309 | ); 310 | expect(exampleRequestResponse.status).toBe(200); 311 | expect(warnSpy).toHaveBeenCalled(); 312 | }); 313 | }); 314 | 315 | afterAll(() => { 316 | if (expressAppServer) { 317 | expressAppServer.close(); 318 | } 319 | }); 320 | --------------------------------------------------------------------------------