diff --git a/.eslintrc.js b/.eslintrc.js deleted file mode 100644 index fbe2908..0000000 --- a/.eslintrc.js +++ /dev/null @@ -1,7 +0,0 @@ -module.exports = { - ignorePatterns: ["node_modules/*", "docs/*", "dist/*"], - extends: "@top-gg/eslint-config", - parserOptions: { - project: "./tsconfig.json", - } -}; diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6232ab2..0c3e42f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -13,9 +13,9 @@ jobs: matrix: node: [18, 20] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v5 - name: Cache node_modules - uses: actions/cache@v3 + uses: actions/cache@v4 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@v5 with: node-version: ${{ matrix.node }} check-latest: true diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 2d6d3e7..d545b16 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -8,8 +8,8 @@ jobs: publish: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 + - uses: actions/checkout@v5 + - uses: actions/setup-node@v5 with: node-version: 18 check-latest: true diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index bce1efc..65d32d1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,9 +10,9 @@ jobs: test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v5 - name: Use Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v5 with: node-version: 18 check-latest: true diff --git a/.husky/pre-commit b/.husky/pre-commit index 20d0d06..a845b85 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,4 +1 @@ -#!/bin/sh -. "$(dirname "$0")/_/husky.sh" - -npm run lint +npm run lint \ No newline at end of file diff --git a/.npmignore b/.npmignore index 3f76e69..7bd816b 100644 --- a/.npmignore +++ b/.npmignore @@ -5,7 +5,7 @@ logs/ *.log !dist/ -.eslintrc.json +eslint.config.js .gitattributes .gitignore .github/ diff --git a/README.md b/README.md index 156cae7..dab0c59 100644 --- a/README.md +++ b/README.md @@ -1,62 +1,270 @@ -# Top.gg Node SDK +# Top.gg Node.js SDK -An official module for interacting with the Top.gg API +The community-maintained Node.js library for Top.gg. -# Installation +## Chapters -`yarn add @top-gg/sdk` or `npm i @top-gg/sdk` +- [Installation](#installation) +- [Setting up](#setting-up) +- [Usage](#usage) + - [API v1](#api-v1-1) + - [Getting your project's vote information of a user](#getting-your-projects-vote-information-of-a-user) + - [Posting your bot's application commands list](#posting-your-bots-application-commands-list) + - [API v0](#api-v0-1) + - [Getting a bot](#getting-a-bot) + - [Getting several bots](#getting-several-bots) + - [Getting your project's voters](#getting-your-projects-voters) + - [Check if a user has voted for your project](#check-if-a-user-has-voted-for-your-project) + - [Getting your bot's statistics](#getting-your-bots-statistics) + - [Posting your bot's statistics](#posting-your-bots-statistics) + - [Automatically posting your bot's statistics every few minutes](#automatically-posting-your-bots-statistics-every-few-minutes) + - [Checking if the weekend vote multiplier is active](#checking-if-the-weekend-vote-multiplier-is-active) + - [Generating widget URLs](#generating-widget-urls) + - [Webhooks](#webhooks) + - [Being notified whenever someone voted for your project](#being-notified-whenever-someone-voted-for-your-project) -# Introduction +## Installation -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. +### NPM -See [this tutorial](https://github.com/top-gg/rust-sdk/assets/60427892/d2df5bd3-bc48-464c-b878-a04121727bff) on how to retrieve your API token. +```sh +$ npm i @top-gg/sdk +``` + +### Yarn -You can also setup webhooks via Topgg.Webhook, look down below at the examples for how to do so! +```sh +$ yarn add @top-gg/sdk +``` -# Links +## Setting up -[Documentation](https://topgg.js.org) +### API v1 -[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) +> **NOTE**: API v1 also includes API v0. -# Popular Examples +```js +import Topgg from "@top-gg/sdk"; -## Auto-Posting stats +const client = new Topgg.V1Api(process.env.TOPGG_TOKEN); +``` -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) +### API v0 ```js -const client = Discord.Client(); // Your discord.js client or any other -const { AutoPoster } = require("topgg-autoposter"); +import Topgg from "@top-gg/sdk"; + +const client = new Topgg.Api(process.env.TOPGG_TOKEN); +``` + +## Usage + +### API v1 + +#### 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"); +``` + +#### Posting your bot's application commands list + +##### Discord.js + +```js +const commands = (await bot.application.commands.fetch()).map(cmd => cmd.toJSON()); + +await client.postCommands(commands); +``` + +##### Eris + +```js +const commands = await bot.getCommands(); + +await client.postCommands(commands); +``` -AutoPoster("topgg-token", client).on("posted", () => { - console.log("Posted stats to Top.gg!"); +##### Discordeno + +```js +import { getApplicationCommands } from "discordeno"; + +const commands = await getApplicationCommands(bot); + +await client.postCommands(commands); +``` + +##### Harmony + +```js +const commands = await bot.interactions.commands.all(); + +await client.postCommands(commands); +``` + +##### Oceanic + +```js +const commands = await bot.application.getGlobalCommands(); + +await client.postCommands(commands); +``` + +##### Raw + +```js +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 + } +]); +``` + +### API v0 + +#### Getting a bot + +```js +const bot = await client.getBot("461521980492087297"); +``` + +#### Getting several bots + +```js +const bots = await client.getBots(); +``` + +#### Getting your project's voters + +##### First page + +```js +const voters = await client.getVoters(); +``` + +##### Subsequent pages + +```js +const voters = await client.getVoters(2); +``` + +#### Check if a user has voted for your project + +```js +const hasVoted = await client.hasVoted("661200758510977084"); +``` + +#### Getting your bot's statistics + +```js +const stats = await client.getStats(); +``` + +#### Posting your bot's statistics + +```js + +await api.postStats({ + serverCount: bot.getServerCount(), +}); +``` + +#### Automatically posting your bot's statistics every few minutes + +You would need to use the third-party `topgg-autoposter` package to be able to autopost. Install it in your terminal like so: + +##### NPM + +```sh +$ npm i topgg-autoposter +``` + +##### Yarn + +```sh +$ yarn add topgg-autoposter +``` + +Then in your code: + +```js +import { AutoPoster } from "topgg-autoposter"; + +// Your discord.js client or any other +const client = Discord.Client(); + +AutoPoster(process.env.TOPGG_TOKEN, client).on("posted", () => { + console.log("Successfully posted statistics to Top.gg!"); }); ``` -With this your server count and shard count will be posted to Top.gg +#### Checking if the weekend vote multiplier is active + +```js +const isWeekend = await client.isWeekend(); +``` + +#### Generating widget URLs + +##### Large + +```js +const widgetUrl = Topgg.Widget.large(Topgg.WidgetType.DiscordBot, "574652751745777665"); +``` -## Webhook server +##### Votes ```js -const express = require("express"); -const Topgg = require("@top-gg/sdk"); +const widgetUrl = Topgg.Widget.votes(Topgg.WidgetType.DiscordBot, "574652751745777665"); +``` -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(Topgg.WidgetType.DiscordBot, "574652751745777665"); +``` -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(Topgg.WidgetType.DiscordBot, "574652751745777665"); ``` -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 + +#### Being notified whenever someone voted for your project + +With express: + +```js +import { Webhook } from "@top-gg/sdk"; +import express from "express"; + +const app = express(); +const webhook = new Webhook(process.env.TOPGG_WEBHOOK_PASSWORD); + +app.post("/votes", webhook.listener(vote => { + console.log(`A user with the ID of ${vote.user} has voted us on Top.gg!`); +})); + +app.listen(8080); +``` diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..7e89370 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,81 @@ +const js = require('@eslint/js'); +const ts = require("@typescript-eslint/eslint-plugin"); + +const tsPlugin = require('@typescript-eslint/eslint-plugin'); +const jestPlugin = require('eslint-plugin-jest'); + +module.exports = [ + { + ignores: ["node_modules/*", "docs/*", "dist/*"] + }, + { + ...js.configs.recommended, + files: ["src/**/*.ts"], + languageOptions: { + parser: require('@typescript-eslint/parser'), + parserOptions: { + project: './tsconfig.json', + }, + globals: { + es6: true, + browser: true, + node: true, + jest: true + } + }, + plugins: { + "@typescript-eslint": tsPlugin, + jest: jestPlugin + }, + rules: { + ...ts.configs.recommended.rules, + semi: "error", + "no-unreachable-loop": "warn", + "no-unsafe-optional-chaining": "warn", + eqeqeq: "error", + "no-alert": "error", + "prefer-spread": "error", + "no-duplicate-imports": "warn", + "no-eval": "error", + "no-implied-eval": "error", + "no-extend-native": "warn", + "no-new-wrappers": "error", + "no-proto": "error", + "no-script-url": "error", + "no-self-compare": "warn", + "no-useless-catch": "warn", + "no-throw-literal": "error", + "no-var": "warn", + "no-labels": "error", + "no-undefined": "off", + "no-new-object": "error", + "no-multi-assign": "warn", + "prefer-const": "warn", + "prefer-numeric-literals": "warn", + "prefer-object-spread": "error", + "prefer-rest-params": "error", + "prefer-exponentiation-operator": "error", + "no-lonely-if": "error", + radix: "warn", + camelcase: "warn", + "new-cap": "error", + quotes: ["warn", "double", { allowTemplateLiterals: true }], + "no-void": "error", + "spaced-comment": ["warn", "always"], + "eol-last": "warn", + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/prefer-optional-chain": "error", + "@typescript-eslint/prefer-for-of": "error", + "@typescript-eslint/no-namespace": [ + "error", + { allowDefinitionFiles: true } + ] + } + }, + { + files: ["*.browser.js"], + env: { + browser: true + } + } +]; diff --git a/package.json b/package.json index 94c010e..f2e0afe 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@top-gg/sdk", - "version": "3.1.6", - "description": "Official Top.gg Node SDK", + "version": "3.2.0", + "description": "A community-maintained Node.js API Client for the Top.gg API.", "main": "./dist/index.js", "scripts": { "test": "jest --verbose", @@ -10,8 +10,8 @@ "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", + "lint": "eslint", + "lint:ci": "eslint --output-file eslint_report.json --format json", "prepare": "npx husky install" }, "repository": { @@ -25,27 +25,28 @@ }, "homepage": "https://topgg.js.org", "devDependencies": { - "@top-gg/eslint-config": "^0.0.4", - "@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": "^9.31.0", + "@types/express": "^5.0.3", + "@types/jest": "^30.0.0", + "@types/node": "^24.0.14", + "@typescript-eslint/eslint-plugin": "^8.37.0", + "@typescript-eslint/parser": "^8.37.0", + "discord-api-types": "^0.38.23", + "eslint": "^9.31.0", + "eslint-config-prettier": "^10.1.5", + "eslint-plugin-jest": "^29.0.1", + "express": "^5.1.0", + "husky": "^9.1.7", + "jest": "^30.0.4", + "lint-staged": "^16.1.2", + "prettier": "^3.6.2", + "ts-jest": "^29.4.0", + "typedoc": "^0.28.7", + "typescript": "^5.8.3" }, "dependencies": { - "raw-body": "^2.5.2", - "undici": "^5.23.0" + "raw-body": "^3.0.0", + "undici": "^7.11.0" }, "types": "./dist/index.d.ts" } diff --git a/src/index.ts b/src/index.ts index 1d7c6b4..f32d5ae 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ export * from "./structs/Api"; export * from "./structs/Webhook"; +export * from "./structs/Widget"; export * from "./typings"; diff --git a/src/structs/Api.ts b/src/structs/Api.ts index 74aaf73..da2bf86 100644 --- a/src/structs/Api.ts +++ b/src/structs/Api.ts @@ -1,35 +1,38 @@ -import { request, type Dispatcher } from "undici"; +import type { APIApplicationCommand } from "discord-api-types/v10"; import type { IncomingHttpHeaders } from "undici/types/header"; -import ApiError from "../utils/ApiError"; +import { request, type Dispatcher } from "undici"; +import APIError from "../utils/ApiError"; import { EventEmitter } from "events"; import { STATUS_CODES } from "http"; import { APIOptions, Snowflake, - BotStats, BotInfo, - UserInfo, BotsResponse, + BotStats, ShortUser, BotsQuery, + Vote, + UserInfo, + UserSource, } from "../typings"; /** - * Top.gg API Client for Posting stats or Fetching data + * Top.gg API v0 client * * @example * ```js * const Topgg = require("@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://docs.top.gg | API Reference} */ export class Api extends EventEmitter { - private options: APIOptions; + protected options: APIOptions; /** * Create Top.gg API instance @@ -46,10 +49,11 @@ export class Api extends EventEmitter { throw new Error("Got a malformed API token."); } - const tokenData = atob(tokenSegments[1]); - try { - JSON.parse(tokenData).id; + const tokenData = JSON.parse(atob(tokenSegments[1])); + const tokenId = tokenData.id; + + options.id ??= tokenId; } catch { throw new Error( "Invalid API token state, this should not happen! Please report!" @@ -62,13 +66,13 @@ export class Api extends EventEmitter { }; } - private async _request( + protected async _request( method: Dispatcher.HttpMethod, path: string, body?: Record ): Promise { const headers: IncomingHttpHeaders = {}; - if (this.options.token) headers["authorization"] = this.options.token; + if (this.options.token) headers["authorization"] = `Bearer ${this.options.token}`; if (method !== "GET") headers["content-type"] = "application/json"; let url = `https://top.gg/api${path}`; @@ -81,23 +85,23 @@ export class Api extends EventEmitter { 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" + (response.headers["content-type"] as string)?.includes( + "json" ) ) { - responseBody = await response.body.json(); + responseBody = await response.body.json() as object; } else { responseBody = await response.body.text(); } if (response.statusCode < 200 || response.statusCode > 299) { - throw new ApiError( + throw new APIError( response.statusCode, STATUS_CODES[response.statusCode] ?? "", - response + responseBody ); } @@ -105,7 +109,7 @@ export class Api extends EventEmitter { } /** - * Post bot stats to Top.gg + * Post your bot's stats to Top.gg * * @example * ```js @@ -164,7 +168,7 @@ export class Api extends EventEmitter { * * @example * ```js - * await api.getBot("461521980492087297"); // returns bot info + * const bot = await client.getBot("461521980492087297"); * ``` * * @param {Snowflake} id Bot ID @@ -187,15 +191,15 @@ export class Api extends EventEmitter { * user.username; // Xignotic * ``` * - * @param {Snowflake} id User ID + * @param {Snowflake} _id User ID * @returns {UserInfo} Info for user - */ - public async getUser(id: Snowflake): Promise { - console.warn( - "[DeprecationWarning] getUser is no longer supported by Top.gg API v0." + */ // eslint-disable-next-line @typescript-eslint/no-unused-vars + public async getUser(_id: Snowflake): Promise { + throw new APIError( + 404, + STATUS_CODES[404]!, + "getUser is no longer supported by Top.gg API v0." ); - - return this._request("GET", `/users/${id}`); } /** @@ -203,46 +207,7 @@ export class Api extends EventEmitter { * * @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' - * }, - * ... - * ], - * ... - * } + * const bots = await client.getBots(); * ``` * * @param {BotsQuery} query Bot Query @@ -251,42 +216,27 @@ export class Api extends EventEmitter { 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 this._request("GET", "/bots", query); } /** - * Get recent unique users who've voted + * Get recent 100 unique voters * * @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 - * ] + * // First page + * const voters1 = await client.getVoters(); + * + * // Subsequent pages + * const voters2 = await client.getVoters(2); * ``` * - * @param {number} [page] The page number. Each page can only have at most 100 voters. - * @returns {ShortUser[]} Array of unique users who've voted + * @param {number} [page] The page number. Page numbers start at 1. Each page can only have at most 100 voters. + * @returns {ShortUser[]} Array of 100 unique voters */ public async getVotes(page?: number): Promise { - return this._request("GET", "/bots/votes", { page: page ?? 1 }); + return this._request("GET", `/bots/${this.options.id}/votes`, { page: page ?? 1 }); } /** @@ -294,8 +244,7 @@ export class Api extends EventEmitter { * * @example * ```js - * await api.hasVoted("205680187394752512"); - * // => true/false + * const hasVoted = await client.hasVoted("661200758510977084"); * ``` * * @param {Snowflake} id User ID @@ -303,6 +252,7 @@ export class Api extends EventEmitter { */ 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 ); @@ -313,8 +263,7 @@ export class Api extends EventEmitter { * * @example * ```js - * await api.isWeekend(); - * // => true/false + * const isWeekend = await client.isWeekend(); * ``` * * @returns {boolean} Whether the multiplier is active @@ -323,3 +272,115 @@ export class Api extends EventEmitter { return this._request("GET", "/weekend").then((x) => x.is_weekend); } } + +/** + * Top.gg API v1 client + * + * @example + * ```js + * const Topgg = require("@top-gg/sdk"); + * + * const client = new Topgg.V1Api(process.env.TOPGG_TOKEN); + * ``` + * + * @link {@link https://topgg.js.org | Library docs} + * @link {@link https://docs.top.gg | API Reference} + */ +export class V1Api extends Api { + /** + * Create Top.gg API instance + * + * @param {string} token Token or options + * @param {APIOptions} [options] API Options + */ + constructor(token: string, options: APIOptions = {}) { + super(token, options); + } + + /** + * Updates the application commands list in your Discord bot's Top.gg page. + * + * @example + * ```js + * // Discord.js: + * const commands = (await bot.application.commands.fetch()).map(cmd => cmd.toJSON()); + * + * // Eris: + * const commands = await bot.getCommands(); + * + * // Discordeno: + * import { getApplicationCommands } from "discordeno"; + * + * const commands = await getApplicationCommands(bot); + * + * // Harmony: + * const commands = await bot.interactions.commands.all(); + * + * // Oceanic: + * const commands = await bot.application.getGlobalCommands(); + * + * await client.postCommands(commands); + * + * // Raw: + * 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 {APIApplicationCommand[]} commands A list of application commands in raw Discord API JSON objects. This cannot be empty. + */ + public async postCommands(commands: APIApplicationCommand[]): Promise { + await this._request("POST", "/v1/projects/@me/commands", commands); + } + + /** + * Get the latest vote information of a Top.gg user on your project. + * + * @example + * ```js + * // Discord ID + * const vote = await client.getVote("661200758510977084"); + * + * // Top.gg ID + * const vote = await client.getVote("8226924471638491136", "topgg"); + * ``` + * + * @param {Snowflake} id The user's ID. + * @param {UserSource} source The ID type to use. Defaults to "discord". + * + * @returns {Vote | null} The user's latest vote information on your project or null if the user has not voted for your project in the past 12 hours. + */ + public async getVote(id: Snowflake, source: UserSource = "discord"): Promise { + if (!id) throw new Error("Missing ID"); + + try { + const response = await this._request("GET", `/v1/projects/@me/votes/${id}?source=${source}`); + + return { + votedAt: response.created_at, + expiresAt: response.expires_at, + weight: response.weight + }; + } catch (err) { + const topggError = err as APIError; + + if (topggError.statusCode === 404) { + return null; + } + + throw err; + } + } +} diff --git a/src/structs/Webhook.ts b/src/structs/Webhook.ts index d257ce8..c5ebb1b 100644 --- a/src/structs/Webhook.ts +++ b/src/structs/Webhook.ts @@ -69,7 +69,7 @@ export class Webhook { this.authorization && req.headers.authorization !== this.authorization ) - return res.status(403).json({ error: "Unauthorized" }); + return res.status(401).json({ error: "Unauthorized" }); // parse json if (req.body) return resolve(this._formatIncoming(req.body)); @@ -80,7 +80,7 @@ export class Webhook { const parsed = JSON.parse(body.toString("utf8")); resolve(this._formatIncoming(parsed)); - } catch (err) { + } catch { res.status(400).json({ error: "Invalid body" }); resolve(false); } diff --git a/src/structs/Widget.ts b/src/structs/Widget.ts new file mode 100644 index 0000000..011facc --- /dev/null +++ b/src/structs/Widget.ts @@ -0,0 +1,60 @@ +import { Snowflake } from "../typings"; + +const BASE_URL: string = "https://top.gg/api/v1/widgets"; + +/** + * Widget type. + */ +export enum WidgetType { + DiscordBot = "discord/bot", + DiscordServer = "discord/server" +} + +/** + * Widget generator functions. + */ +export class Widget { + /** + * Generates a large widget URL. + * + * @param {WidgetType} ty The widget type. + * @param {Snowflake} id The ID. + * @returns {string} The widget URL. + */ + public static large(ty: WidgetType, id: Snowflake): string { + return `${BASE_URL}/large/${ty}/${id}`; + } + + /** + * Generates a small widget URL for displaying votes. + * + * @param {WidgetType} ty The widget type. + * @param {Snowflake} id The ID. + * @returns {string} The widget URL. + */ + public static votes(ty: WidgetType, id: Snowflake): string { + return `${BASE_URL}/small/votes/${ty}/${id}`; + } + + /** + * Generates a small widget URL for displaying a project's owner. + * + * @param {WidgetType} ty The widget type. + * @param {Snowflake} id The ID. + * @returns {string} The widget URL. + */ + public static owner(ty: WidgetType, id: Snowflake): string { + return `${BASE_URL}/small/owner/${ty}/${id}`; + } + + /** + * Generates a small widget URL for displaying social stats. + * + * @param {WidgetType} ty The widget type. + * @param {Snowflake} id The ID. + * @returns {string} The widget URL. + */ + public static social(ty: WidgetType, id: Snowflake): string { + return `${BASE_URL}/small/social/${ty}/${id}`; + } +} diff --git a/src/typings.ts b/src/typings.ts index f891c54..a0b17c0 100644 --- a/src/typings.ts +++ b/src/typings.ts @@ -1,10 +1,16 @@ +/** 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"; export interface BotInfo { /** The Top.gg ID of the bot */ @@ -168,7 +174,11 @@ export interface BotsQuery { limit?: number; /** Amount of bots to skip */ offset?: number; - /** A search string in the format of "field: value field2: value2" */ + /** + * A search string in the format of "field: value field2: value2" + * + * @deprecated No longer supported by Top.gg API v0. + */ search?: | { [key in keyof BotInfo]: string; @@ -193,6 +203,15 @@ export interface BotsResponse { total: number; } +export interface Vote { + /** When the vote was cast */ + votedAt?: string; + /** When the vote expires and the user is required to vote again */ + expiresAt?: string; + /** This vote's weight. 1 during weekdays, 2 during weekends. */ + weight?: number; +} + export interface ShortUser { /** User's ID */ id: Snowflake; @@ -209,21 +228,15 @@ export interface ShortUser { } export interface WebhookPayload { - /** If webhook is a bot: ID of the bot that received a vote */ + /** If webhook is a Discord 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") - */ + /** 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 - */ + /** 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: diff --git a/src/utils/ApiError.ts b/src/utils/ApiError.ts index b4df924..8cca887 100644 --- a/src/utils/ApiError.ts +++ b/src/utils/ApiError.ts @@ -1,20 +1,25 @@ -import type { Dispatcher } from "undici"; - const tips = { 401: "You need a token for this endpoint", 403: "You don't have access to this endpoint", }; /** API Error */ -export default class TopGGAPIError extends Error { - /** Possible response from Request */ - public response?: Dispatcher.ResponseData; - constructor(code: number, text: string, response: Dispatcher.ResponseData) { +export default class APIError extends Error { + /** Response status code */ + public statusCode: number; + + /** Possible response body from Response */ + public body?: string | object; + + constructor(code: number, text: string, body?: string | object) { if (code in tips) { super(`${code} ${text} (${tips[code as keyof typeof tips]})`); } else { super(`${code} ${text}`); } - this.response = response; + + this.statusCode = code; + this.message = tips[code as keyof typeof tips] ?? text; + this.body = body; } } diff --git a/tests/Api.test.ts b/tests/Api.test.ts index f163b42..89259e7 100644 --- a/tests/Api.test.ts +++ b/tests/Api.test.ts @@ -3,7 +3,7 @@ import ApiError from '../src/utils/ApiError'; import { BOT, BOT_STATS, VOTES } from './mocks/data'; /* mock token */ -const client = new Api('.eyJpZCI6IjEwMjY1MjU1NjgzNDQyNjQ3MjQiLCJib3QiOnRydWV9.'); +const client = new Api('.eyJfdCI6IiIsImlkIjoiMzY0ODA2MDI5ODc2NTU1Nzc2In0=.'); describe('API postStats test', () => { it('postStats without server count should throw error', async () => { @@ -23,7 +23,7 @@ describe('API postStats test', () => { describe('API getStats test', () => { it('getStats should return 200 when bot is found', async () => { - expect(client.getStats('1')).resolves.toStrictEqual({ + expect(client.getStats()).resolves.toStrictEqual({ serverCount: BOT_STATS.server_count, shardCount: BOT_STATS.shard_count, shards: BOT_STATS.shards @@ -65,4 +65,4 @@ describe('API isWeekend tests', () => { it('isWeekend should return true', async () => { expect(client.isWeekend()).resolves.toBe(true); }); -}); +}); \ No newline at end of file diff --git a/tests/V1Api.test.ts b/tests/V1Api.test.ts new file mode 100644 index 0000000..eeff856 --- /dev/null +++ b/tests/V1Api.test.ts @@ -0,0 +1,29 @@ +import { V1Api } from '../src/index'; +import { VOTE } from './mocks/data'; + +/* mock token */ +const client = new V1Api('.eyJfdCI6IiIsImlkIjoiMzY0ODA2MDI5ODc2NTU1Nzc2In0=.'); + +describe('API postCommands test', () => { + it('postCommands should work', () => { + expect(client.postCommands([{ + id: '1', + type: 1, + application_id: '1', + name: 'test', + description: 'command description', + default_member_permissions: '', + version: '1' + }])).resolves.toBeUndefined(); + }); +}); + +describe('API getVote test', () => { + it('getVote should return 200 when token is provided', () => { + expect(client.getVote('1')).resolves.toStrictEqual(VOTE); + }); + + it('getVote should throw error when no id is provided', () => { + expect(client.getVote('')).rejects.toThrow(Error); + }); +}); diff --git a/tests/jest.setup.ts b/tests/jest.setup.ts index 7d4db81..f8af465 100644 --- a/tests/jest.setup.ts +++ b/tests/jest.setup.ts @@ -10,7 +10,7 @@ interface IOptions { export const getIdInPath = (pattern: string, url: string) => { const regex = new RegExp(`^${pattern.replace(/:[^/]+/g, '([^/]+)')}$`); - const match = url.match(regex); + const match = url.split('?')[0].match(regex); return match ? match[1] : null; }; diff --git a/tests/mocks/data.ts b/tests/mocks/data.ts index 65ec229..9f68ed0 100644 --- a/tests/mocks/data.ts +++ b/tests/mocks/data.ts @@ -31,6 +31,18 @@ export const BOTS = { results: [BOT], } +export const RAW_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 VOTE = { + votedAt: "2025-09-09T08:55:16.218761+00:00", + expiresAt: "2025-09-09T20:55:16.218761+00:00", + weight: 1 +}; + // https://docs.top.gg/api/bot/#last-1000-votes export const VOTES = [ { diff --git a/tests/mocks/endpoints.ts b/tests/mocks/endpoints.ts index ce1fca9..c7e8b62 100644 --- a/tests/mocks/endpoints.ts +++ b/tests/mocks/endpoints.ts @@ -1,5 +1,5 @@ import { MockInterceptor } from 'undici/types/mock-interceptor'; -import { BOT, BOTS, BOT_STATS, USER_VOTE, VOTES, WEEKEND } from './data'; +import { BOT, BOTS, BOT_STATS, RAW_VOTE, USER_VOTE, VOTES, WEEKEND } from './data'; import { getIdInPath } from '../jest.setup'; export const endpoints = [ @@ -21,10 +21,15 @@ export const endpoints = [ } }, { - pattern: '/api/bots/votes', + pattern: '/api/bots/:bot_id/votes', method: 'GET', data: VOTES, - requireAuth: true + requireAuth: true, + validate: (request: MockInterceptor.MockResponseCallbackOptions) => { + const bot_id = getIdInPath('/api/bots/:bot_id/votes', request.path); + if (Number(bot_id) === 0) return { statusCode: 404 }; + return null; + } }, { pattern: '/api/bots/check', @@ -49,5 +54,22 @@ export const endpoints = [ method: 'GET', data: WEEKEND, requireAuth: true + }, + { + pattern: '/api/v1/projects/@me/votes/:user_id', + method: 'GET', + data: RAW_VOTE, + requireAuth: true, + validate: (request: MockInterceptor.MockResponseCallbackOptions) => { + const user_id = getIdInPath('/api/v1/projects/@me/votes/:user_id', request.path); + if (Number(user_id) === 0) return { statusCode: 404 }; + return null; + } + }, + { + pattern: '/api/v1/projects/@me/commands', + method: 'POST', + data: {}, + requireAuth: true } ] \ No newline at end of file