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..0908897 100644 --- a/README.md +++ b/README.md @@ -1,62 +1,196 @@ -# 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: https://topgg.js.org. -# Installation +The community-maintained Node.js library 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) + - [Posting your bot's application commands list](#posting-your-bots-application-commands-list) + - [Generating widget URLs](#generating-widget-urls) + - [Webhooks](#webhooks) + - [Being notified whenever someone voted for your project](#being-notified-whenever-someone-voted-for-your-project) -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"); +``` + +### 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 -[Documentation](https://topgg.js.org) +```js +const commands = await bot.getCommands(); + +await client.postCommands(commands); +``` + +#### Discordeno -[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) +```js +import { getApplicationCommands } from "discordeno"; -# Popular Examples +const commands = await getApplicationCommands(bot); -## Auto-Posting stats +await client.postCommands(commands); +``` -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) +#### Harmony ```js -const client = Discord.Client(); // Your discord.js client or any other -const { AutoPoster } = require("topgg-autoposter"); +const commands = await bot.interactions.commands.all(); + +await client.postCommands(commands); +``` -AutoPoster("topgg-token", client).on("posted", () => { - console.log("Posted stats to Top.gg!"); -}); +#### 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 + } +]); ``` -With this your server count and shard count will be posted to Top.gg +### Generating widget URLs -## Webhook server +#### Large ```js -const express = require("express"); -const Topgg = require("@top-gg/sdk"); +const widgetUrl = Topgg.Widget.large(Topgg.WidgetType.DiscordBot, "574652751745777665"); +``` + +#### Votes + +```js +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); +``` \ No newline at end of file diff --git a/package.json b/package.json index 94c010e..9fce006 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": "4.0.0", + "description": "A community-maintained Node.js API Client for the Top.gg API.", "main": "./dist/index.js", "scripts": { "test": "jest --verbose", @@ -31,6 +31,7 @@ "@types/node": "^20.5.9", "@typescript-eslint/eslint-plugin": "^6.6.0", "@typescript-eslint/parser": "^6.6.0", + "discord-api-types": "^0.38.38", "eslint": "^8.48.0", "eslint-config-prettier": "^9.0.0", "eslint-plugin-jest": "^27.2.3", 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..9699335 100644 --- a/src/structs/Api.ts +++ b/src/structs/Api.ts @@ -1,28 +1,32 @@ -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 TopGGAPIError from "../utils/ApiError"; import { EventEmitter } from "events"; import { STATUS_CODES } from "http"; import { APIOptions, Snowflake, - BotStats, - BotInfo, - UserInfo, - BotsResponse, - ShortUser, - BotsQuery, + Vote, + UserSource, + Project, } from "../typings"; +/** 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"); - * - * 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} @@ -46,10 +50,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!" @@ -68,10 +73,10 @@ export class Api extends EventEmitter { 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}`; + let url = BASE_URL + path; if (body && method === "GET") url += `?${new URLSearchParams(body)}`; @@ -81,20 +86,20 @@ 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 TopGGAPIError( response.statusCode, STATUS_CODES[response.statusCode] ?? "", response @@ -105,221 +110,144 @@ export class Api extends EventEmitter { } /** - * 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"); - return { - serverCount: result.server_count, - shardCount: null, - shards: [], - }; - } + public async getSelf(): Promise { + const project = await this._request("GET", "/projects/@me"); - /** - * Get bot info - * - * @example - * ```js - * await api.getBot("461521980492087297"); // returns bot info - * ``` - * - * @param {Snowflake} id Bot ID - * @returns {BotInfo} Info for bot - */ - public async getBot(id: Snowflake): Promise { - if (!id) throw new Error("ID Missing"); - return this._request("GET", `/bots/${id}`); - } - - /** - * @deprecated No longer supported by Top.gg API v0. - * - * Get user info - * - * @example - * ```js - * await api.getUser("205680187394752512"); - * // => - * user.username; // Xignotic - * ``` - * - * @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." - ); - - return this._request("GET", `/users/${id}`); - } - - /** - * 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 { + 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 } - } - return this._request("GET", "/bots", query); + }; } /** - * Get recent unique users who've voted + * Updates the application commands list in your Discord bot's Top.gg page. * * @example * ```js - * await api.getVotes(); - * // => - * [ - * { - * username: 'Xignotic', - * id: '205680187394752512', - * avatar: 'https://cdn.discordapp.com/avatars/1026525568344264724/cd70e62e41f691f1c05c8455d8c31e23.png' - * }, + * // 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([ * { - * username: 'iara', - * id: '395526710101278721', - * avatar: 'https://cdn.discordapp.com/avatars/1026525568344264724/cd70e62e41f691f1c05c8455d8c31e23.png' + * 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 * } - * ...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 + * @param {APIApplicationCommand[]} commands A list of application commands in raw Discord API JSON objects. This cannot be empty. + * @returns {Promise} */ - public async getVotes(page?: number): Promise { - return this._request("GET", "/bots/votes", { page: page ?? 1 }); + public async postCommands(commands: APIApplicationCommand[]): Promise { + await this._request("POST", "/projects/@me/commands", commands); } /** - * Get whether or not a user has voted in the last 12 hours + * Gets the latest vote information of a Top.gg user on your project. * * @example * ```js - * await api.hasVoted("205680187394752512"); - * // => true/false + * // Discord ID + * const vote = await client.getVote("661200758510977084"); + * + * // Top.gg ID + * const vote = await client.getVote("8226924471638491136", "topgg"); * ``` * - * @param {Snowflake} id User ID - * @returns {boolean} Whether the user has voted in the last 12 hours + * @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 has not voted for your project in the past 12 hours. */ - public async hasVoted(id: Snowflake): Promise { + public async getVote(id: Snowflake, source: UserSource = "discord"): 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 - * - * @example - * ```js - * await api.isWeekend(); - * // => true/false - * ``` - * - * @returns {boolean} Whether the multiplier is active - */ - public async isWeekend(): Promise { - return this._request("GET", "/weekend").then((x) => x.is_weekend); + try { + const response = await this._request("GET", `/projects/@me/votes/${id}?source=${source}`); + + return { + votedAt: response.created_at, + expiresAt: response.expires_at, + weight: response.weight + }; + } catch (err) { + const topggError = err as TopGGAPIError; + + if (topggError.response?.statusCode === 404) { + return null; + } + + throw err; + } } } diff --git a/src/structs/Webhook.ts b/src/structs/Webhook.ts index d257ce8..cd75176 100644 --- a/src/structs/Webhook.ts +++ b/src/structs/Webhook.ts @@ -1,6 +1,8 @@ import getBody from "raw-body"; import { Request, Response, NextFunction } from "express"; +import crypto from "node:crypto"; import { WebhookPayload } from "../typings"; +import { API_VERSION } from "./Api"; export interface WebhookOptions { /** @@ -43,9 +45,9 @@ export class Webhook { /** * Create a new webhook client instance * - * @param authorization Webhook authorization to verify requests + * @param {string} authorization Webhook authorization to verify requests */ - constructor(private authorization?: string, options: WebhookOptions = {}) { + constructor(private authorization: string, options: WebhookOptions = {}) { this.options = { error: options.error ?? console.error, }; @@ -65,22 +67,44 @@ 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" }); + if (error) { + res.status(422).json({ error: "Malformed request" }); + return resolve(false); + } + + let signatureHeader = req.headers["x-topgg-signature"]; + + if (Array.isArray(signatureHeader)) { + signatureHeader = signatureHeader[0]; + } + + if (!signatureHeader) { + res.status(401).json({ error: "Missing Top.gg 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(400).send({ error: "Invalid signature format" }); + return resolve(false); + } + + const hmac = crypto.createHmac("sha256", this.authorization); + const digest = hmac.update(`${parsedSignature.t}.${body}`).digest("hex"); + + if (signature !== digest) { + res.status(401).json({ error: "Invalid Authorization" }); + return resolve(false); + } try { 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..d9d3a8d 100644 --- a/src/typings.ts +++ b/src/typings.ts @@ -1,229 +1,73 @@ +/** 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 */ - 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 */ - 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 */ - count: number; - }; -} +/** A project's source platform */ +export type Platform = "discord"; -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 project's type */ +export type Type = "bot" | "server"; -/** - * @deprecated No longer supported by Top.gg API v0. - */ -export interface UserInfo { - /** The id of the user */ +/** A project listed on Top.gg */ +export interface Project { + /** The project's Top.gg ID */ 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 project's name sourced from the external platform */ + name: string; + /** The project's source platform */ + platform: Platform; + /** The project's type */ + type: Type; + /** The project's short description */ + headline: string; + /** The project's tag IDs */ + tags: string[]; + /** 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; }; - /** 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; -} - -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; -} - -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; } -export interface ShortUser { - /** 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 */ - avatar: string; +/** A project's vote information */ +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 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/tests/Api.test.ts b/tests/Api.test.ts index f163b42..a227966 100644 --- a/tests/Api.test.ts +++ b/tests/Api.test.ts @@ -1,68 +1,35 @@ import { Api } from '../src/index'; -import ApiError from '../src/utils/ApiError'; -import { BOT, BOT_STATS, VOTES } from './mocks/data'; +import { PROJECT, VOTE } 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 () => { - await expect(client.postStats({ shardCount: 0 })).rejects.toThrow(Error); - }); - - it('postStats with invalid negative server count should throw error', () => { - expect(client.postStats({ serverCount: -1 })).rejects.toThrow(Error); - }); - - it('postStats should return 200', async () => { - await expect(client.postStats({ serverCount: 1 })).resolves.toBeInstanceOf( - Object - ); +describe('API getSelf test', () => { + it('getSelf should work', () => { + expect(client.getSelf()).resolves.toStrictEqual(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', () => { + expect(client.postCommands([{ + id: '1', + type: 1, + application_id: '1', + name: 'test', + description: 'command description', + default_member_permissions: '', + version: '1' + }])).resolves.toBeUndefined(); }); }); -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 return 200 when token is provided', () => { + expect(client.getVote('1')).resolves.toStrictEqual(VOTE); }); - it('getBot should return 200 when bot is found', async () => { - expect(client.getBot('1')).resolves.toStrictEqual(BOT); - }); - - it('getBot should throw when no id is provided', () => { - expect(client.getBot('')).rejects.toThrow(Error); - }); -}); - -describe('API getVotes test', () => { - it('getVotes should return 200 when token is provided', () => { - expect(client.getVotes()).resolves.toEqual(VOTES); - }); -}); - -describe('API hasVoted test', () => { - it('hasVoted should return 200 when token is provided', () => { - expect(client.hasVoted('1')).resolves.toBe(true); - }); - - it('hasVoted should throw error when no id is provided', () => { - expect(client.hasVoted('')).rejects.toThrow(Error); - }); -}); - -describe('API isWeekend tests', () => { - it('isWeekend should return true', async () => { - expect(client.isWeekend()).resolves.toBe(true); + 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..6bf1568 100644 --- a/tests/mocks/data.ts +++ b/tests/mocks/data.ts @@ -1,58 +1,59 @@ -// 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 } -} +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 +}; -// 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 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_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: RAW_VOTE.created_at, + expiresAt: RAW_VOTE.expires_at, + weight: RAW_VOTE.weight +}; // 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 -} +} \ No newline at end of file diff --git a/tests/mocks/endpoints.ts b/tests/mocks/endpoints.ts index ce1fca9..16012ff 100644 --- a/tests/mocks/endpoints.ts +++ b/tests/mocks/endpoints.ts @@ -1,53 +1,29 @@ import { MockInterceptor } from 'undici/types/mock-interceptor'; -import { BOT, BOTS, BOT_STATS, USER_VOTE, VOTES, WEEKEND } from './data'; +import { RAW_PROJECT, RAW_VOTE } from './data'; import { getIdInPath } from '../jest.setup'; export const endpoints = [ { - pattern: '/api/bots', + pattern: '/api/v1/projects/@me', method: 'GET', - data: BOTS, + data: RAW_PROJECT, requireAuth: true }, { - pattern: '/api/bots/:bot_id', + pattern: '/api/v1/projects/@me/votes/:user_id', method: 'GET', - data: BOT, + data: RAW_VOTE, requireAuth: true, validate: (request: MockInterceptor.MockResponseCallbackOptions) => { - const bot_id = getIdInPath('/api/bots/:bot_id', request.path); - if (Number(bot_id) === 0) return { statusCode: 404 }; + 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/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', + pattern: '/api/v1/projects/@me/commands', method: 'POST', data: {}, requireAuth: true - }, - { - pattern: '/api/weekend', - method: 'GET', - data: WEEKEND, - requireAuth: true } ] \ No newline at end of file