diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6232ab2..da339af 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,4 +1,4 @@ -name: Build library +name: Build SDK on: push: @@ -11,11 +11,11 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - node: [18, 20] + node: [21, 22, 24, 25] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v6 - name: Cache node_modules - uses: actions/cache@v3 + uses: actions/cache@v5 env: cache-name: cache-node-modules with: @@ -26,7 +26,7 @@ jobs: ${{ runner.os }}-build- ${{ runner.os }}- - name: Setup Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v6 with: node-version: ${{ matrix.node }} check-latest: true diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 2d6d3e7..3815f1c 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -8,10 +8,10 @@ jobs: publish: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 with: - node-version: 18 + node-version: 21 check-latest: true registry-url: https://registry.npmjs.org/ - run: npm publish --access public diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index bce1efc..174d8ee 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,4 +1,4 @@ -name: Jest Tests +name: Run tests on: push: @@ -10,14 +10,14 @@ jobs: test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v6 - name: Use Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v6 with: - node-version: 18 + node-version: 21 check-latest: true registry-url: https://registry.npmjs.org/ - name: Install Dependencies run: npm install - - name: Run Jest tests - run: npm run test + - name: Run tests + run: npm test diff --git a/.husky/pre-commit b/.husky/pre-commit index 20d0d06..96a0db8 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,4 +1 @@ -#!/bin/sh -. "$(dirname "$0")/_/husky.sh" - -npm run lint +npm run lint && npx tsc diff --git a/.nvmrc b/.nvmrc index 87ec884..a58d2d2 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -18.18.2 +18.18.2 \ No newline at end of file diff --git a/README.md b/README.md index 156cae7..da9bf8d 100644 --- a/README.md +++ b/README.md @@ -1,62 +1,175 @@ -# Top.gg Node SDK +# Top.gg Node.js SDK -An official module for interacting with the Top.gg API +> For more information, see the documentation here: . -# Installation +The community-maintained Node.js SDK for Top.gg. -`yarn add @top-gg/sdk` or `npm i @top-gg/sdk` +## Chapters -# Introduction +- [Installation](#installation) +- [Setting up](#setting-up) +- [Usage](#usage) + - [Getting your project's information](#getting-your-projects-information) + - [Getting your project's vote information of a user](#getting-your-projects-vote-information-of-a-user) + - [Getting a cursor-based paginated list of votes for your project](#getting-a-cursor-based-paginated-list-of-votes-for-your-project) + - [Posting your bot's application commands list](#posting-your-bots-application-commands-list) + - [Generating widget URLs](#generating-widget-urls) + - [Webhooks](#webhooks) -The base client is Topgg.Api, and it takes your Top.gg token and provides you with plenty of methods to interact with the API. +## Installation -See [this tutorial](https://github.com/top-gg/rust-sdk/assets/60427892/d2df5bd3-bc48-464c-b878-a04121727bff) on how to retrieve your API token. -You can also setup webhooks via Topgg.Webhook, look down below at the examples for how to do so! +### NPM -# Links +```sh +$ npm i @top-gg/sdk +``` + +### Yarn + +```sh +$ yarn add @top-gg/sdk +``` + +## Setting up + +```js +import Topgg from "@top-gg/sdk"; + +const client = new Topgg.Api(process.env.TOPGG_TOKEN); +``` + +## Usage + +### Getting your project's information + +```js +const project = await client.getSelf(); + +console.log(project); +// => +// { +// id: '218109768489992192', +// name: 'Miki', +// type: 'bot', +// platform: 'discord', +// headline: 'A great bot with tons of features! language | admin | cards | fun | levels | roles | marriage | currency | custom commands!', +// tags: [ +// 'anime', +// 'customizable-behavior', +// 'economy', +// 'fun', +// 'game', +// 'leveling', +// 'multifunctional', +// 'role-management', +// 'roleplay', +// 'social' +// ], +// votes: { current: 1120, total: 313389 }, +// review: { score: 4.38, count: 62245 } +// } +``` + +### Getting your project's vote information of a user + +#### Discord ID + +```js +const vote = await client.getVote("661200758510977084"); +``` + +#### Top.gg ID + +```js +const vote = await client.getVote("8226924471638491136", "topgg"); +``` + +### Getting a cursor-based paginated list of votes for your project -[Documentation](https://topgg.js.org) +```js +const since = new Date("2026-01-01"); -[API Reference](https://docs.top.gg) | [GitHub](https://github.com/top-gg/node-sdk) | [NPM](https://npmjs.com/package/@top-gg/sdk) | [Discord Server](https://discord.gg/EYHTgJX) +const firstPage = await client.getVotes(since); +console.log(firstPage.votes); -# Popular Examples +const secondPage = await firstPage.next(); +console.log(secondPage.votes); +``` -## Auto-Posting stats +### Posting your bot's application commands list -If you're looking for an easy way to post your bot's stats (server count, shard count), check out [`topgg-autoposter`](https://npmjs.com/package/topgg-autoposter) +#### Discord.js ```js -const client = Discord.Client(); // Your discord.js client or any other -const { AutoPoster } = require("topgg-autoposter"); +const commands = (await bot.application.commands.fetch()).map(command => command.toJSON()); -AutoPoster("topgg-token", client).on("posted", () => { - console.log("Posted stats to Top.gg!"); -}); +await client.postCommands(commands); ``` -With this your server count and shard count will be posted to Top.gg +#### Raw + +```js +// Array of application commands that +// can be serialized to Discord API's raw JSON format. +await client.postCommands([ + { + options: [], + name: 'test', + name_localizations: null, + description: 'command description', + description_localizations: null, + contexts: [], + default_permission: null, + default_member_permissions: null, + dm_permission: false, + integration_types: [], + nsfw: false + } +]); +``` + +### Generating widget URLs + +#### Large + +```js +const widgetUrl = Topgg.Widget.large("discord", "bot", "1026525568344264724"); +``` -## Webhook server +#### Votes ```js -const express = require("express"); -const Topgg = require("@top-gg/sdk"); +const widgetUrl = Topgg.Widget.votes("discord", "bot", "1026525568344264724"); +``` -const app = express(); // Your express app +#### Owner -const webhook = new Topgg.Webhook("topggauth123"); // add your Top.gg webhook authorization (not bot token) +```js +const widgetUrl = Topgg.Widget.owner("discord", "bot", "1026525568344264724"); +``` -app.post( - "/dblwebhook", - webhook.listener((vote) => { - // vote is your vote object - console.log(vote.user); // 221221226561929217 - }) -); // attach the middleware +#### Social -app.listen(3000); // your port +```js +const widgetUrl = Topgg.Widget.social("discord", "bot", "1026525568344264724"); ``` -With this example, your webhook dashboard (`https://top.gg/bot/{your bot's id}/webhooks`) should look like this: -![](https://i.imgur.com/cZfZgK5.png) +### Webhooks + +With express: + +```js +import { Webhook } from "@top-gg/sdk"; +import express from "express"; + +const app = express(); +const webhook = new Webhook(process.env.TOPGG_WEBHOOK_SECRET); + +// POST /webhook +app.post("/webhook", webhook.listener((payload) => { + console.log(payload); +})); + +app.listen(8080); +``` \ No newline at end of file diff --git a/eslint.config.js b/eslint.config.js index a7cdbb0..262342a 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,10 +1,9 @@ -const js = require('@eslint/js'); -const ts = require("@typescript-eslint/eslint-plugin"); +import tsPlugin from "@typescript-eslint/eslint-plugin"; +import ts from "@typescript-eslint/eslint-plugin"; +import tsParser from "@typescript-eslint/parser"; +import js from "@eslint/js"; -const tsPlugin = require('@typescript-eslint/eslint-plugin'); -const jestPlugin = require('eslint-plugin-jest'); - -module.exports = [ +export default [ { ignores: ["node_modules/*", "docs/*", "dist/*"] }, @@ -12,20 +11,19 @@ module.exports = [ ...js.configs.recommended, files: ["src/**/*.ts"], languageOptions: { - parser: require('@typescript-eslint/parser'), + parser: tsParser, parserOptions: { - project: './tsconfig.json', + projectService: true }, globals: { es6: true, browser: true, - node: true, - jest: true - } + node: true + }, + sourceType: "module" }, plugins: { - "@typescript-eslint": tsPlugin, - jest: jestPlugin + "@typescript-eslint": tsPlugin }, rules: { ...ts.configs.recommended.rules, diff --git a/jest.config.js b/jest.config.js deleted file mode 100644 index fa04662..0000000 --- a/jest.config.js +++ /dev/null @@ -1,6 +0,0 @@ -/** @type {import('ts-jest').JestConfigWithTsJest} */ -module.exports = { - preset: 'ts-jest', - testEnvironment: 'node', - setupFilesAfterEnv: ['./tests/jest.setup.ts'] -}; \ No newline at end of file diff --git a/package.json b/package.json index 3861048..ed128d4 100644 --- a/package.json +++ b/package.json @@ -1,18 +1,22 @@ { "name": "@top-gg/sdk", - "version": "3.1.6", - "description": "Official Top.gg Node SDK", + "version": "4.0.0", + "description": "The community-maintained Node.js SDK for Top.gg.", "main": "./dist/index.js", + "type": "module", + "engines": { + "node": ">= 18.18.2" + }, "scripts": { - "test": "jest --verbose", - "test:coverage": "jest --coverage", + "test": "node --import tsx --test tests/**/*.test.ts", + "test:coverage": "c8 node --import tsx --test tests/**/*.test.ts", "build": "tsc", "build:ci": "npm i --include=dev && tsc", "docs": "typedoc", "prepublishOnly": "npm run build:ci", "lint": "eslint src/**/*.ts", "lint:ci": "eslint --output-file eslint_report.json --format json src/**/*.ts", - "prepare": "npx husky install" + "prepare": "husky" }, "repository": { "type": "git", @@ -25,26 +29,32 @@ }, "homepage": "https://topgg.js.org", "devDependencies": { - "@types/express": "^4.17.17", - "@types/jest": "^29.5.4", - "@types/node": "^20.5.9", - "@typescript-eslint/eslint-plugin": "^6.6.0", - "@typescript-eslint/parser": "^6.6.0", - "eslint": "^8.48.0", - "eslint-config-prettier": "^9.0.0", - "eslint-plugin-jest": "^27.2.3", - "express": "^4.18.2", - "husky": "^8.0.3", - "jest": "^29.6.4", - "lint-staged": "^14.0.1", - "prettier": "^3.0.3", - "ts-jest": "^29.1.1", - "typedoc": "^0.25.1", - "typescript": "^5.2.2" + "@eslint/js": "^10.0.1", + "@types/express": "^5.0.6", + "@types/node": "^25.5.0", + "@typescript-eslint/eslint-plugin": "^8.57.1", + "@typescript-eslint/parser": "^8.57.1", + "c8": "^11.0.0", + "discord-api-types": "^0.38.42", + "eslint": "^10.0.3", + "eslint-config-prettier": "^10.1.8", + "express": "^5.2.1", + "husky": "^9.1.7", + "lint-staged": "^16.4.0", + "node-mocks-http": "^1.17.2", + "prettier": "^3.8.1", + "tsx": "^4.21.0", + "typedoc": "^0.28.17", + "typescript": "^5.9.3", + "undici": "^7.24.4" }, "dependencies": { - "raw-body": "^2.5.2", - "undici": "^5.23.0" + "raw-body": "^3.0.2" + }, + "overrides": { + "c8": { + "yargs": "^18.0.0" + } }, "types": "./dist/index.d.ts" } diff --git a/src/index.ts b/src/index.ts index 1d7c6b4..68d5f3c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ -export * from "./structs/Api"; -export * from "./structs/Webhook"; -export * from "./typings"; +export * from "./structs/Api.js"; +export * from "./structs/Webhook.js"; +export * from "./structs/Widget.js"; +export * from "./typings.js"; diff --git a/src/structs/Api.ts b/src/structs/Api.ts index 74aaf73..ea47626 100644 --- a/src/structs/Api.ts +++ b/src/structs/Api.ts @@ -1,31 +1,34 @@ -import { request, type Dispatcher } from "undici"; -import type { IncomingHttpHeaders } from "undici/types/header"; -import ApiError from "../utils/ApiError"; +import type { APIApplicationCommand } from "discord-api-types/v10"; +import TopGGAPIError from "../utils/ApiError.js"; import { EventEmitter } from "events"; import { STATUS_CODES } from "http"; -import { +import type { APIOptions, Snowflake, - BotStats, - BotInfo, - UserInfo, - BotsResponse, - ShortUser, - BotsQuery, -} from "../typings"; + UserSource, + Project, + PartialVote, + PaginatedVotes +} from "../typings.js"; + +/** The API version to use */ +export const API_VERSION = "v1"; + +/** The API's base URL */ +const BASE_URL = `https://top.gg/api/${API_VERSION}`; /** - * Top.gg API Client for Posting stats or Fetching data + * Top.gg API v1 client * * @example * ```js - * const Topgg = require("@top-gg/sdk"); + * import Topgg from "@top-gg/sdk"; * - * const api = new Topgg.Api("Your top.gg token"); + * const client = new Topgg.Api(process.env.TOPGG_TOKEN); * ``` * - * @link {@link https://topgg.js.org | Library docs} + * @link {@link https://topgg.js.org | SDK docs} * @link {@link https://docs.top.gg | API Reference} */ export class Api extends EventEmitter { @@ -40,286 +43,223 @@ export class Api extends EventEmitter { constructor(token: string, options: APIOptions = {}) { super(); - const tokenSegments = token.split("."); - - if (tokenSegments.length !== 3) { - throw new Error("Got a malformed API token."); - } - - const tokenData = atob(tokenSegments[1]); - - try { - JSON.parse(tokenData).id; - } catch { - throw new Error( - "Invalid API token state, this should not happen! Please report!" - ); - } - this.options = { token, - ...options, + ...options }; } private async _request( - method: Dispatcher.HttpMethod, + method: string, path: string, body?: Record ): Promise { - const headers: IncomingHttpHeaders = {}; - if (this.options.token) headers["authorization"] = this.options.token; - if (method !== "GET") headers["content-type"] = "application/json"; - - let url = `https://top.gg/api${path}`; + const headers = new Headers(); - if (body && method === "GET") url += `?${new URLSearchParams(body)}`; + if (this.options.token) + headers.set("authorization", `Bearer ${this.options.token}`); + if (method !== "GET") headers.set("content-type", "application/json"); - const response = await request(url, { + const response = await fetch(BASE_URL + path, { method, headers, - body: body && method !== "GET" ? JSON.stringify(body) : undefined, + body: body && method !== "GET" ? JSON.stringify(body) : undefined }); - let responseBody; + let responseBody: string | object | undefined; - if ( - (response.headers["content-type"] as string)?.startsWith( - "application/json" - ) - ) { - responseBody = await response.body.json(); + if (response.headers.get("content-type")?.includes("json")) { + responseBody = (await response.json()) as object; } else { - responseBody = await response.body.text(); + responseBody = await response.text(); } - if (response.statusCode < 200 || response.statusCode > 299) { - throw new ApiError( - response.statusCode, - STATUS_CODES[response.statusCode] ?? "", - response - ); + if (response.status < 200 || response.status > 299) { + /* node:coverage ignore next 1 */ + throw new TopGGAPIError(STATUS_CODES[response.status] ?? "", response); } return responseBody; } /** - * Post bot stats to Top.gg + * Gets your project's information. * * @example * ```js - * await api.postStats({ - * serverCount: 28199, - * }); - * ``` - * - * @param {object} stats Stats object - * @param {number} stats.serverCount Server count - * @returns {BotStats} Passed object - */ - public async postStats(stats: BotStats): Promise { - if ((stats?.serverCount ?? 0) <= 0) throw new Error("Missing server count"); - - /* eslint-disable camelcase */ - await this._request("POST", "/bots/stats", { - server_count: stats.serverCount, - }); - /* eslint-enable camelcase */ - - return stats; - } - - /** - * Get your bot's stats + * const project = await client.getSelf(); * - * @example - * ```js - * await api.getStats(); + * console.log(project); * // => - * { - * serverCount: 28199, - * shardCount: null, - * shards: [] - * } + * // { + * // id: '218109768489992192', + * // name: 'Miki', + * // type: 'bot', + * // platform: 'discord', + * // headline: 'A great bot with tons of features! language | admin | cards | fun | levels | roles | marriage | currency | custom commands!', + * // tags: [ + * // 'anime', + * // 'customizable-behavior', + * // 'economy', + * // 'fun', + * // 'game', + * // 'leveling', + * // 'multifunctional', + * // 'role-management', + * // 'roleplay', + * // 'social' + * // ], + * // votes: { current: 1120, total: 313389 }, + * // review: { score: 4.38, count: 62245 } + * // } * ``` * - * @returns {BotStats} Your bot's stats + * @returns {Promise} Your project's information. */ - public async getStats(_id?: Snowflake): Promise { - if (_id) - console.warn( - "[DeprecationWarning] getStats() no longer needs an ID argument" - ); - const result = await this._request("GET", "/bots/stats"); + public async getSelf(): Promise { + const project = await this._request("GET", "/projects/@me"); + return { - serverCount: result.server_count, - shardCount: null, - shards: [], + id: project.id, + name: project.name, + platform: project.platform, + type: project.type, + headline: project.headline, + tags: project.tags, + votes: { + current: project.votes, + total: project.votes_total + }, + review: { + score: project.review_score, + count: project.review_count + } }; } /** - * Get bot info + * Updates the application commands list in your Discord bot's Top.gg page. * * @example * ```js - * await api.getBot("461521980492087297"); // returns bot info + * // Discord.js: + * const commands = (await bot.application.commands.fetch()).map(command => command.toJSON()); + * + * // Raw: + * // Array of application commands that + * // can be serialized to Discord API's raw JSON format. + * await client.postCommands([ + * { + * options: [], + * name: 'test', + * name_localizations: null, + * description: 'command description', + * description_localizations: null, + * contexts: [], + * default_permission: null, + * default_member_permissions: null, + * dm_permission: false, + * integration_types: [], + * nsfw: false + * } + * ]); * ``` * - * @param {Snowflake} id Bot ID - * @returns {BotInfo} Info for bot + * @param {APIApplicationCommand[]} commands A list of application commands in raw Discord API JSON objects. This cannot be empty. + * @returns {Promise} */ - public async getBot(id: Snowflake): Promise { - if (!id) throw new Error("ID Missing"); - return this._request("GET", `/bots/${id}`); + public async postCommands(commands: APIApplicationCommand[]): Promise { + await this._request("POST", "/projects/@me/commands", commands); } /** - * @deprecated No longer supported by Top.gg API v0. - * - * Get user info + * Gets the latest vote information of a Top.gg user on your project. * * @example * ```js - * await api.getUser("205680187394752512"); - * // => - * user.username; // Xignotic + * // Discord ID + * const vote = await client.getVote("661200758510977084"); + * + * // Top.gg ID + * const vote = await client.getVote("8226924471638491136", "topgg"); * ``` * - * @param {Snowflake} id User ID - * @returns {UserInfo} Info for user + * @param {Snowflake} id The user's ID. + * @param {UserSource} source The ID type to use. Defaults to "discord". + * @returns {Promise} The user's latest vote information on your project or null if the user does not exist or has not voted for your project in the past 12 hours. */ - public async getUser(id: Snowflake): Promise { - console.warn( - "[DeprecationWarning] getUser is no longer supported by Top.gg API v0." - ); + public async getVote( + id: Snowflake, + source: UserSource = "discord" + ): Promise { + if (!id) throw new Error("Missing ID"); - return this._request("GET", `/users/${id}`); - } + try { + const response = await this._request( + "GET", + `/projects/@me/votes/${id}?source=${source}` + ); - /** - * Get a list of bots - * - * @example - * ```js - * // Finding by properties - * await api.getBots({ - * search: { - * username: "shiro" - * }, - * }); - * // => - * { - * results: [ - * { - * id: "461521980492087297", - * username: "Shiro", - * ...rest of bot object - * } - * ...other shiro knockoffs B) - * ], - * limit: 10, - * offset: 0, - * count: 1, - * total: 1 - * } - * // Restricting fields - * await api.getBots({ - * fields: ["id", "username"], - * }); - * // => - * { - * results: [ - * { - * id: '461521980492087297', - * username: 'Shiro' - * }, - * { - * id: '493716749342998541', - * username: 'Mimu' - * }, - * ... - * ], - * ... - * } - * ``` - * - * @param {BotsQuery} query Bot Query - * @returns {BotsResponse} Return response - */ - public async getBots(query?: BotsQuery): Promise { - if (query) { - if (Array.isArray(query.fields)) query.fields = query.fields.join(", "); - if (query.search instanceof Object) { - query.search = Object.entries(query.search) - .map(([key, value]) => `${key}: ${value}`) - .join(" "); + return { + votedAt: new Date(response.created_at), + expiresAt: new Date(response.expires_at), + weight: response.weight + }; + } catch (err) { + const topggError = err as TopGGAPIError; + + if (topggError.response.status === 404) { + return null; } + + throw err; } - return this._request("GET", "/bots", query); } - /** - * Get recent unique users who've voted - * - * @example - * ```js - * await api.getVotes(); - * // => - * [ - * { - * username: 'Xignotic', - * id: '205680187394752512', - * avatar: 'https://cdn.discordapp.com/avatars/1026525568344264724/cd70e62e41f691f1c05c8455d8c31e23.png' - * }, - * { - * username: 'iara', - * id: '395526710101278721', - * avatar: 'https://cdn.discordapp.com/avatars/1026525568344264724/cd70e62e41f691f1c05c8455d8c31e23.png' - * } - * ...more - * ] - * ``` - * - * @param {number} [page] The page number. Each page can only have at most 100 voters. - * @returns {ShortUser[]} Array of unique users who've voted - */ - public async getVotes(page?: number): Promise { - return this._request("GET", "/bots/votes", { page: page ?? 1 }); + private async _getVotesInner(options: { + since?: Date; + cursor?: string; + }): Promise { + const response = await this._request( + "GET", + `/projects/@me/votes?${options.since ? `startDate=${encodeURIComponent(options.since.toISOString())}` : `cursor=${options.cursor}`}` + ); + /* eslint-disable-next-line @typescript-eslint/no-this-alias */ + const self = this; + + return { + votes: response.data.map((vote: any) => ({ + voterId: vote.user_id, + platformId: vote.platform_id, + votedAt: new Date(vote.created_at), + expiresAt: new Date(vote.expires_at), + weight: vote.weight + })), + next: () => + self._getVotesInner({ + cursor: response.cursor + }) + }; } /** - * Get whether or not a user has voted in the last 12 hours + * Gets a cursor-based paginated list of votes for your project, ordered by creation date. * * @example * ```js - * await api.hasVoted("205680187394752512"); - * // => true/false - * ``` + * const since = new Date("2026-01-01"); * - * @param {Snowflake} id User ID - * @returns {boolean} Whether the user has voted in the last 12 hours - */ - public async hasVoted(id: Snowflake): Promise { - if (!id) throw new Error("Missing ID"); - return this._request("GET", "/bots/check", { userId: id }).then( - (x) => !!x.voted - ); - } - - /** - * Whether or not the weekend multiplier is active + * const firstPage = await client.getVotes(since); + * console.log(firstPage.votes); * - * @example - * ```js - * await api.isWeekend(); - * // => true/false + * const secondPage = await firstPage.next(); + * console.log(secondPage.votes); * ``` * - * @returns {boolean} Whether the multiplier is active + * @param {Date} since Timestamp to start fetching votes from. + * @returns {Promise} A cursor-based paginated list of votes for your project. */ - public async isWeekend(): Promise { - return this._request("GET", "/weekend").then((x) => x.is_weekend); + public async getVotes(since: Date): Promise { + return await this._getVotesInner({ since }); } } diff --git a/src/structs/Webhook.ts b/src/structs/Webhook.ts index d257ce8..585e711 100644 --- a/src/structs/Webhook.ts +++ b/src/structs/Webhook.ts @@ -1,6 +1,17 @@ +import type { Request, Response, NextFunction } from "express"; +import crypto from "node:crypto"; +import type { + IntegrationCreatePayload, + IntegrationDeletePayload, + PartialProject, + User, + VoteCreatePayload, + WebhookPayload, + WebhookPayloadType, + WebhookTestPayload +} from "../typings.js"; +import { API_VERSION } from "./Api.js"; import getBody from "raw-body"; -import { Request, Response, NextFunction } from "express"; -import { WebhookPayload } from "../typings"; export interface WebhookOptions { /** @@ -9,6 +20,11 @@ export interface WebhookOptions { * @default console.error */ error?: (error: Error) => void | Promise; + + /** + * The timeout for reading payloads in milliseconds. Defaults to five seconds. + */ + timeout?: number; } /** @@ -16,48 +32,121 @@ export interface WebhookOptions { * * @example * ```js - * const express = require("express"); - * const { Webhook } = require("@top-gg/sdk"); + * import { Webhook } from "@top-gg/sdk"; + * import express from "express"; * * const app = express(); - * const wh = new Webhook("webhookauth123"); + * const webhook = new Webhook(process.env.TOPGG_WEBHOOK_SECRET); * - * app.post("/dblwebhook", wh.listener((vote) => { - * // vote is your vote object e.g - * console.log(vote.user); // => 321714991050784770 + * // POST /webhook + * app.post("/webhook", webhook.listener((payload) => { + * console.log(payload); * })); * - * app.listen(80); - * - * // In this situation, your TopGG Webhook dashboard should look like - * // URL = http://your.server.ip:80/dblwebhook - * // Authorization: webhookauth123 + * app.listen(8080); * ``` * * @link {@link https://docs.top.gg/resources/webhooks/#schema | Webhook Data Schema} * @link {@link https://docs.top.gg/resources/webhoooks | Webhook Documentation} */ export class Webhook { + public secret: string; public options: WebhookOptions; /** * Create a new webhook client instance * - * @param authorization Webhook authorization to verify requests + * @param {string} secret The secret to verify requests */ - constructor(private authorization?: string, options: WebhookOptions = {}) { + constructor(secret: string, options: WebhookOptions = {}) { + this.secret = secret; this.options = { error: options.error ?? console.error, + timeout: options.timeout ?? 5000 + }; + } + + private _formatPartialProject(project: any): PartialProject { + return { + id: project.id, + type: project.type, + platform: project.platform, + platformId: project.platform_id + }; + } + + private _formatUser(user: any): User { + return { + id: user.id, + name: user.name, + avatar: user.avatar_url, + platformId: user.platform_id }; } private _formatIncoming( - body: WebhookPayload & { query: string } + body: { + type: WebhookPayloadType; + data: any; + }, + trace: string | string[] | undefined ): WebhookPayload { - const out: WebhookPayload = { ...body }; - if (body?.query?.length > 0) - out.query = Object.fromEntries(new URLSearchParams(body.query)); - return out; + let data; + + switch (body.type) { + case "integration.create": { + data = { + connectionId: body.data.connection_id, + secret: body.data.webhook_secret, + project: this._formatPartialProject(body.data.project), + user: this._formatUser(body.data.user) + } as IntegrationCreatePayload; + + this.secret = data.secret; + + break; + } + + case "integration.delete": { + data = { + connectionId: body.data.connection_id + } as IntegrationDeletePayload; + + break; + } + + case "vote.create": { + data = { + id: body.data.id, + weight: body.data.weight, + votedAt: new Date(body.data.created_at), + expiresAt: new Date(body.data.expires_at), + project: this._formatPartialProject(body.data.project), + user: this._formatUser(body.data.user) + } as VoteCreatePayload; + + break; + } + + case "webhook.test": { + data = { + project: this._formatPartialProject(body.data.project), + user: this._formatUser(body.data.user) + } as WebhookTestPayload; + + break; + } + + default: { + throw new Error(`Got an unrecognized payload type '${body.type}'.`); + } + } + + return { + type: body.type, + data, + trace + }; } private _parseRequest( @@ -65,26 +154,77 @@ export class Webhook { res: Response ): Promise { return new Promise((resolve) => { - if ( - this.authorization && - req.headers.authorization !== this.authorization - ) - return res.status(403).json({ error: "Unauthorized" }); - // parse json - - if (req.body) return resolve(this._formatIncoming(req.body)); - getBody(req, {}, (error, body) => { - if (error) return res.status(422).json({ error: "Malformed request" }); - - try { - const parsed = JSON.parse(body.toString("utf8")); - - resolve(this._formatIncoming(parsed)); - } catch (err) { - res.status(400).json({ error: "Invalid body" }); - resolve(false); + getBody( + req, + { + limit: 2 * 1024 * 1024 + }, + (error, body) => { + /* node:coverage ignore next 4 */ + if (error) { + res.status(400).json({ error: "Malformed request" }); + return resolve(false); + } + + let signatureHeader = req.headers["x-topgg-signature"]; + + /* node:coverage ignore next 3 */ + if (Array.isArray(signatureHeader)) { + signatureHeader = signatureHeader[0]; + } + + if (!signatureHeader) { + res.status(401).json({ error: "Missing signature" }); + return resolve(false); + } + + const parsedSignature = Object.fromEntries( + signatureHeader.split(",").map((part) => part.split("=")) + ); + const signature = parsedSignature[API_VERSION]; + + if (!parsedSignature.t || !signature) { + res.status(422).json({ error: "Invalid signature format" }); + return resolve(false); + } + + const hmac = crypto.createHmac("sha256", this.secret); + const digest = hmac + .update(`${parsedSignature.t}.${body}`) + .digest("hex"); + + if (signature !== digest) { + res.status(403).json({ error: "Invalid signature" }); + return resolve(false); + } + + const bodyString = body.toString("utf8"); + + try { + const parsed = JSON.parse(bodyString); + + return resolve( + this._formatIncoming(parsed, req.headers["x-topgg-trace"]) + ); + } catch (err: any) { + /* node:coverage ignore next 3 */ + console.warn( + `[WARNING] Unable to parse Top.gg webhook payload. Please report this bug to the SDK maintainers.\nCause: ${err.stack || err.message || err}\n--- BEGIN BODY DUMP ---\n${bodyString}\n--- END BODY DUMP ---` + ); + + res.sendStatus(204); + + return resolve(false); + } } - }); + ); + + setTimeout(() => { + req.destroy(); + res.status(408).json({ error: "Request timed out" }); + + resolve(false); + }, this.options.timeout); }); } @@ -93,17 +233,21 @@ export class Webhook { * * @example * ```js - * app.post("/webhook", wh.listener((vote) => { - * console.log(vote.user); // => 395526710101278721 + * // POST /webhook + * app.post("/webhook", webhook.listener((payload) => { + * console.log(payload); * })); * ``` * * @example * ```js + * // POST /webhook * // Throwing an error to resend the webhook - * app.post("/webhook/", wh.listener((vote) => { + * app.post("/webhook", webhook.listener((payload) => { * // for example, if your bot is offline, you should probably not handle votes and try again - * if (bot.offline) throw new Error('Bot offline'); + * if (bot.offline) { + * throw new Error("Bot offline"); + * } * })); * ``` * @@ -114,9 +258,9 @@ export class Webhook { public listener( fn: ( payload: WebhookPayload, - req?: Request, - res?: Response, - next?: NextFunction + req: Request, + res: Response, + next: NextFunction ) => void | Promise ) { return async ( @@ -129,10 +273,6 @@ export class Webhook { try { await fn(response, req, res, next); - - if (!res.headersSent) { - res.sendStatus(204); - } } catch (err) { if (err instanceof Error) this.options.error?.(err); @@ -140,30 +280,4 @@ export class Webhook { } }; } - - /** - * Middleware function to pass to express, sets req.vote to the payload - * - * @deprecated Use the new {@link Webhook.listener | .listener()} function - * @example - * ```js - * app.post("/dblwebhook", wh.middleware(), (req, res) => { - * // req.vote is your payload e.g - * console.log(req.vote.user); // => 395526710101278721 - * }); - * ``` - */ - public middleware() { - return async ( - req: Request, - res: Response, - next: NextFunction - ): Promise => { - const response = await this._parseRequest(req, res); - if (!response) return; - res.sendStatus(204); - req.vote = response; - next(); - }; - } } diff --git a/src/structs/Widget.ts b/src/structs/Widget.ts new file mode 100644 index 0000000..06129ef --- /dev/null +++ b/src/structs/Widget.ts @@ -0,0 +1,72 @@ +import type { Platform, ProjectType, Snowflake } from "../typings.js"; + +const BASE_URL: string = "https://top.gg/api/v1/widgets"; + +/** + * Widget generator functions. + */ +export class Widget { + /** + * Generates a large widget URL. + * + * @param {Platform} platform The project's platform. + * @param {WidgetType} projectType The project's type. + * @param {Snowflake} id The project's ID. + * @returns {string} The widget URL. + */ + public static large( + platform: Platform, + projectType: ProjectType, + id: Snowflake + ): string { + return `${BASE_URL}/large/${platform}/${projectType}/${id}`; + } + + /** + * Generates a small widget URL for displaying votes. + * + * @param {Platform} platform The project's platform. + * @param {WidgetType} projectType The project's type. + * @param {Snowflake} id The project's ID. + * @returns {string} The widget URL. + */ + public static votes( + platform: Platform, + projectType: ProjectType, + id: Snowflake + ): string { + return `${BASE_URL}/small/votes/${platform}/${projectType}/${id}`; + } + + /** + * Generates a small widget URL for displaying a project's owner. + * + * @param {Platform} platform The project's platform. + * @param {WidgetType} projectType The project's type. + * @param {Snowflake} id The project's ID. + * @returns {string} The widget URL. + */ + public static owner( + platform: Platform, + projectType: ProjectType, + id: Snowflake + ): string { + return `${BASE_URL}/small/owner/${platform}/${projectType}/${id}`; + } + + /** + * Generates a small widget URL for displaying social stats. + * + * @param {Platform} platform The project's platform. + * @param {WidgetType} projectType The project's type. + * @param {Snowflake} id The project's ID. + * @returns {string} The widget URL. + */ + public static social( + platform: Platform, + projectType: ProjectType, + id: Snowflake + ): string { + return `${BASE_URL}/small/social/${platform}/${projectType}/${id}`; + } +} diff --git a/src/typings.ts b/src/typings.ts index f891c54..028aeaa 100644 --- a/src/typings.ts +++ b/src/typings.ts @@ -1,240 +1,167 @@ +/** Discord ID */ +export type Snowflake = string; + export interface APIOptions { - /** Top.gg token */ + /** Top.gg API token */ token?: string; + /** Client ID to use */ + id?: Snowflake; } -/** Discord ID */ -export type Snowflake = string; +/** A user account from an external platform that is linked to a Top.gg user account. */ +export type UserSource = "discord" | "topgg"; + +/** A project's platform */ +export type Platform = "discord"; -export interface BotInfo { - /** The Top.gg ID of the bot */ +/** A project's type */ +export type ProjectType = "bot" | "server"; + +/** A webhook payload's type */ +export type WebhookPayloadType = + | "integration.create" + | "integration.delete" + | "webhook.test" + | "vote.create"; + +/** A project listed on Top.gg */ +export interface Project { + /** The project's ID */ id: Snowflake; - /** The Discord ID of the bot */ - clientid: Snowflake; - /** The username of the bot */ - username: string; - /** - * The discriminator of the bot - * - * @deprecated No longer supported by Top.gg API v0. - */ - discriminator: string; - /** The bot's avatar */ - avatar: string; - /** - * The cdn hash of the bot's avatar if the bot has none - * - * @deprecated No longer supported by Top.gg API v0. - */ - defAvatar: string; - /** - * The URL for the banner image - * - * @deprecated No longer supported by Top.gg API v0. - */ - bannerUrl?: string; - /** - * The library of the bot - * - * @deprecated No longer supported by Top.gg API v0. - */ - lib: string; - /** The prefix of the bot */ - prefix: string; - /** The short description of the bot */ - shortdesc: string; - /** The long description of the bot. Can contain HTML and/or Markdown */ - longdesc?: string; - /** The tags of the bot */ + /** The project's name sourced from the external platform */ + name: string; + /** The project's platform */ + platform: Platform; + /** The project's type */ + type: ProjectType; + /** The project's short description */ + headline: string; + /** The project's tag IDs */ tags: string[]; - /** The website url of the bot */ - website?: string; - /** The support url of the bot */ - support?: string; - /** The link to the github repo of the bot */ - github?: string; - /** The owners of the bot. First one in the array is the main owner */ - owners: Snowflake[]; - /** - * The guilds featured on the bot page - * - * @deprecated No longer supported by Top.gg API v0. - */ - guilds: Snowflake[]; - /** The custom bot invite url of the bot */ - invite?: string; - /** The date when the bot was submitted (in ISO 8601) */ - date: string; - /** - * The certified status of the bot - * - * @deprecated No longer supported by Top.gg API v0. - */ - certifiedBot: boolean; - /** The vanity url of the bot */ - vanity?: string; - /** The amount of votes the bot has */ - points: number; - /** The amount of votes the bot has this month */ - monthlyPoints: number; - /** - * The guild id for the donatebot setup - * - * @deprecated No longer supported by Top.gg API v0. - */ - donatebotguildid: Snowflake; - /** The amount of servers the bot is in based on posted stats */ - server_count?: number; - /** The bot's reviews on Top.gg */ - reviews: { - /** This bot's average review score out of 5 */ - averageScore: number; - /** This bot's review count */ + /** The project's vote information */ + votes: { + /** The project's current vote count that affects the project's ranking */ + current: number; + /** The project's total vote count */ + total: number; + }; + /** The project's review information */ + review: { + /** The project's review score out of 5 */ + score: number; + /** The project's total review count */ count: number; }; } -export interface BotStats { - /** The amount of servers the bot is in */ - serverCount?: number; - /** - * The amount of servers the bot is in per shard. Always present but can be - * empty. (Only when receiving stats) - * - * @deprecated No longer supported by Top.gg API v0. - */ - shards?: number[]; - /** - * The shard ID to post as (only when posting) - * - * @deprecated No longer supported by Top.gg API v0. - */ - shardId?: number; - /** - * The amount of shards a bot has - * - * @deprecated No longer supported by Top.gg API v0. - */ - shardCount?: number | null; +/** A brief information on a project listed on Top.gg */ +export interface PartialProject { + /** The project's ID */ + id: Snowflake; + /** The project's type */ + type: ProjectType; + /** The project's platform */ + platform: Platform; + /** The project's platform ID */ + platformId: Snowflake; } -/** - * @deprecated No longer supported by Top.gg API v0. - */ -export interface UserInfo { - /** The id of the user */ - id: Snowflake; - /** The username of the user */ - username: string; - /** The discriminator of the user */ - discriminator: string; - /** The user's avatar url */ - avatar: string; - /** The cdn hash of the user's avatar if the user has none */ - defAvatar: string; - /** The bio of the user */ - bio?: string; - /** The banner image url of the user */ - banner?: string; - /** The social usernames of the user */ - social: { - /** The youtube channel id of the user */ - youtube?: string; - /** The reddit username of the user */ - reddit?: string; - /** The twitter username of the user */ - twitter?: string; - /** The instagram username of the user */ - instagram?: string; - /** The github username of the user */ - github?: string; - }; - /** The custom hex color of the user */ - color: string; - /** The supporter status of the user */ - supporter: boolean; - /** The certified status of the user */ - certifiedDev: boolean; - /** The mod status of the user */ - mod: boolean; - /** The website moderator status of the user */ - webMod: boolean; - /** The admin status of the user */ - admin: boolean; +/** A brief information of a project's vote */ +export interface PartialVote { + /** When the vote was cast */ + votedAt: Date; + /** When the vote expires and the user is required to vote again */ + expiresAt: Date; + /** The vote's weight. 1 during weekdays, 2 during weekends. */ + weight: number; } -export interface BotsQuery { - /** The amount of bots to return. Max. 500 */ - limit?: number; - /** Amount of bots to skip */ - offset?: number; - /** A search string in the format of "field: value field2: value2" */ - search?: - | { - [key in keyof BotInfo]: string; - } - | string; - /** Sorts results from a specific criteria. Results will always be descending. */ - sort?: "monthlyPoints" | "id" | "date"; - /** A list of fields to show. */ - fields?: string[] | string; +/** A project's vote information */ +export interface Vote extends PartialVote { + /** The voter's ID */ + voterId: Snowflake; + /** The voter's ID on the project's platform */ + platformId: Snowflake; } -export interface BotsResponse { - /** The matching bots */ - results: BotInfo[]; - /** The limit used */ - limit: number; - /** The offset used */ - offset: number; - /** The length of the results array */ - count: number; - /** The total number of bots matching your search */ - total: number; +/** A paginated list of a project's vote information. */ +export interface PaginatedVotes { + /** The votes in this page */ + votes: Vote[]; + /** Tries to advance to the next page */ + next(): Promise; } -export interface ShortUser { - /** User's ID */ +/** A Top.gg user */ +export interface User { + /** The user's ID */ id: Snowflake; - /** User's username */ - username: string; - /** - * User's discriminator - * - * @deprecated No longer supported by Top.gg API v0. - */ - discriminator: string; - /** User's avatar url */ + /** The user's name */ + name: string; + /** The user's avatar URL */ avatar: string; + /** The user's platform ID */ + platformId: Snowflake; +} + +/** A `vote.create` webhook payload */ +export interface VoteCreatePayload { + /** The vote's ID */ + id: Snowflake; + /** The number of votes this vote counted for. This is a rounded integer value which determines how many points this individual vote was worth */ + weight: number; + /** When the vote was cast */ + votedAt: Date; + /** When the vote expires (the user can vote again) */ + expiresAt: Date; + /** The project that received this vote */ + project: PartialProject; + /** The user who voted for this project */ + user: User; +} + +/** An `integration.create` webhook payload */ +export interface IntegrationCreatePayload { + /** The unique identifier for this connection */ + connectionId: Snowflake; + /** The secret used to verify future webhook deliveries */ + secret: string; + /** The project that the integration refers to */ + project: PartialProject; + /** The user who triggered this event */ + user: User; +} + +/** An `integration.delete` webhook payload */ +export interface IntegrationDeletePayload { + /** The unique identifier for this connection */ + connectionId: Snowflake; +} + +/** A `webhook.test` webhook payload */ +export interface WebhookTestPayload { + /** The project that the test refers to */ + project: PartialProject; + /** The user who triggered this test */ + user: User; } +/** A webhook payload */ export interface WebhookPayload { - /** If webhook is a bot: ID of the bot that received a vote */ - bot?: Snowflake; - /** If webhook is a server: ID of the server that received a vote */ - guild?: Snowflake; - /** ID of the user who voted */ - user: Snowflake; - /** - * The type of the vote (should always be "upvote" except when using the test - * button it's "test") - */ - type: string; - /** - * Whether the weekend multiplier is in effect, meaning users votes count as - * two - */ - isWeekend?: boolean; - /** Query parameters in vote page in a key to value object */ - query: - | { - [key: string]: string; - } - | string; + /** The payload's type */ + type: WebhookPayloadType; + /** The payload's data */ + data: + | IntegrationCreatePayload + | IntegrationDeletePayload + | VoteCreatePayload + | WebhookTestPayload; + /** The payload's x-topgg-trace header for debugging and correlating requests with Top.gg support */ + trace: string | string[] | undefined; } declare module "express" { export interface Request { - vote?: WebhookPayload; + topggPayload?: WebhookPayload; } } diff --git a/src/utils/ApiError.ts b/src/utils/ApiError.ts index b4df924..fb321f0 100644 --- a/src/utils/ApiError.ts +++ b/src/utils/ApiError.ts @@ -1,20 +1,26 @@ -import type { Dispatcher } from "undici"; - const tips = { - 401: "You need a token for this endpoint", + 400: "Attempted to send an invalid request to the API", + 401: "Invalid Top.gg API token", 403: "You don't have access to this endpoint", + 404: "Route not found", + 429: "The client is blocked by the API. Please try again in a few moments", + 500: "Received an unexpected error from Top.gg's end" }; /** API Error */ export default class TopGGAPIError extends Error { - /** Possible response from Request */ - public response?: Dispatcher.ResponseData; - constructor(code: number, text: string, response: Dispatcher.ResponseData) { - if (code in tips) { - super(`${code} ${text} (${tips[code as keyof typeof tips]})`); + /** The response from the request */ + public response: Response; + constructor(text: string, response: Response) { + if (response.status in tips) { + super( + `${response.status} ${text} (${tips[response.status as keyof typeof tips]})` + ); + /* node:coverage ignore next 3 */ } else { - super(`${code} ${text}`); + super(`${response.status} ${text}`); } + this.name = "TopGGAPIError"; this.response = response; } } diff --git a/tests/Api.test.ts b/tests/Api.test.ts index f163b42..4b51f8c 100644 --- a/tests/Api.test.ts +++ b/tests/Api.test.ts @@ -1,68 +1,69 @@ -import { Api } from '../src/index'; -import ApiError from '../src/utils/ApiError'; -import { BOT, BOT_STATS, VOTES } from './mocks/data'; +import { deepStrictEqual, rejects, strictEqual } from "node:assert"; +import { it, describe } from "node:test"; -/* mock token */ -const client = new Api('.eyJpZCI6IjEwMjY1MjU1NjgzNDQyNjQ3MjQiLCJib3QiOnRydWV9.'); +import { MOCK_TOKEN, PARTIAL_VOTE, PROJECT, VOTE } from "./mocks/data"; +import { registerMocks } from "./mocks/index"; +import { Api, Widget } from "../src/index"; -describe('API postStats test', () => { - it('postStats without server count should throw error', async () => { - await expect(client.postStats({ shardCount: 0 })).rejects.toThrow(Error); - }); +const client = new Api(MOCK_TOKEN); - it('postStats with invalid negative server count should throw error', () => { - expect(client.postStats({ serverCount: -1 })).rejects.toThrow(Error); - }); +registerMocks(); - it('postStats should return 200', async () => { - await expect(client.postStats({ serverCount: 1 })).resolves.toBeInstanceOf( - Object - ); - }); +describe("API getSelf test", () => { + it("getSelf should work", async () => { + deepStrictEqual(await client.getSelf(), PROJECT); + }); }); -describe('API getStats test', () => { - it('getStats should return 200 when bot is found', async () => { - expect(client.getStats('1')).resolves.toStrictEqual({ - serverCount: BOT_STATS.server_count, - shardCount: BOT_STATS.shard_count, - shards: BOT_STATS.shards - }); - }); +describe("API postCommands test", () => { + it("postCommands should work", async () => { + strictEqual( + await client.postCommands([ + { + id: "1", + type: 1, + application_id: "1", + name: "test", + description: "command description", + default_member_permissions: "", + version: "1" + } + ]), + undefined + ); + }); }); -describe('API getBot test', () => { - it('getBot should return 404 when bot is not found', () => { - expect(client.getBot('0')).rejects.toThrow(ApiError); - }); +describe("API getVote test", () => { + it("getVote should work", async () => { + deepStrictEqual(await client.getVote("1"), PARTIAL_VOTE); + }); - it('getBot should return 200 when bot is found', async () => { - expect(client.getBot('1')).resolves.toStrictEqual(BOT); - }); + it("getVote should return null when an invalid id is provided", async () => { + strictEqual(await client.getVote("0"), null); + }); - it('getBot should throw when no id is provided', () => { - expect(client.getBot('')).rejects.toThrow(Error); - }); -}); + it("getVote should throw error when no id is provided", async () => { + await rejects(() => client.getVote(""), { name: "Error" }); + }); -describe('API getVotes test', () => { - it('getVotes should return 200 when token is provided', () => { - expect(client.getVotes()).resolves.toEqual(VOTES); - }); + it("getVote should throw error when no token is provided", async () => { + await rejects(() => new Api("").getVote("1"), { name: "TopGGAPIError" }); + }); }); -describe('API hasVoted test', () => { - it('hasVoted should return 200 when token is provided', () => { - expect(client.hasVoted('1')).resolves.toBe(true); - }); +describe("API getVotes test", () => { + it("getVotes should work", async () => { + const response = await client.getVotes(new Date("2026-01-01")); - it('hasVoted should throw error when no id is provided', () => { - expect(client.hasVoted('')).rejects.toThrow(Error); - }); + deepStrictEqual(response.votes, [VOTE]); + deepStrictEqual((await response.next()).votes, [VOTE]); + }); }); -describe('API isWeekend tests', () => { - it('isWeekend should return true', async () => { - expect(client.isWeekend()).resolves.toBe(true); - }); +describe("Widgets test", () => { + for (const type of ["large", "owner", "social", "votes"]) { + it(`${type} widget should work`, () => + (Widget as any)[type]("discord", "bot", "12345")); + } }); diff --git a/tests/Webhook.test.ts b/tests/Webhook.test.ts new file mode 100644 index 0000000..cef7898 --- /dev/null +++ b/tests/Webhook.test.ts @@ -0,0 +1,177 @@ +import { deepStrictEqual, strictEqual } from "node:assert"; +import { it, describe } from "node:test"; +import { Readable } from "node:stream"; + +import { + INTEGRATION_CREATE_PAYLOAD, + MOCK_WEBHOOK_SECRET, + MOCK_WEBHOOK_TRACE, + PAYLOADS +} from "./mocks/data"; +import httpMocks from "node-mocks-http"; +import type { Request } from "express"; +import { Webhook } from "../src/index"; +import { signature } from "./mocks"; + +const webhook = new Webhook(MOCK_WEBHOOK_SECRET); +const listener = webhook.listener((payload, _, response) => { + response.status(200).json({ + type: payload.type, + trace: payload.trace + }); +}); + +describe("Webhook error handling test", () => { + it("Listener should timeout when content-length is wrong", async () => { + const request = Object.assign(new Readable({ read() {} }), { + method: "POST", + headers: { + "content-type": "application/json", + "content-length": "2" + } + }); + + const response = httpMocks.createResponse(); + + await listener(request as any, response, () => {}); + + strictEqual(response._getStatusCode(), 408); + deepStrictEqual(response._getJSONData(), { + error: "Request timed out" + }); + }); + + it("Listener should return 401 when x-topgg-signature is not specified", async () => { + const request = Object.assign(Readable.from([Buffer.allocUnsafe(0)]), { + method: "POST", + headers: { + "content-type": "application/json", + "content-length": "0" + } + }); + + const response = httpMocks.createResponse(); + + await listener(request as any, response, () => {}); + + strictEqual(response._getStatusCode(), 401); + deepStrictEqual(response._getJSONData(), { + error: "Missing signature" + }); + }); + + it("Listener should return 422 when x-topgg-signature is not in the valid format", async () => { + const request = Object.assign(Readable.from([Buffer.allocUnsafe(0)]), { + method: "POST", + headers: { + "content-type": "application/json", + "content-length": "0", + "x-topgg-signature": "test" + } + }); + + const response = httpMocks.createResponse(); + + await listener(request as any, response, () => {}); + + strictEqual(response._getStatusCode(), 422); + deepStrictEqual(response._getJSONData(), { + error: "Invalid signature format" + }); + }); + + it("Listener should return 403 when x-topgg-signature does not match", async () => { + const body = Buffer.from("test"); + const request = Object.assign(Readable.from([body]), { + method: "POST", + headers: { + "content-type": "application/json", + "content-length": Buffer.byteLength(body).toString(), + "x-topgg-signature": signature(Buffer.from("test2")), + "x-topgg-trace": MOCK_WEBHOOK_TRACE + } + }); + + const response = httpMocks.createResponse(); + + await listener(request as any, response, () => {}); + + strictEqual(response._getStatusCode(), 403); + deepStrictEqual(response._getJSONData(), { + error: "Invalid signature" + }); + }); + + it("Listener should return 500 when an exception is thrown", async () => { + const faultyListener = webhook.listener(() => { + throw new Error("test"); + }); + + const body = Buffer.from(JSON.stringify(INTEGRATION_CREATE_PAYLOAD)); + const request = Object.assign(Readable.from([body]), { + method: "POST", + headers: { + "content-type": "application/json", + "content-length": Buffer.byteLength(body).toString(), + "x-topgg-signature": signature(body), + "x-topgg-trace": MOCK_WEBHOOK_TRACE + } + }); + + const response = httpMocks.createResponse(); + + await faultyListener(request as any, response, () => {}); + + strictEqual(response._getStatusCode(), 500); + }); + + it("Listener should return 204 when the body is not a valid JSON", async () => { + const body = Buffer.from( + JSON.stringify({ + type: "asdfghjkl" + }) + ); + const request = Object.assign(Readable.from([body]), { + method: "POST", + headers: { + "content-type": "application/json", + "content-length": Buffer.byteLength(body).toString(), + "x-topgg-signature": signature(body), + "x-topgg-trace": MOCK_WEBHOOK_TRACE + } + }); + + const response = httpMocks.createResponse(); + + await listener(request as any, response, () => {}); + + strictEqual(response._getStatusCode(), 204); + }); +}); + +describe("Webhook payload test", () => { + for (const payload of PAYLOADS) { + it(`${payload.type} payload handling should work`, async () => { + const body = Buffer.from(JSON.stringify(payload)); + const request = Object.assign(Readable.from([body]), { + method: "POST", + headers: { + "content-type": "application/json", + "content-length": Buffer.byteLength(body).toString(), + "x-topgg-signature": signature(body), + "x-topgg-trace": MOCK_WEBHOOK_TRACE + } + }); + + const response = httpMocks.createResponse(); + + await listener(request as any, response, () => {}); + + strictEqual(response._getStatusCode(), 200); + deepStrictEqual(response._getJSONData(), { + type: payload.type, + trace: MOCK_WEBHOOK_TRACE + }); + }); + } +}); diff --git a/tests/jest.setup.ts b/tests/jest.setup.ts deleted file mode 100644 index 7d4db81..0000000 --- a/tests/jest.setup.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { MockAgent, setGlobalDispatcher } from 'undici'; -import { MockInterceptor } from 'undici/types/mock-interceptor'; -import { endpoints } from './mocks/endpoints'; - -interface IOptions { - pattern: string; - requireAuth?: boolean; - validate?: (request: MockInterceptor.MockResponseCallbackOptions) => void; -} - -export const getIdInPath = (pattern: string, url: string) => { - const regex = new RegExp(`^${pattern.replace(/:[^/]+/g, '([^/]+)')}$`); - const match = url.match(regex); - - return match ? match[1] : null; -}; - -export const isMatchingPath = (pattern: string, url: string) => { - // Remove query params - url = url.split("?")[0]; - - if (pattern === url) { - return true; - } - - // Check if there is an exact match - if (endpoints.some(({ pattern }) => pattern === url)) { - return false; - } - - return getIdInPath(pattern, url) !== null; -}; - -beforeEach(() => { - const mockAgent = new MockAgent(); - mockAgent.disableNetConnect(); - const client = mockAgent.get('https://top.gg'); - - const generateResponse = (request: MockInterceptor.MockResponseCallbackOptions, statusCode: number, data: any, headers = {}, options: IOptions) => { - const error = options.validate?.(request); - if (error) return error; - - return { - statusCode, - data: JSON.stringify(data), - responseOptions: { - headers: { 'content-type': 'application/json', ...headers }, - } - } - } - - endpoints.forEach(({ pattern, method, data, requireAuth, validate }) => { - client.intercept({ - path: (path) => isMatchingPath(pattern, path), - method, - }).reply((request) => {return generateResponse(request, 200, data, {}, { pattern, requireAuth, validate })}); - }) - - client.intercept({ - path: (path) => !endpoints.some(({ pattern }) => isMatchingPath(pattern, path)), - method: (_) => true, - }).reply((request) => { - throw Error(`No endpoint found for ${request.method} ${request.path}`) - }) - - setGlobalDispatcher(mockAgent); -}); \ No newline at end of file diff --git a/tests/mocks/data.ts b/tests/mocks/data.ts index 65ec229..e4f3c1e 100644 --- a/tests/mocks/data.ts +++ b/tests/mocks/data.ts @@ -1,58 +1,151 @@ -// https://docs.top.gg/api/bot/#find-one-bot -export const BOT = { - invite: "https://top.gg/discord", - support: "https://discord.gg/dbl", - github: "https://github.com/top-gg", - longdesc: - "A bot to grant API access to our Library Developers on the Top.gg site without them needing to submit a bot to pass verification just to be able to access the API. \n" + - "\n" + - "Access to this bot's team can be requested by contacting a Community Manager in [our Discord server](https://top.gg/discord).", - shortdesc: "API access for Top.gg Library Developers", - prefix: "/", - clientid: "1026525568344264724", - avatar: "https://cdn.discordapp.com/avatars/1026525568344264724/cd70e62e41f691f1c05c8455d8c31e23.png", - id: "1026525568344264724", - username: "Top.gg Lib Dev API Access", - date: "2022-10-03T16:08:55.000Z", - server_count: 2, - monthlyPoints: 4, - points: 18, - owners: ["491002268401926145"], - tags: ["api", "library", "topgg"], - reviews: { averageScore: 5, count: 2 } -} - -// https://docs.top.gg/api/bot/#search-bots -export const BOTS = { - limit: 0, - offset: 0, - count: 1, - total: 1, - results: [BOT], -} - -// https://docs.top.gg/api/bot/#last-1000-votes -export const VOTES = [ - { - username: "Xetera", - id: "140862798832861184", - avatar: "https://cdn.discordapp.com/avatars/1026525568344264724/cd70e62e41f691f1c05c8455d8c31e23.png" +export const MOCK_TOKEN = ".eyJfdCI6IiIsImlkIjoiMzY0ODA2MDI5ODc2NTU1Nzc2In0=."; + +export const MOCK_WEBHOOK_SECRET = "testsecret1234"; +export const MOCK_WEBHOOK_TRACE = "trace"; + +export const RAW_PROJECT = { + id: "218109768489992192", + name: "Miki", + type: "bot", + platform: "discord", + headline: + "A great bot with tons of features! language | admin | cards | fun | levels | roles | marriage | currency | custom commands!", + tags: [ + "anime", + "customizable-behavior", + "economy", + "fun", + "game", + "leveling", + "multifunctional", + "role-management", + "roleplay", + "social" + ], + votes: 1120, + votes_total: 313389, + review_score: 4.38, + review_count: 62245 +}; + +export const PROJECT = { + id: RAW_PROJECT.id, + name: RAW_PROJECT.name, + type: RAW_PROJECT.type, + platform: RAW_PROJECT.platform, + headline: RAW_PROJECT.headline, + tags: RAW_PROJECT.tags, + votes: { + current: RAW_PROJECT.votes, + total: RAW_PROJECT.votes_total + }, + review: { + score: RAW_PROJECT.review_score, + count: RAW_PROJECT.review_count + } +}; + +export const RAW_PARTIAL_VOTE = { + created_at: "2025-09-09T08:55:16.218761+00:00", + expires_at: "2025-09-09T20:55:16.218761+00:00", + weight: 1 +}; + +export const PARTIAL_VOTE = { + votedAt: new Date(RAW_PARTIAL_VOTE.created_at), + expiresAt: new Date(RAW_PARTIAL_VOTE.expires_at), + weight: RAW_PARTIAL_VOTE.weight +}; + +export const RAW_VOTE = { + user_id: "1234567890", + platform_id: "1234567890", + created_at: "2025-09-09T08:55:16.218761+00:00", + expires_at: "2025-09-09T20:55:16.218761+00:00", + weight: 1 +}; + +export const VOTE = { + voterId: RAW_VOTE.user_id, + platformId: RAW_VOTE.platform_id, + votedAt: new Date(RAW_VOTE.created_at), + expiresAt: new Date(RAW_VOTE.expires_at), + weight: RAW_VOTE.weight +}; + +export const RAW_PAGINATED_VOTES = { + data: [RAW_VOTE], + cursor: "0123456789abcdef" +}; + +export const INTEGRATION_CREATE_PAYLOAD = { + type: "integration.create", + data: { + connection_id: "112402021105124", + webhook_secret: MOCK_WEBHOOK_SECRET, + project: { + id: "1230954036934033243", + platform: "discord", + platform_id: "3949456393249234923", + type: "bot" + }, + user: { + id: "3949456393249234923", + platform_id: "3949456393249234923", + name: "username", + avatar_url: "" + } + } +}; + +export const INTEGRATION_DELETE_PAYLOAD = { + type: "integration.delete", + data: { connection_id: "112402021105124" } +}; + +export const TEST_PAYLOAD = { + type: "webhook.test", + data: { + user: { + id: "160105994217586689", + platform_id: "160105994217586689", + name: "username", + avatar_url: "" + }, + project: { + id: "803190510032756736", + type: "bot", + platform: "discord", + platform_id: "160105994217586689" + } + } +}; + +export const VOTE_CREATE_PAYLOAD = { + type: "vote.create", + data: { + id: "808499215864008704", + weight: 1, + created_at: "2026-02-09T00:47:14.2510149+00:00", + expires_at: "2026-02-09T12:47:14.2510149+00:00", + project: { + id: "803190510032756736", + type: "bot", + platform: "discord", + platform_id: "160105994217586689" + }, + user: { + id: "160105994217586689", + platform_id: "160105994217586689", + name: "username", + avatar_url: "" + } } -] - -// https://docs.top.gg/api/bot/#bot-stats -export const BOT_STATS = { - server_count: 0, - shards: [], - shard_count: null -} - -// https://docs.top.gg/api/bot/#individual-user-vote -export const USER_VOTE = { - voted: 1 -} - -// Undocumented 😢 -export const WEEKEND = { - is_weekend: true -} +}; + +export const PAYLOADS = [ + INTEGRATION_CREATE_PAYLOAD, + INTEGRATION_DELETE_PAYLOAD, + TEST_PAYLOAD, + VOTE_CREATE_PAYLOAD +]; diff --git a/tests/mocks/endpoints.ts b/tests/mocks/endpoints.ts index ce1fca9..27c8662 100644 --- a/tests/mocks/endpoints.ts +++ b/tests/mocks/endpoints.ts @@ -1,53 +1,42 @@ -import { MockInterceptor } from 'undici/types/mock-interceptor'; -import { BOT, BOTS, BOT_STATS, USER_VOTE, VOTES, WEEKEND } from './data'; -import { getIdInPath } from '../jest.setup'; +import { RAW_PAGINATED_VOTES, RAW_PARTIAL_VOTE, RAW_PROJECT } from "./data"; +import type { MockInterceptor } from "undici/types/mock-interceptor"; +import { getIdInPath, type MockResponse } from "./index"; -export const endpoints = [ - { - pattern: '/api/bots', - method: 'GET', - data: BOTS, - requireAuth: true - }, - { - pattern: '/api/bots/:bot_id', - method: 'GET', - data: BOT, - requireAuth: true, - validate: (request: MockInterceptor.MockResponseCallbackOptions) => { - const bot_id = getIdInPath('/api/bots/:bot_id', request.path); - if (Number(bot_id) === 0) return { statusCode: 404 }; - return null; - } - }, - { - pattern: '/api/bots/votes', - method: 'GET', - data: VOTES, - requireAuth: true - }, - { - pattern: '/api/bots/check', - method: 'GET', - data: USER_VOTE, - requireAuth: true - }, - { - pattern: '/api/bots/stats', - method: 'GET', - data: BOT_STATS, - requireAuth: true - }, - { - pattern: '/api/bots/stats', - method: 'POST', - data: {}, - requireAuth: true - }, - { - pattern: '/api/weekend', - method: 'GET', - data: WEEKEND, - requireAuth: true +interface MockEndpoint { + pattern: string; + method: string; + data: any; + validate?: ( + request: MockInterceptor.MockResponseCallbackOptions + ) => MockResponse | null; +} + +export const endpoints: MockEndpoint[] = [ + { + pattern: "/api/v1/projects/@me", + method: "GET", + data: RAW_PROJECT + }, + { + pattern: "/api/v1/projects/@me/votes/:user_id", + method: "GET", + data: RAW_PARTIAL_VOTE, + validate: (request: MockInterceptor.MockResponseCallbackOptions) => { + const user_id = getIdInPath( + "/api/v1/projects/@me/votes/:user_id", + request.path + ); + return Number(user_id) === 0 ? { statusCode: 404 } : null; } -] \ No newline at end of file + }, + { + pattern: "/api/v1/projects/@me/commands", + method: "POST", + data: "" + }, + { + pattern: "/api/v1/projects/@me/votes", + method: "GET", + data: RAW_PAGINATED_VOTES + } +]; diff --git a/tests/mocks/index.ts b/tests/mocks/index.ts new file mode 100644 index 0000000..48cfdb6 --- /dev/null +++ b/tests/mocks/index.ts @@ -0,0 +1,97 @@ +import type { MockInterceptor } from "undici/types/mock-interceptor"; +import { MOCK_TOKEN, MOCK_WEBHOOK_SECRET } from "./data"; +import { MockAgent, setGlobalDispatcher } from "undici"; +import { endpoints } from "./endpoints"; +import { API_VERSION } from "../../src"; +import crypto from "node:crypto"; + +export interface MockResponse { + statusCode: number; + data?: string | object | Buffer | undefined; + responseOptions?: MockInterceptor.MockResponseOptions; +} + +export function getIdInPath(pattern: string, url: string) { + const regex = new RegExp(`^${pattern.replace(/:[^/]+/g, "([^/]+)")}$`); + const match = url.split("?")[0].match(regex); + + return match ? match[1] : null; +} + +export function isMatchingPath(pattern: string, url: string) { + // Remove query params + url = url.split("?")[0]; + + return ( + pattern === url || + (!endpoints.some(({ pattern }) => pattern === url) && + getIdInPath(pattern, url) !== null) + ); +} + +export function signature(body: Buffer) { + const timestamp = Math.floor(Date.now() / 1000); + + const hmac = crypto.createHmac("sha256", MOCK_WEBHOOK_SECRET); + const signature = hmac + .update(`${timestamp}.${body.toString("utf-8")}`) + .digest("hex"); + + return `t=${timestamp},${API_VERSION}=${signature}`; +} + +export function registerMocks() { + const mockAgent = new MockAgent(); + + mockAgent.disableNetConnect(); + + const client = mockAgent.get("https://top.gg"); + + endpoints.forEach(({ pattern, method, data, validate }) => { + client + .intercept({ + path: (path) => isMatchingPath(pattern, path), + method + }) + .reply((request) => { + const headers = new Headers(request.headers as any); + + if (request.method !== method) { + return { + statusCode: 405 + }; + } else if (headers.get("authorization") !== `Bearer ${MOCK_TOKEN}`) { + return { + statusCode: 401 + }; + } + + return ( + validate?.(request) ?? { + statusCode: 200, + data: typeof data === "string" ? data : JSON.stringify(data), + responseOptions: { + headers: { + "content-type": + typeof data === "string" ? "text/html" : "application/json" + } + } + } + ); + }) + .persist(); + }); + + client + .intercept({ + path: (path) => + !endpoints.some(({ pattern }) => isMatchingPath(pattern, path)), + method: (_) => true + }) + .reply((request) => { + throw Error(`No endpoint found for ${request.method} ${request.path}`); + }) + .persist(); + + setGlobalDispatcher(mockAgent); +} diff --git a/tsconfig.json b/tsconfig.json index 8e78a71..35496e1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,13 +1,15 @@ { - "files": ["./src/index.ts"], + "include": ["src/**/*.ts"], "compilerOptions": { - "moduleResolution": "node", + "moduleResolution": "nodenext", "noImplicitAny": true, "declaration": true, "strict": true, - "target": "ES2019", + "target": "esnext", + "rootDir": "./src", "outDir": "./dist", - "module": "commonjs", - "esModuleInterop": true + "module": "nodenext", + "esModuleInterop": true, + "isolatedModules": true } }