From 26a58c2119951e47cdcc42fc05643f24b3adab26 Mon Sep 17 00:00:00 2001 From: null8626 Date: Mon, 26 May 2025 00:29:12 +0700 Subject: [PATCH 01/29] fix: fix GET /bots/votes not working --- src/structs/Api.ts | 9 +++++---- src/typings.ts | 3 +++ 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/structs/Api.ts b/src/structs/Api.ts index 74aaf73..0dc25e6 100644 --- a/src/structs/Api.ts +++ b/src/structs/Api.ts @@ -46,10 +46,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 = atob(tokenSegments[1]); + const tokenId = JSON.parse(tokenData).id; + + options.id ??= tokenId; } catch { throw new Error( "Invalid API token state, this should not happen! Please report!" @@ -286,7 +287,7 @@ export class Api extends EventEmitter { * @returns {ShortUser[]} Array of unique users who've voted */ 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 }); } /** diff --git a/src/typings.ts b/src/typings.ts index f891c54..c7e04b3 100644 --- a/src/typings.ts +++ b/src/typings.ts @@ -1,6 +1,9 @@ export interface APIOptions { /** Top.gg token */ token?: string; + + /** Discord bot ID */ + id?: string; } /** Discord ID */ From d2cb2ce7765cfa6052c596f131b803ecbe432b30 Mon Sep 17 00:00:00 2001 From: null8626 Date: Tue, 17 Jun 2025 16:29:31 +0700 Subject: [PATCH 02/29] feat: add widgets --- src/index.ts | 1 + src/structs/Api.ts | 14 ++------------ src/structs/Widget.ts | 18 ++++++++++++++++++ src/typings.ts | 6 +++++- tests/Api.test.ts | 2 +- tests/jest.setup.ts | 2 +- tests/mocks/endpoints.ts | 9 +++++++-- 7 files changed, 35 insertions(+), 17 deletions(-) create mode 100644 src/structs/Widget.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 0dc25e6..de51ebc 100644 --- a/src/structs/Api.ts +++ b/src/structs/Api.ts @@ -72,7 +72,7 @@ export class Api extends EventEmitter { if (this.options.token) headers["authorization"] = this.options.token; if (method !== "GET") headers["content-type"] = "application/json"; - let url = `https://top.gg/api${path}`; + let url = `https://top.gg/api/v1${path}`; if (body && method === "GET") url += `?${new URLSearchParams(body)}`; @@ -204,12 +204,7 @@ export class Api extends EventEmitter { * * @example * ```js - * // Finding by properties - * await api.getBots({ - * search: { - * username: "shiro" - * }, - * }); + * await api.getBots(); * // => * { * results: [ @@ -252,11 +247,6 @@ 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); } diff --git a/src/structs/Widget.ts b/src/structs/Widget.ts new file mode 100644 index 0000000..daabe85 --- /dev/null +++ b/src/structs/Widget.ts @@ -0,0 +1,18 @@ +import { Snowflake } from "../typings"; + +const BASE_URL: string = "https://top.gg/api/v1"; + +/** + * Widget generator functions. + */ +export class Widget { + /** + * Generates a large widget URL. + * + * @param {Snowflake} id The ID. + * @returns {string} The widget URL. + */ + public static large(id: Snowflake): string { + return `${BASE_URL}/widgets/large/${id}`; + } +} diff --git a/src/typings.ts b/src/typings.ts index c7e04b3..5ca8965 100644 --- a/src/typings.ts +++ b/src/typings.ts @@ -171,7 +171,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 v1. + */ search?: | { [key in keyof BotInfo]: string; diff --git a/tests/Api.test.ts b/tests/Api.test.ts index f163b42..0d6dda3 100644 --- a/tests/Api.test.ts +++ b/tests/Api.test.ts @@ -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 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/endpoints.ts b/tests/mocks/endpoints.ts index ce1fca9..868fc18 100644 --- a/tests/mocks/endpoints.ts +++ b/tests/mocks/endpoints.ts @@ -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', From 2327b15f7863d76fcec0b6738e41ac75cb2efb73 Mon Sep 17 00:00:00 2001 From: null8626 Date: Wed, 18 Jun 2025 21:00:24 +0700 Subject: [PATCH 03/29] feat: add small widgets --- src/structs/Widget.ts | 46 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/src/structs/Widget.ts b/src/structs/Widget.ts index daabe85..084d77a 100644 --- a/src/structs/Widget.ts +++ b/src/structs/Widget.ts @@ -2,6 +2,14 @@ import { Snowflake } from "../typings"; const BASE_URL: string = "https://top.gg/api/v1"; +/** + * Widget type. + */ +export enum WidgetType { + DiscordBot = "discord/bot", + DiscordServer = "discord/server" +} + /** * Widget generator functions. */ @@ -9,10 +17,44 @@ 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}/widgets/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}/widgets/small/votes/${ty}/${id}`; + } + + /** + * Generates a small widget URL for displaying an entity'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}/widgets/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 large(id: Snowflake): string { - return `${BASE_URL}/widgets/large/${id}`; + public static social(ty: WidgetType, id: Snowflake): string { + return `${BASE_URL}/widgets/small/social/${ty}/${id}`; } } From 13982a58e0618ff14a6d737cb1809de1eac60a0c Mon Sep 17 00:00:00 2001 From: null <60427892+null8626@users.noreply.github.com> Date: Mon, 23 Jun 2025 18:51:11 +0700 Subject: [PATCH 04/29] style: remove trailing comma --- src/structs/Api.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/structs/Api.ts b/src/structs/Api.ts index de51ebc..022bb88 100644 --- a/src/structs/Api.ts +++ b/src/structs/Api.ts @@ -111,7 +111,7 @@ export class Api extends EventEmitter { * @example * ```js * await api.postStats({ - * serverCount: 28199, + * serverCount: 28199 * }); * ``` * From 46ef8d168174208c294b4597406c8ded09267cb1 Mon Sep 17 00:00:00 2001 From: null8626 Date: Mon, 23 Jun 2025 23:34:01 +0700 Subject: [PATCH 05/29] docs: readme overhaul --- README.md | 161 +++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 129 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 156cae7..d91dc94 100644 --- a/README.md +++ b/README.md @@ -1,62 +1,159 @@ -# 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 +## Installation -`yarn add @top-gg/sdk` or `npm i @top-gg/sdk` +### NPM -# Introduction +```sh +$ npm i @top-gg/sdk +``` + +### Yarn + +```sh +$ yarn add @top-gg/sdk +``` + +## Setting up + +### CommonJS + +```js +const Topgg = require("@top-gg/sdk"); + +const client = new Topgg.Api(process.env.TOPGG_TOKEN); +``` + +### ES module + +```js +import Topgg from "@top-gg/sdk"; + +const client = new Topgg.Api(process.env.TOPGG_TOKEN); +``` + +## Usage + +### Getting a bot + +```js +const bot = await client.getBot("461521980492087297"); +``` + +### Getting several bots + +```js +const bots = await client.getBots(); +``` + +### Getting your bot's voters + +#### First page + +```js +const voters = await client.getVotes(); +``` + +#### Subsequent pages + +```js +const voters = await client.getVotes(2); +``` -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. +### Check if a user has voted for your bot -See [this tutorial](https://github.com/top-gg/rust-sdk/assets/60427892/d2df5bd3-bc48-464c-b878-a04121727bff) on how to retrieve your API token. +```js +const hasVoted = await client.hasVoted("205680187394752512"); +``` -You can also setup webhooks via Topgg.Webhook, look down below at the examples for how to do so! +### Getting your bot's server count -# Links +```js +const { serverCount } = await client.getStats(); +``` -[Documentation](https://topgg.js.org) +### Posting your bot's server count -[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 +await client.postStats({ + serverCount: bot.getServerCount() +}); +``` -# Popular Examples +### Automatically posting your bot's server count every few minutes -## Auto-Posting stats +You would need to use the third-party `topgg-autoposter` package to be able to autopost. Install it in your terminal like so: -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) +#### NPM + +```sh +$ npm i topgg-autoposter +``` + +#### Yarn + +```sh +$ yarn add topgg-autoposter +``` + +Then in your code: + +#### CommonJS ```js -const client = Discord.Client(); // Your discord.js client or any other const { AutoPoster } = require("topgg-autoposter"); -AutoPoster("topgg-token", client).on("posted", () => { +// Your discord.js client or any other +const client = Discord.Client(); + +AutoPoster(process.env.TOPGG_TOKEN, client).on("posted", () => { + console.log("Posted stats to Top.gg!"); +}); +``` + +#### ES module + +```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("Posted stats 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(); +``` -## Webhook server +### Generating widget URLs + +#### Large ```js -const express = require("express"); -const Topgg = require("@top-gg/sdk"); +const widgetUrl = Topgg.Widget.large(Topgg.WidgetType.DiscordBot, "574652751745777665"); +``` -const app = express(); // Your express app +#### Votes -const webhook = new Topgg.Webhook("topggauth123"); // add your Top.gg webhook authorization (not bot token) +```js +const widgetUrl = Topgg.Widget.votes(Topgg.WidgetType.DiscordBot, "574652751745777665"); +``` -app.post( - "/dblwebhook", - webhook.listener((vote) => { - // vote is your vote object - console.log(vote.user); // 221221226561929217 - }) -); // attach the middleware +#### Owner -app.listen(3000); // your port +```js +const widgetUrl = Topgg.Widget.owner(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) +#### Social + +```js +const widgetUrl = Topgg.Widget.social(Topgg.WidgetType.DiscordBot, "574652751745777665"); +``` \ No newline at end of file From db99c598d135708fc43c3c0e3beb66c563a015ea Mon Sep 17 00:00:00 2001 From: null8626 Date: Tue, 24 Jun 2025 20:10:56 +0700 Subject: [PATCH 06/29] feat: make webhooks more specific --- .husky/pre-commit | 3 - README.md | 38 ++++++++++++ src/structs/Webhook.ts | 127 ++++++++++++++++++--------------------- src/typings.ts | 18 ++---- tests/mocks/endpoints.ts | 18 +++--- 5 files changed, 109 insertions(+), 95 deletions(-) diff --git a/.husky/pre-commit b/.husky/pre-commit index 20d0d06..3867a0f 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,4 +1 @@ -#!/bin/sh -. "$(dirname "$0")/_/husky.sh" - npm run lint diff --git a/README.md b/README.md index d91dc94..996d506 100644 --- a/README.md +++ b/README.md @@ -156,4 +156,42 @@ const widgetUrl = Topgg.Widget.owner(Topgg.WidgetType.DiscordBot, "5746527517457 ```js const widgetUrl = Topgg.Widget.social(Topgg.WidgetType.DiscordBot, "574652751745777665"); +``` + +### Webhooks + +#### Being notified whenever someone voted for your bot + +With express: + +##### CommonJS + +```js +const { Webhook } = require("@top-gg/sdk"); +const express = require("express"); + +const app = express(); +const webhook = new Webhook(process.env.MY_TOPGG_WEBHOOK_SECRET); + +app.post("/votes", webhook.voteListener(vote => { + console.log(`A user with the ID of ${vote.voterId} has voted us on Top.gg!`); +})); + +app.listen(8080); +``` + +##### ES module + +```js +import { Webhook } from "@top-gg/sdk"; +import express from "express"; + +const app = express(); +const webhook = new Webhook(process.env.MY_TOPGG_WEBHOOK_SECRET); + +app.post("/votes", webhook.voteListener(vote => { + console.log(`A user with the ID of ${vote.voterId} has voted us on Top.gg!`); +})); + +app.listen(8080); ``` \ No newline at end of file diff --git a/src/structs/Webhook.ts b/src/structs/Webhook.ts index d257ce8..f0d1b54 100644 --- a/src/structs/Webhook.ts +++ b/src/structs/Webhook.ts @@ -1,10 +1,10 @@ import getBody from "raw-body"; import { Request, Response, NextFunction } from "express"; -import { WebhookPayload } from "../typings"; +import { WebhookVotePayload } from "../typings"; export interface WebhookOptions { /** - * Handles an error created by the function passed to Webhook.listener() + * Handles an error created by the function passed to webhook listeners * * @default console.error */ @@ -22,15 +22,15 @@ export interface WebhookOptions { * const app = express(); * const wh = new Webhook("webhookauth123"); * - * app.post("/dblwebhook", wh.listener((vote) => { + * app.post("/votes", wh.voteListener((vote) => { * // vote is your vote object e.g - * console.log(vote.user); // => 321714991050784770 + * console.log(vote.voterId); // => 321714991050784770 * })); * - * app.listen(80); + * app.listen(8080); * - * // In this situation, your TopGG Webhook dashboard should look like - * // URL = http://your.server.ip:80/dblwebhook + * // In this situation, your Top.gg Webhook dashboard should look like + * // URL = http://your.server.ip:8080/votes * // Authorization: webhookauth123 * ``` * @@ -45,41 +45,41 @@ export class Webhook { * * @param 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, + error: options.error ?? console.error }; } - private _formatIncoming( - body: WebhookPayload & { query: string } - ): WebhookPayload { - const out: WebhookPayload = { ...body }; - if (body?.query?.length > 0) - out.query = Object.fromEntries(new URLSearchParams(body.query)); - return out; + private _formatVotePayload(body: any): WebhookVotePayload { + return { + receiverId: (body.bot ?? body.guild)!, + voterId: body.user, + type: body.type, + isWeekend: body.isWeekend, + query: body.query ?? Object.fromEntries(new URLSearchParams(body.query)) + }; } - private _parseRequest( - req: Request, - res: Response - ): Promise { + private _parseRequest(req: Request, res: Response): Promise { return new Promise((resolve) => { if ( 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(req.body); - 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)); + resolve(JSON.parse(body.toString("utf8"))); } catch (err) { res.status(400).json({ error: "Invalid body" }); resolve(false); @@ -88,32 +88,10 @@ export class Webhook { }); } - /** - * Listening function for handling webhook requests - * - * @example - * ```js - * app.post("/webhook", wh.listener((vote) => { - * console.log(vote.user); // => 395526710101278721 - * })); - * ``` - * - * @example - * ```js - * // Throwing an error to resend the webhook - * app.post("/webhook/", wh.listener((vote) => { - * // for example, if your bot is offline, you should probably not handle votes and try again - * if (bot.offline) throw new Error('Bot offline'); - * })); - * ``` - * - * @param fn Vote handling function, this function can also throw an error to - * allow for the webhook to resend from Top.gg - * @returns An express request handler - */ - public listener( - fn: ( - payload: WebhookPayload, + private _listener( + formatFn: (data: any) => T, + callbackFn: ( + payload: T, req?: Request, res?: Response, next?: NextFunction @@ -125,10 +103,11 @@ export class Webhook { next: NextFunction ): Promise => { const response = await this._parseRequest(req, res); + if (!response) return; try { - await fn(response, req, res, next); + await callbackFn(formatFn(response), req, res, next); if (!res.headersSent) { res.sendStatus(204); @@ -142,28 +121,36 @@ export class Webhook { } /** - * Middleware function to pass to express, sets req.vote to the payload + * Listening function for handling webhook requests * - * @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 - * }); + * app.post("/webhook", wh.voteListener((vote) => { + * console.log(vote.voterId); // => 395526710101278721 + * })); + * ``` + * + * @example + * ```js + * // Throwing an error to resend the webhook + * app.post("/webhook/", wh.voteListener((vote) => { + * // for example, if your bot is offline, you should probably not handle votes and try again + * if (bot.offline) throw new Error('Bot offline'); + * })); * ``` + * + * @param fn Vote handling function, this function can also throw an error to + * allow for the webhook to resend from Top.gg + * @returns An express request handler */ - 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(); - }; + public voteListener( + fn: ( + payload: WebhookVotePayload, + req?: Request, + res?: Response, + next?: NextFunction + ) => void | Promise + ) { + return this._listener(this._formatVotePayload, fn); } } diff --git a/src/typings.ts b/src/typings.ts index 5ca8965..3103186 100644 --- a/src/typings.ts +++ b/src/typings.ts @@ -215,13 +215,11 @@ export interface ShortUser { avatar: string; } -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; +export interface WebhookVotePayload { + /** The ID of the Discord bot/server that received a vote. */ + receiverId: Snowflake; + /** The ID of the Top.gg user who voted. */ + voterId: Snowflake; /** * The type of the vote (should always be "upvote" except when using the test * button it's "test") @@ -239,9 +237,3 @@ export interface WebhookPayload { } | string; } - -declare module "express" { - export interface Request { - vote?: WebhookPayload; - } -} diff --git a/tests/mocks/endpoints.ts b/tests/mocks/endpoints.ts index 868fc18..2e4da0b 100644 --- a/tests/mocks/endpoints.ts +++ b/tests/mocks/endpoints.ts @@ -4,53 +4,53 @@ import { getIdInPath } from '../jest.setup'; export const endpoints = [ { - pattern: '/api/bots', + pattern: '/api/v1/bots', method: 'GET', data: BOTS, requireAuth: true }, { - pattern: '/api/bots/:bot_id', + pattern: '/api/v1/bots/:bot_id', method: 'GET', data: BOT, requireAuth: true, validate: (request: MockInterceptor.MockResponseCallbackOptions) => { - const bot_id = getIdInPath('/api/bots/:bot_id', request.path); + const bot_id = getIdInPath('/api/v1/bots/:bot_id', request.path); if (Number(bot_id) === 0) return { statusCode: 404 }; return null; } }, { - pattern: '/api/bots/:bot_id/votes', + pattern: '/api/v1/bots/:bot_id/votes', method: 'GET', data: VOTES, requireAuth: true, validate: (request: MockInterceptor.MockResponseCallbackOptions) => { - const bot_id = getIdInPath('/api/bots/:bot_id/votes', request.path); + const bot_id = getIdInPath('/api/v1/bots/:bot_id/votes', request.path); if (Number(bot_id) === 0) return { statusCode: 404 }; return null; } }, { - pattern: '/api/bots/check', + pattern: '/api/v1/bots/check', method: 'GET', data: USER_VOTE, requireAuth: true }, { - pattern: '/api/bots/stats', + pattern: '/api/v1/bots/stats', method: 'GET', data: BOT_STATS, requireAuth: true }, { - pattern: '/api/bots/stats', + pattern: '/api/v1/bots/stats', method: 'POST', data: {}, requireAuth: true }, { - pattern: '/api/weekend', + pattern: '/api/v1/weekend', method: 'GET', data: WEEKEND, requireAuth: true From 327e15b8aaee436b8b94769121ab5afd5248b8e4 Mon Sep 17 00:00:00 2001 From: null8626 Date: Tue, 24 Jun 2025 21:39:18 +0700 Subject: [PATCH 07/29] feat: remove features deprecated by v0 --- README.md | 4 +- src/structs/Api.ts | 119 +++++++---------------------------------- src/structs/Webhook.ts | 33 +++++------- src/typings.ts | 117 +--------------------------------------- 4 files changed, 37 insertions(+), 236 deletions(-) diff --git a/README.md b/README.md index 996d506..ff387eb 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,7 @@ const { AutoPoster } = require("topgg-autoposter"); const client = Discord.Client(); AutoPoster(process.env.TOPGG_TOKEN, client).on("posted", () => { - console.log("Posted stats to Top.gg!"); + console.log("Successfully posted server count to Top.gg!"); }); ``` @@ -122,7 +122,7 @@ import { AutoPoster } from "topgg-autoposter"; const client = Discord.Client(); AutoPoster(process.env.TOPGG_TOKEN, client).on("posted", () => { - console.log("Posted stats to Top.gg!"); + console.log("Successfully posted server count to Top.gg!"); }); ``` diff --git a/src/structs/Api.ts b/src/structs/Api.ts index 022bb88..93031d7 100644 --- a/src/structs/Api.ts +++ b/src/structs/Api.ts @@ -9,7 +9,6 @@ import { Snowflake, BotStats, BotInfo, - UserInfo, BotsResponse, ShortUser, BotsQuery, @@ -21,8 +20,8 @@ import { * @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} @@ -106,12 +105,12 @@ export class Api extends EventEmitter { } /** - * Post bot stats to Top.gg + * Post your bot's server count to Top.gg * * @example * ```js - * await api.postStats({ - * serverCount: 28199 + * await client.postStats({ + * serverCount: bot.getServerCount() * }); * ``` * @@ -132,31 +131,20 @@ export class Api extends EventEmitter { } /** - * Get your bot's stats + * Get your bot's server count * * @example * ```js - * await api.getStats(); - * // => - * { - * serverCount: 28199, - * shardCount: null, - * shards: [] - * } + * const { serverCount } = await client.getStats(); * ``` * * @returns {BotStats} Your bot's stats */ - public async getStats(_id?: Snowflake): Promise { - if (_id) - console.warn( - "[DeprecationWarning] getStats() no longer needs an ID argument" - ); + public async getStats(): Promise { const result = await this._request("GET", "/bots/stats"); + return { - serverCount: result.server_count, - shardCount: null, - shards: [], + serverCount: result.server_count }; } @@ -165,7 +153,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 @@ -176,69 +164,12 @@ export class Api extends EventEmitter { 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 - * await api.getBots(); - * // => - * { - * 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 @@ -256,21 +187,11 @@ export class Api extends EventEmitter { * * @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.getVotes(); + * + * // Subsequent pages + * const voters2 = await client.getVotes(2); * ``` * * @param {number} [page] The page number. Each page can only have at most 100 voters. @@ -285,8 +206,7 @@ export class Api extends EventEmitter { * * @example * ```js - * await api.hasVoted("205680187394752512"); - * // => true/false + * const hasVoted = await client.hasVoted("205680187394752512"); * ``` * * @param {Snowflake} id User ID @@ -304,8 +224,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 diff --git a/src/structs/Webhook.ts b/src/structs/Webhook.ts index f0d1b54..4b8e1e7 100644 --- a/src/structs/Webhook.ts +++ b/src/structs/Webhook.ts @@ -16,22 +16,17 @@ export interface WebhookOptions { * * @example * ```js - * const express = require("express"); * const { Webhook } = require("@top-gg/sdk"); - * + * const express = require("express"); + * * const app = express(); - * const wh = new Webhook("webhookauth123"); - * - * app.post("/votes", wh.voteListener((vote) => { - * // vote is your vote object e.g - * console.log(vote.voterId); // => 321714991050784770 + * const webhook = new Webhook(process.env.MY_TOPGG_WEBHOOK_SECRET); + * + * app.post("/votes", webhook.voteListener(vote => { + * console.log(`A user with the ID of ${vote.voterId} has voted us on Top.gg!`); * })); - * + * * app.listen(8080); - * - * // In this situation, your Top.gg Webhook dashboard should look like - * // URL = http://your.server.ip:8080/votes - * // Authorization: webhookauth123 * ``` * * @link {@link https://docs.top.gg/resources/webhooks/#schema | Webhook Data Schema} @@ -43,7 +38,7 @@ 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, @@ -58,7 +53,7 @@ export class Webhook { return { receiverId: (body.bot ?? body.guild)!, voterId: body.user, - type: body.type, + isTest: body.type === "test", isWeekend: body.isWeekend, query: body.query ?? Object.fromEntries(new URLSearchParams(body.query)) }; @@ -125,21 +120,21 @@ export class Webhook { * * @example * ```js - * app.post("/webhook", wh.voteListener((vote) => { - * console.log(vote.voterId); // => 395526710101278721 + * app.post("/votes", webhook.voteListener(vote => { + * console.log(`A user with the ID of ${vote.voterId} has voted us on Top.gg!`); * })); * ``` * * @example * ```js * // Throwing an error to resend the webhook - * app.post("/webhook/", wh.voteListener((vote) => { - * // for example, if your bot is offline, you should probably not handle votes and try again + * app.post("/votes", webhook.voteListener(vote => { + * // For example, if your bot is offline, you should probably not handle votes and try again. * if (bot.offline) throw new Error('Bot offline'); * })); * ``` * - * @param fn Vote handling function, this function can also throw an error to + * @param {(payload: WebhookVotePayload, req?: Request, res?: Response, next?: NextFunction) => void | Promise} fn Vote handling function, this function can also throw an error to * allow for the webhook to resend from Top.gg * @returns An express request handler */ diff --git a/src/typings.ts b/src/typings.ts index 3103186..8bac9c4 100644 --- a/src/typings.ts +++ b/src/typings.ts @@ -16,32 +16,8 @@ export interface BotInfo { 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 */ @@ -58,34 +34,16 @@ export interface BotInfo { 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 */ @@ -100,70 +58,6 @@ export interface BotInfo { 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; -} - -/** - * @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; } export interface BotsQuery { @@ -205,12 +99,6 @@ export interface ShortUser { 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; } @@ -221,10 +109,9 @@ export interface WebhookVotePayload { /** The ID of the Top.gg user who voted. */ voterId: Snowflake; /** - * The type of the vote (should always be "upvote" except when using the test - * button it's "test") + * Whether this vote is just a test done from the page settings. */ - type: string; + isTest: boolean; /** * Whether the weekend multiplier is in effect, meaning users votes count as * two From 1a03b2779bbce064b0116d80662ff3c6963b5e48 Mon Sep 17 00:00:00 2001 From: null8626 Date: Fri, 4 Jul 2025 20:42:53 +0700 Subject: [PATCH 08/29] feat: remove BotStats --- README.md | 10 ++++------ src/structs/Api.ts | 35 ++++++++++++----------------------- src/typings.ts | 5 ----- tests/Api.test.ts | 32 +++++++++++--------------------- 4 files changed, 27 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index ff387eb..61bf2ff 100644 --- a/README.md +++ b/README.md @@ -53,13 +53,13 @@ const bots = await client.getBots(); #### First page ```js -const voters = await client.getVotes(); +const voters = await client.getVoters(); ``` #### Subsequent pages ```js -const voters = await client.getVotes(2); +const voters = await client.getVoters(2); ``` ### Check if a user has voted for your bot @@ -71,15 +71,13 @@ const hasVoted = await client.hasVoted("205680187394752512"); ### Getting your bot's server count ```js -const { serverCount } = await client.getStats(); +const serverCount = await client.getServerCount(); ``` ### Posting your bot's server count ```js -await client.postStats({ - serverCount: bot.getServerCount() -}); +await client.postServerCount(bot.getServerCount()); ``` ### Automatically posting your bot's server count every few minutes diff --git a/src/structs/Api.ts b/src/structs/Api.ts index 93031d7..169be18 100644 --- a/src/structs/Api.ts +++ b/src/structs/Api.ts @@ -7,7 +7,6 @@ import { STATUS_CODES } from "http"; import { APIOptions, Snowflake, - BotStats, BotInfo, BotsResponse, ShortUser, @@ -109,25 +108,19 @@ export class Api extends EventEmitter { * * @example * ```js - * await client.postStats({ - * serverCount: bot.getServerCount() - * }); + * await client.postServerCount(bot.getServerCount()); * ``` * - * @param {object} stats Stats object - * @param {number} stats.serverCount Server count - * @returns {BotStats} Passed object + * @param {number} serverCount Server count */ - public async postStats(stats: BotStats): Promise { - if ((stats?.serverCount ?? 0) <= 0) throw new Error("Missing server count"); + public async postServerCount(serverCount: number): Promise { + if ((serverCount ?? 0) <= 0) throw new Error("Missing server count"); /* eslint-disable camelcase */ await this._request("POST", "/bots/stats", { - server_count: stats.serverCount, + server_count: serverCount, }); /* eslint-enable camelcase */ - - return stats; } /** @@ -135,17 +128,13 @@ export class Api extends EventEmitter { * * @example * ```js - * const { serverCount } = await client.getStats(); + * const serverCount = await client.getServerCount(); * ``` * - * @returns {BotStats} Your bot's stats + * @returns {number} Your bot's server count */ - public async getStats(): Promise { - const result = await this._request("GET", "/bots/stats"); - - return { - serverCount: result.server_count - }; + public async getServerCount(): Promise { + return (await this._request("GET", "/bots/stats")).server_count; } /** @@ -188,16 +177,16 @@ export class Api extends EventEmitter { * @example * ```js * // First page - * const voters1 = await client.getVotes(); + * const voters1 = await client.getVoters(); * * // Subsequent pages - * const voters2 = await client.getVotes(2); + * 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 */ - public async getVotes(page?: number): Promise { + public async getVoters(page?: number): Promise { return this._request("GET", `/bots/${this.options.id}/votes`, { page: page ?? 1 }); } diff --git a/src/typings.ts b/src/typings.ts index 8bac9c4..09b7a05 100644 --- a/src/typings.ts +++ b/src/typings.ts @@ -55,11 +55,6 @@ export interface BotInfo { }; } -export interface BotStats { - /** The amount of servers the bot is in */ - serverCount?: number; -} - export interface BotsQuery { /** The amount of bots to return. Max. 500 */ limit?: number; diff --git a/tests/Api.test.ts b/tests/Api.test.ts index 0d6dda3..6073be1 100644 --- a/tests/Api.test.ts +++ b/tests/Api.test.ts @@ -5,29 +5,19 @@ import { BOT, BOT_STATS, VOTES } from './mocks/data'; /* mock token */ const client = new Api('.eyJpZCI6IjEwMjY1MjU1NjgzNDQyNjQ3MjQiLCJib3QiOnRydWV9.'); -describe('API postStats test', () => { - it('postStats without server count should throw error', async () => { - await expect(client.postStats({ shardCount: 0 })).rejects.toThrow(Error); +describe('API postServerCount test', () => { + it('postServerCount with invalid negative server count should throw error', () => { + expect(client.postServerCount(-1)).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 - ); + it('postServerCount should return 200', async () => { + await expect(client.postServerCount(1)).resolves.toBeUndefined(); }); }); -describe('API getStats test', () => { - it('getStats should return 200 when bot is found', async () => { - expect(client.getStats()).resolves.toStrictEqual({ - serverCount: BOT_STATS.server_count, - shardCount: BOT_STATS.shard_count, - shards: BOT_STATS.shards - }); +describe('API getServerCount test', () => { + it('getServerCount should return 200 when bot is found', async () => { + expect(client.getServerCount()).resolves.toStrictEqual(BOT_STATS.server_count); }); }); @@ -45,9 +35,9 @@ describe('API getBot test', () => { }); }); -describe('API getVotes test', () => { - it('getVotes should return 200 when token is provided', () => { - expect(client.getVotes()).resolves.toEqual(VOTES); +describe('API getVoters test', () => { + it('getVoters should return 200 when token is provided', () => { + expect(client.getVoters()).resolves.toEqual(VOTES); }); }); From ed43118db1a386c4c6f533154c7eb4e469a7eb61 Mon Sep 17 00:00:00 2001 From: null8626 Date: Thu, 17 Jul 2025 02:49:23 +0700 Subject: [PATCH 09/29] deps: remove the dependence on @top-gg/eslint-config --- .eslintrc.js | 7 ---- .husky/pre-commit | 2 +- .npmignore | 2 +- README.md | 17 +++++++++ eslint.config.js | 81 ++++++++++++++++++++++++++++++++++++++++++ package.json | 42 +++++++++++----------- src/structs/Webhook.ts | 2 +- 7 files changed, 122 insertions(+), 31 deletions(-) delete mode 100644 .eslintrc.js create mode 100644 eslint.config.js 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/.husky/pre-commit b/.husky/pre-commit index 3867a0f..a845b85 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1 +1 @@ -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 61bf2ff..00fddf5 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,23 @@ The community-maintained Node.js library for Top.gg. +## Chapters + +- [Installation](#installation) +- [Setting up](#setting-up) +- [Usage](#usage) + - [Getting a bot](#getting-a-bot) + - [Getting several bots](#getting-several-bots) + - [Getting your bot's voters](#getting-your-bots-voters) + - [Check if a user has voted for your bot](#check-if-a-user-has-voted-for-your-bot) + - [Getting your bot's server count](#getting-your-bots-server-count) + - [Posting your bot's server count](#posting-your-bots-server-count) + - [Automatically posting your bot's server count every few minutes]#automatically-posting-your-bots-server-count-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 bot](#being-notified-whenever-someone-voted-for-your-bot) + ## Installation ### NPM 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..78a610d 100644 --- a/package.json +++ b/package.json @@ -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,27 @@ }, "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", + "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/structs/Webhook.ts b/src/structs/Webhook.ts index 4b8e1e7..f39656d 100644 --- a/src/structs/Webhook.ts +++ b/src/structs/Webhook.ts @@ -75,7 +75,7 @@ export class Webhook { try { resolve(JSON.parse(body.toString("utf8"))); - } catch (err) { + } catch { res.status(400).json({ error: "Invalid body" }); resolve(false); } From b27d29491d9abd7fc0c832e9b807dd90f203a389 Mon Sep 17 00:00:00 2001 From: null8626 Date: Wed, 10 Sep 2025 20:37:34 +0700 Subject: [PATCH 10/29] feat: adapt to v1 --- README.md | 41 +++++++++++--- package.json | 1 + src/structs/Api.ts | 117 +++++++++++++++++++++++++++++++++------ src/structs/Widget.ts | 12 ++-- src/typings.ts | 25 ++++----- src/utils/ApiError.ts | 17 ++++-- tests/Api.test.ts | 46 +++++++++------ tests/mocks/data.ts | 13 +++++ tests/mocks/endpoints.ts | 37 +++++++++---- 9 files changed, 231 insertions(+), 78 deletions(-) diff --git a/README.md b/README.md index 00fddf5..2ced25a 100644 --- a/README.md +++ b/README.md @@ -9,15 +9,15 @@ The community-maintained Node.js library for Top.gg. - [Usage](#usage) - [Getting a bot](#getting-a-bot) - [Getting several bots](#getting-several-bots) - - [Getting your bot's voters](#getting-your-bots-voters) - - [Check if a user has voted for your bot](#check-if-a-user-has-voted-for-your-bot) + - [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 server count](#getting-your-bots-server-count) - [Posting your bot's server count](#posting-your-bots-server-count) - [Automatically posting your bot's server count every few minutes]#automatically-posting-your-bots-server-count-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 bot](#being-notified-whenever-someone-voted-for-your-bot) + - [Being notified whenever someone voted for your project](#being-notified-whenever-someone-voted-for-your-project) ## Installation @@ -65,7 +65,7 @@ const bot = await client.getBot("461521980492087297"); const bots = await client.getBots(); ``` -### Getting your bot's voters +### Getting your project's voters #### First page @@ -79,22 +79,45 @@ const voters = await client.getVoters(); const voters = await client.getVoters(2); ``` -### Check if a user has voted for your bot +### Getting your project's vote information of a user ```js -const hasVoted = await client.hasVoted("205680187394752512"); +const vote = await client.getVote("8226924471638491136"); ``` ### Getting your bot's server count ```js -const serverCount = await client.getServerCount(); +const serverCount = await client.getBotServerCount(); ``` ### Posting your bot's server count ```js -await client.postServerCount(bot.getServerCount()); +await client.postBotServerCount(bot.getServerCount()); +``` + +### Posting your bot's application commands list + +```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.postBotCommands(commands); ``` ### Automatically posting your bot's server count every few minutes @@ -175,7 +198,7 @@ const widgetUrl = Topgg.Widget.social(Topgg.WidgetType.DiscordBot, "574652751745 ### Webhooks -#### Being notified whenever someone voted for your bot +#### Being notified whenever someone voted for your project With express: diff --git a/package.json b/package.json index 78a610d..6ceabb1 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "@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", diff --git a/src/structs/Api.ts b/src/structs/Api.ts index 169be18..b14f7c2 100644 --- a/src/structs/Api.ts +++ b/src/structs/Api.ts @@ -1,6 +1,7 @@ -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"; @@ -11,6 +12,7 @@ import { BotsResponse, ShortUser, BotsQuery, + Vote, } from "../typings"; /** @@ -28,6 +30,7 @@ import { */ export class Api extends EventEmitter { private options: APIOptions; + private legacy: boolean; /** * Create Top.gg API instance @@ -45,10 +48,11 @@ export class Api extends EventEmitter { } try { - const tokenData = atob(tokenSegments[1]); - const tokenId = JSON.parse(tokenData).id; + const tokenData = JSON.parse(atob(tokenSegments[1])); + const tokenId = tokenData.id; options.id ??= tokenId; + this.legacy = !("_t" in tokenData); } catch { throw new Error( "Invalid API token state, this should not happen! Please report!" @@ -67,10 +71,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/v1${path}`; + let url = `https://top.gg/api${path}`; if (body && method === "GET") url += `?${new URLSearchParams(body)}`; @@ -80,23 +84,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" ) ) { - 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 + responseBody ); } @@ -104,16 +108,51 @@ export class Api extends EventEmitter { } /** - * Post your bot's server count to Top.gg + * 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.postBotCommands(commands); + * ``` + * + * @param {APIApplicationCommand[]} commands A list of application commands in raw Discord API JSON dicts. This cannot be empty. + */ + public async postBotCommands(commands: APIApplicationCommand[]): Promise { + if (this.legacy) { + throw new Error("This endpoint is inaccessible with legacy API tokens."); + } + + await this._request("POST", "/v1/projects/@me/commands", commands); + } + + /** + * Post your Discord bot's server count to Top.gg * * @example * ```js - * await client.postServerCount(bot.getServerCount()); + * await client.postBotServerCount(bot.getServerCount()); * ``` * * @param {number} serverCount Server count */ - public async postServerCount(serverCount: number): Promise { + public async postBotServerCount(serverCount: number): Promise { if ((serverCount ?? 0) <= 0) throw new Error("Missing server count"); /* eslint-disable camelcase */ @@ -124,16 +163,16 @@ export class Api extends EventEmitter { } /** - * Get your bot's server count + * Get your Discord bot's server count * * @example * ```js - * const serverCount = await client.getServerCount(); + * const serverCount = await client.getBotServerCount(); * ``` * * @returns {number} Your bot's server count */ - public async getServerCount(): Promise { + public async getBotServerCount(): Promise { return (await this._request("GET", "/bots/stats")).server_count; } @@ -172,7 +211,7 @@ export class Api extends EventEmitter { } /** - * Get recent unique users who've voted + * Get recent 100 unique voters * * @example * ```js @@ -184,13 +223,15 @@ export class Api extends EventEmitter { * ``` * * @param {number} [page] The page number. Each page can only have at most 100 voters. - * @returns {ShortUser[]} Array of unique users who've voted + * @returns {ShortUser[]} Array of 100 unique voters */ public async getVoters(page?: number): Promise { return this._request("GET", `/bots/${this.options.id}/votes`, { page: page ?? 1 }); } /** + * @deprecated Use a v1 API token with `getVote()` instead. + * * Get whether or not a user has voted in the last 12 hours * * @example @@ -203,11 +244,51 @@ export class Api extends EventEmitter { */ public async hasVoted(id: Snowflake): Promise { if (!id) throw new Error("Missing ID"); + + console.warn("`hasVoted()` is deprecated. Use a v1 API token with `getVote()` instead."); + return this._request("GET", "/bots/check", { userId: id }).then( (x) => !!x.voted ); } + /** + * Get the latest vote information of a Top.gg user on your project. + * + * @example + * ```js + * const vote = await client.getVote("8226924471638491136"); + * ``` + * + * @param {Snowflake} id The Top.gg user's ID. + * @returns {?Vote} 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): Promise { + if (!id) throw new Error("Missing ID"); + + if (this.legacy) { + throw new Error("This endpoint is inaccessible with legacy API tokens."); + } + + try { + const response = await this._request("GET", `/v1/projects/@me/votes/${id}`); + + return { + votedAt: response.created_at, + expiresAt: response.expires_at, + weight: response.weight + }; + } catch (err) { + const topggError = err as TopGGAPIError; + + if ((topggError?.body as { title?: string })?.title === "Vote expired") { + return null; + } + + throw err; + } + } + /** * Whether or not the weekend multiplier is active * diff --git a/src/structs/Widget.ts b/src/structs/Widget.ts index 084d77a..011facc 100644 --- a/src/structs/Widget.ts +++ b/src/structs/Widget.ts @@ -1,6 +1,6 @@ import { Snowflake } from "../typings"; -const BASE_URL: string = "https://top.gg/api/v1"; +const BASE_URL: string = "https://top.gg/api/v1/widgets"; /** * Widget type. @@ -22,7 +22,7 @@ export class Widget { * @returns {string} The widget URL. */ public static large(ty: WidgetType, id: Snowflake): string { - return `${BASE_URL}/widgets/large/${ty}/${id}`; + return `${BASE_URL}/large/${ty}/${id}`; } /** @@ -33,18 +33,18 @@ export class Widget { * @returns {string} The widget URL. */ public static votes(ty: WidgetType, id: Snowflake): string { - return `${BASE_URL}/widgets/small/votes/${ty}/${id}`; + return `${BASE_URL}/small/votes/${ty}/${id}`; } /** - * Generates a small widget URL for displaying an entity's owner. + * 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}/widgets/small/owner/${ty}/${id}`; + return `${BASE_URL}/small/owner/${ty}/${id}`; } /** @@ -55,6 +55,6 @@ export class Widget { * @returns {string} The widget URL. */ public static social(ty: WidgetType, id: Snowflake): string { - return `${BASE_URL}/widgets/small/social/${ty}/${id}`; + return `${BASE_URL}/small/social/${ty}/${id}`; } } diff --git a/src/typings.ts b/src/typings.ts index 09b7a05..7dc09bf 100644 --- a/src/typings.ts +++ b/src/typings.ts @@ -1,8 +1,8 @@ export interface APIOptions { - /** Top.gg token */ + /** Top.gg API token */ token?: string; - /** Discord bot ID */ + /** Client ID to use */ id?: string; } @@ -60,16 +60,6 @@ export interface BotsQuery { limit?: number; /** Amount of bots to skip */ offset?: number; - /** - * A search string in the format of "field: value field2: value2" - * - * @deprecated No longer supported by Top.gg API v1. - */ - 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. */ @@ -89,6 +79,15 @@ export interface BotsResponse { total: number; } +export interface Vote { + /** When this vote was cast */ + votedAt?: string; + /** When this vote expires and the user is required to vote again */ + expiresAt?: string; + /** This vote's weight */ + weight?: number; +} + export interface ShortUser { /** User's ID */ id: Snowflake; @@ -99,7 +98,7 @@ export interface ShortUser { } export interface WebhookVotePayload { - /** The ID of the Discord bot/server that received a vote. */ + /** The ID of the project that received a vote. */ receiverId: Snowflake; /** The ID of the Top.gg user who voted. */ voterId: Snowflake; diff --git a/src/utils/ApiError.ts b/src/utils/ApiError.ts index b4df924..5a8bd1c 100644 --- a/src/utils/ApiError.ts +++ b/src/utils/ApiError.ts @@ -1,5 +1,3 @@ -import type { Dispatcher } from "undici"; - const tips = { 401: "You need a token for this endpoint", 403: "You don't have access to this endpoint", @@ -7,14 +5,21 @@ const tips = { /** API Error */ export default class TopGGAPIError extends Error { - /** Possible response from Request */ - public response?: Dispatcher.ResponseData; - constructor(code: number, text: string, response: Dispatcher.ResponseData) { + /** 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 6073be1..6f9968d 100644 --- a/tests/Api.test.ts +++ b/tests/Api.test.ts @@ -1,23 +1,37 @@ import { Api } from '../src/index'; import ApiError from '../src/utils/ApiError'; -import { BOT, BOT_STATS, VOTES } from './mocks/data'; +import { BOT, BOT_STATS, VOTE, VOTES } from './mocks/data'; /* mock token */ -const client = new Api('.eyJpZCI6IjEwMjY1MjU1NjgzNDQyNjQ3MjQiLCJib3QiOnRydWV9.'); +const client = new Api('.eyJfdCI6IiIsImlkIjoiMzY0ODA2MDI5ODc2NTU1Nzc2In0=.'); -describe('API postServerCount test', () => { - it('postServerCount with invalid negative server count should throw error', () => { - expect(client.postServerCount(-1)).rejects.toThrow(Error); +describe('API postBotCommands test', () => { + it('postBotCommands should work', () => { + expect(client.postBotCommands([{ + id: '1', + type: 1, + application_id: '1', + name: 'test', + description: 'command description', + default_member_permissions: '', + version: '1' + }])).resolves.toBeUndefined(); + }); +}); + +describe('API postBotServerCount test', () => { + it('postBotServerCount with invalid negative server count should throw error', () => { + expect(client.postBotServerCount(-1)).rejects.toThrow(Error); }); - it('postServerCount should return 200', async () => { - await expect(client.postServerCount(1)).resolves.toBeUndefined(); + it('postBotServerCount should return 200', async () => { + await expect(client.postBotServerCount(1)).resolves.toBeUndefined(); }); }); -describe('API getServerCount test', () => { - it('getServerCount should return 200 when bot is found', async () => { - expect(client.getServerCount()).resolves.toStrictEqual(BOT_STATS.server_count); +describe('API getBotServerCount test', () => { + it('getBotServerCount should return 200 when bot is found', async () => { + expect(client.getBotServerCount()).resolves.toStrictEqual(BOT_STATS.server_count); }); }); @@ -37,17 +51,17 @@ describe('API getBot test', () => { describe('API getVoters test', () => { it('getVoters should return 200 when token is provided', () => { - expect(client.getVoters()).resolves.toEqual(VOTES); + expect(client.getVoters()).resolves.toStrictEqual(VOTES); }); }); -describe('API hasVoted test', () => { - it('hasVoted should return 200 when token is provided', () => { - expect(client.hasVoted('1')).resolves.toBe(true); +describe('API getVote test', () => { + it('getVote should return 200 when token is provided', () => { + expect(client.getVote('1')).resolves.toStrictEqual(VOTE); }); - it('hasVoted should throw error when no id is provided', () => { - expect(client.hasVoted('')).rejects.toThrow(Error); + it('getVote should throw error when no id is provided', () => { + expect(client.getVote('')).rejects.toThrow(Error); }); }); diff --git a/tests/mocks/data.ts b/tests/mocks/data.ts index 65ec229..c158848 100644 --- a/tests/mocks/data.ts +++ b/tests/mocks/data.ts @@ -31,6 +31,19 @@ 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 = { + voted: true, + 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 2e4da0b..c7e8b62 100644 --- a/tests/mocks/endpoints.ts +++ b/tests/mocks/endpoints.ts @@ -1,58 +1,75 @@ 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 = [ { - pattern: '/api/v1/bots', + pattern: '/api/bots', method: 'GET', data: BOTS, requireAuth: true }, { - pattern: '/api/v1/bots/:bot_id', + pattern: '/api/bots/:bot_id', method: 'GET', data: BOT, requireAuth: true, validate: (request: MockInterceptor.MockResponseCallbackOptions) => { - const bot_id = getIdInPath('/api/v1/bots/:bot_id', request.path); + const bot_id = getIdInPath('/api/bots/:bot_id', request.path); if (Number(bot_id) === 0) return { statusCode: 404 }; return null; } }, { - pattern: '/api/v1/bots/:bot_id/votes', + pattern: '/api/bots/:bot_id/votes', method: 'GET', data: VOTES, requireAuth: true, validate: (request: MockInterceptor.MockResponseCallbackOptions) => { - const bot_id = getIdInPath('/api/v1/bots/:bot_id/votes', request.path); + const bot_id = getIdInPath('/api/bots/:bot_id/votes', request.path); if (Number(bot_id) === 0) return { statusCode: 404 }; return null; } }, { - pattern: '/api/v1/bots/check', + pattern: '/api/bots/check', method: 'GET', data: USER_VOTE, requireAuth: true }, { - pattern: '/api/v1/bots/stats', + pattern: '/api/bots/stats', method: 'GET', data: BOT_STATS, requireAuth: true }, { - pattern: '/api/v1/bots/stats', + pattern: '/api/bots/stats', method: 'POST', data: {}, requireAuth: true }, { - pattern: '/api/v1/weekend', + pattern: '/api/weekend', 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 From 1b2027c880016fc4c3b34e085066daec6cccdabd Mon Sep 17 00:00:00 2001 From: null8626 Date: Fri, 12 Sep 2025 18:17:12 +0700 Subject: [PATCH 11/29] *: minor tweaks --- README.md | 46 +++++++++++++++++++++++++++++++++++++-------- package.json | 2 +- src/structs/Api.ts | 26 ++++++++++++++++--------- src/typings.ts | 7 +++++-- tests/mocks/data.ts | 1 - 5 files changed, 61 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 2ced25a..4f9ac43 100644 --- a/README.md +++ b/README.md @@ -10,10 +10,11 @@ The community-maintained Node.js library for Top.gg. - [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 project's vote information of a user](#getting-your-projects-vote-information-of-a-user) - [Getting your bot's server count](#getting-your-bots-server-count) - [Posting your bot's server count](#posting-your-bots-server-count) - - [Automatically posting your bot's server count every few minutes]#automatically-posting-your-bots-server-count-every-few-minutes) + - [Posting your bot's application commands list](#posting-your-bots-application-commands-list) + - [Automatically posting your bot's server count every few minutes](#automatically-posting-your-bots-server-count-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) @@ -81,8 +82,16 @@ const voters = await client.getVoters(2); ### Getting your project's vote information of a user +#### Discord ID + ```js -const vote = await client.getVote("8226924471638491136"); +const vote = await client.getVote("661200758510977084"); +``` + +#### Top.gg ID + +```js +const vote = await client.getVote("8226924471638491136", "topgg"); ``` ### Getting your bot's server count @@ -99,22 +108,43 @@ await client.postBotServerCount(bot.getServerCount()); ### Posting your bot's application commands list +#### Discord.js + ```js -// Discord.js: const commands = (await bot.application.commands.fetch()).map(cmd => cmd.toJSON()); -// Eris: +await client.postBotCommands(commands); +``` + +#### Eris + +```js const commands = await bot.getCommands(); -// Discordeno: +await client.postBotCommands(commands); +``` + +#### Discordeno + +```js import { getApplicationCommands } from "discordeno"; const commands = await getApplicationCommands(bot); -// Harmony: +await client.postBotCommands(commands); +``` + +#### Harmony + +```js const commands = await bot.interactions.commands.all(); -// Oceanic: +await client.postBotCommands(commands); +``` + +#### Oceanic + +```js const commands = await bot.application.getGlobalCommands(); await client.postBotCommands(commands); diff --git a/package.json b/package.json index 6ceabb1..8d1ded0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@top-gg/sdk", - "version": "3.1.6", + "version": "3.2.0", "description": "Official Top.gg Node SDK", "main": "./dist/index.js", "scripts": { diff --git a/src/structs/Api.ts b/src/structs/Api.ts index b14f7c2..e570c2a 100644 --- a/src/structs/Api.ts +++ b/src/structs/Api.ts @@ -13,10 +13,11 @@ import { ShortUser, BotsQuery, Vote, + UserSource, } from "../typings"; /** - * Top.gg API Client for Posting stats or Fetching data + * Top.gg API Client * * @example * ```js @@ -87,8 +88,8 @@ export class Api extends EventEmitter { 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() as object; @@ -222,7 +223,7 @@ export class Api extends EventEmitter { * const voters2 = await client.getVoters(2); * ``` * - * @param {number} [page] The page number. Each page can only have at most 100 voters. + * @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 getVoters(page?: number): Promise { @@ -257,21 +258,28 @@ export class Api extends EventEmitter { * * @example * ```js - * const vote = await client.getVote("8226924471638491136"); + * // Discord ID + * const vote = await client.getVote("661200758510977084"); + * + * // Top.gg ID + * const vote = await client.getVote("8226924471638491136", "topgg"); * ``` * - * @param {Snowflake} id The Top.gg user's ID. - * @returns {?Vote} 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. + * @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): Promise { + public async getVote(id: Snowflake, source: UserSource = "discord"): Promise { if (!id) throw new Error("Missing ID"); + if (!source) source = "discord"; if (this.legacy) { throw new Error("This endpoint is inaccessible with legacy API tokens."); } try { - const response = await this._request("GET", `/v1/projects/@me/votes/${id}`); + const response = await this._request("GET", `/v1/projects/@me/votes/${id}?source=${source}`); return { votedAt: response.created_at, diff --git a/src/typings.ts b/src/typings.ts index 7dc09bf..4771c81 100644 --- a/src/typings.ts +++ b/src/typings.ts @@ -9,6 +9,9 @@ export interface APIOptions { /** 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; @@ -80,9 +83,9 @@ export interface BotsResponse { } export interface Vote { - /** When this vote was cast */ + /** When the vote was cast */ votedAt?: string; - /** When this vote expires and the user is required to vote again */ + /** When the vote expires and the user is required to vote again */ expiresAt?: string; /** This vote's weight */ weight?: number; diff --git a/tests/mocks/data.ts b/tests/mocks/data.ts index c158848..9f68ed0 100644 --- a/tests/mocks/data.ts +++ b/tests/mocks/data.ts @@ -38,7 +38,6 @@ export const RAW_VOTE = { }; export const VOTE = { - voted: true, votedAt: "2025-09-09T08:55:16.218761+00:00", expiresAt: "2025-09-09T20:55:16.218761+00:00", weight: 1 From 157a44c971ed6d85d8e7218f86cec2ce84b0c4c7 Mon Sep 17 00:00:00 2001 From: null8626 Date: Sat, 13 Sep 2025 10:01:55 +0700 Subject: [PATCH 12/29] [doc,revert]: rename WebhookVotePayload to WebhookPayload, TopggAPIError to APIError, and describe weight property --- src/structs/Api.ts | 6 +++--- src/structs/Webhook.ts | 8 ++++---- src/typings.ts | 4 ++-- src/utils/ApiError.ts | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/structs/Api.ts b/src/structs/Api.ts index e570c2a..e36e9ec 100644 --- a/src/structs/Api.ts +++ b/src/structs/Api.ts @@ -1,7 +1,7 @@ import type { APIApplicationCommand } from "discord-api-types/v10"; import type { IncomingHttpHeaders } from "undici/types/header"; import { request, type Dispatcher } from "undici"; -import TopGGAPIError from "../utils/ApiError"; +import APIError from "../utils/ApiError"; import { EventEmitter } from "events"; import { STATUS_CODES } from "http"; @@ -98,7 +98,7 @@ export class Api extends EventEmitter { } if (response.statusCode < 200 || response.statusCode > 299) { - throw new TopGGAPIError( + throw new APIError( response.statusCode, STATUS_CODES[response.statusCode] ?? "", responseBody @@ -287,7 +287,7 @@ export class Api extends EventEmitter { weight: response.weight }; } catch (err) { - const topggError = err as TopGGAPIError; + const topggError = err as APIError; if ((topggError?.body as { title?: string })?.title === "Vote expired") { return null; diff --git a/src/structs/Webhook.ts b/src/structs/Webhook.ts index f39656d..ff5d287 100644 --- a/src/structs/Webhook.ts +++ b/src/structs/Webhook.ts @@ -1,6 +1,6 @@ import getBody from "raw-body"; import { Request, Response, NextFunction } from "express"; -import { WebhookVotePayload } from "../typings"; +import { WebhookPayload } from "../typings"; export interface WebhookOptions { /** @@ -49,7 +49,7 @@ export class Webhook { }; } - private _formatVotePayload(body: any): WebhookVotePayload { + private _formatVotePayload(body: any): WebhookPayload { return { receiverId: (body.bot ?? body.guild)!, voterId: body.user, @@ -134,13 +134,13 @@ export class Webhook { * })); * ``` * - * @param {(payload: WebhookVotePayload, req?: Request, res?: Response, next?: NextFunction) => void | Promise} fn Vote handling function, this function can also throw an error to + * @param {(payload: WebhookPayload, req?: Request, res?: Response, next?: NextFunction) => void | Promise} fn Vote handling function, this function can also throw an error to * allow for the webhook to resend from Top.gg * @returns An express request handler */ public voteListener( fn: ( - payload: WebhookVotePayload, + payload: WebhookPayload, req?: Request, res?: Response, next?: NextFunction diff --git a/src/typings.ts b/src/typings.ts index 4771c81..6f54cd5 100644 --- a/src/typings.ts +++ b/src/typings.ts @@ -87,7 +87,7 @@ export interface Vote { votedAt?: string; /** When the vote expires and the user is required to vote again */ expiresAt?: string; - /** This vote's weight */ + /** This vote's weight. 1 during weekdays, 2 during weekends. */ weight?: number; } @@ -100,7 +100,7 @@ export interface ShortUser { avatar: string; } -export interface WebhookVotePayload { +export interface WebhookPayload { /** The ID of the project that received a vote. */ receiverId: Snowflake; /** The ID of the Top.gg user who voted. */ diff --git a/src/utils/ApiError.ts b/src/utils/ApiError.ts index 5a8bd1c..8cca887 100644 --- a/src/utils/ApiError.ts +++ b/src/utils/ApiError.ts @@ -4,7 +4,7 @@ const tips = { }; /** API Error */ -export default class TopGGAPIError extends Error { +export default class APIError extends Error { /** Response status code */ public statusCode: number; From 4192bd3e08a8b9ee8620c8c2b704077d55dd50db Mon Sep 17 00:00:00 2001 From: null8626 Date: Sat, 13 Sep 2025 10:11:40 +0700 Subject: [PATCH 13/29] refactor: remove redundant checks --- src/structs/Api.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/structs/Api.ts b/src/structs/Api.ts index e36e9ec..0a02cba 100644 --- a/src/structs/Api.ts +++ b/src/structs/Api.ts @@ -272,7 +272,6 @@ export class Api extends EventEmitter { */ public async getVote(id: Snowflake, source: UserSource = "discord"): Promise { if (!id) throw new Error("Missing ID"); - if (!source) source = "discord"; if (this.legacy) { throw new Error("This endpoint is inaccessible with legacy API tokens."); @@ -289,7 +288,7 @@ export class Api extends EventEmitter { } catch (err) { const topggError = err as APIError; - if ((topggError?.body as { title?: string })?.title === "Vote expired") { + if (topggError.statusCode === 404) { return null; } From 8f986e1e7fbcb108c65305413c633658e9b8c7f3 Mon Sep 17 00:00:00 2001 From: null8626 Date: Sat, 13 Sep 2025 11:57:28 +0700 Subject: [PATCH 14/29] feat: separate v1 from v0 --- src/structs/Api.ts | 139 ++++++++++++++++++++++++-------------------- tests/Api.test.ts | 26 ++------- tests/V1Api.test.ts | 29 +++++++++ 3 files changed, 110 insertions(+), 84 deletions(-) create mode 100644 tests/V1Api.test.ts diff --git a/src/structs/Api.ts b/src/structs/Api.ts index 0a02cba..7e9ac25 100644 --- a/src/structs/Api.ts +++ b/src/structs/Api.ts @@ -17,7 +17,7 @@ import { } from "../typings"; /** - * Top.gg API Client + * Top.gg v0 API Client * * @example * ```js @@ -30,8 +30,7 @@ import { * @link {@link https://docs.top.gg | API Reference} */ export class Api extends EventEmitter { - private options: APIOptions; - private legacy: boolean; + protected options: APIOptions; /** * Create Top.gg API instance @@ -53,7 +52,6 @@ export class Api extends EventEmitter { const tokenId = tokenData.id; options.id ??= tokenId; - this.legacy = !("_t" in tokenData); } catch { throw new Error( "Invalid API token state, this should not happen! Please report!" @@ -66,13 +64,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"] = `Bearer ${this.options.token}`; + if (this.options.token) headers["authorization"] = this.options.token; if (method !== "GET") headers["content-type"] = "application/json"; let url = `https://top.gg/api${path}`; @@ -108,41 +106,6 @@ export class Api extends EventEmitter { return responseBody; } - /** - * 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.postBotCommands(commands); - * ``` - * - * @param {APIApplicationCommand[]} commands A list of application commands in raw Discord API JSON dicts. This cannot be empty. - */ - public async postBotCommands(commands: APIApplicationCommand[]): Promise { - if (this.legacy) { - throw new Error("This endpoint is inaccessible with legacy API tokens."); - } - - await this._request("POST", "/v1/projects/@me/commands", commands); - } - /** * Post your Discord bot's server count to Top.gg * @@ -231,8 +194,6 @@ export class Api extends EventEmitter { } /** - * @deprecated Use a v1 API token with `getVote()` instead. - * * Get whether or not a user has voted in the last 12 hours * * @example @@ -246,13 +207,81 @@ export class Api extends EventEmitter { public async hasVoted(id: Snowflake): Promise { if (!id) throw new Error("Missing ID"); - console.warn("`hasVoted()` is deprecated. Use a v1 API token with `getVote()` instead."); - return this._request("GET", "/bots/check", { userId: id }).then( (x) => !!x.voted ); } + /** + * Whether or not the weekend multiplier is active + * + * @example + * ```js + * const isWeekend = await client.isWeekend(); + * ``` + * + * @returns {boolean} Whether the multiplier is active + */ + public async isWeekend(): Promise { + return this._request("GET", "/weekend").then((x) => x.is_weekend); + } +} + +/** + * Top.gg v1 API 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(`Bearer ${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.postBotCommands(commands); + * ``` + * + * @param {APIApplicationCommand[]} commands A list of application commands in raw Discord API JSON dicts. This cannot be empty. + */ + public async postBotCommands(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. * @@ -273,10 +302,6 @@ export class Api extends EventEmitter { public async getVote(id: Snowflake, source: UserSource = "discord"): Promise { if (!id) throw new Error("Missing ID"); - if (this.legacy) { - throw new Error("This endpoint is inaccessible with legacy API tokens."); - } - try { const response = await this._request("GET", `/v1/projects/@me/votes/${id}?source=${source}`); @@ -295,18 +320,4 @@ export class Api extends EventEmitter { throw err; } } - - /** - * Whether or not the weekend multiplier is active - * - * @example - * ```js - * const isWeekend = await client.isWeekend(); - * ``` - * - * @returns {boolean} Whether the multiplier is active - */ - public async isWeekend(): Promise { - return this._request("GET", "/weekend").then((x) => x.is_weekend); - } -} +} \ No newline at end of file diff --git a/tests/Api.test.ts b/tests/Api.test.ts index 6f9968d..fccdd98 100644 --- a/tests/Api.test.ts +++ b/tests/Api.test.ts @@ -1,24 +1,10 @@ import { Api } from '../src/index'; import ApiError from '../src/utils/ApiError'; -import { BOT, BOT_STATS, VOTE, VOTES } from './mocks/data'; +import { BOT, BOT_STATS, VOTES } from './mocks/data'; /* mock token */ const client = new Api('.eyJfdCI6IiIsImlkIjoiMzY0ODA2MDI5ODc2NTU1Nzc2In0=.'); -describe('API postBotCommands test', () => { - it('postBotCommands should work', () => { - expect(client.postBotCommands([{ - id: '1', - type: 1, - application_id: '1', - name: 'test', - description: 'command description', - default_member_permissions: '', - version: '1' - }])).resolves.toBeUndefined(); - }); -}); - describe('API postBotServerCount test', () => { it('postBotServerCount with invalid negative server count should throw error', () => { expect(client.postBotServerCount(-1)).rejects.toThrow(Error); @@ -55,13 +41,13 @@ describe('API getVoters test', () => { }); }); -describe('API getVote test', () => { - it('getVote should return 200 when token is provided', () => { - expect(client.getVote('1')).resolves.toStrictEqual(VOTE); +describe('API hasVoted test', () => { + it('hasVoted should return 200 when token is provided', () => { + expect(client.hasVoted('1')).resolves.toBe(true); }); - it('getVote should throw error when no id is provided', () => { - expect(client.getVote('')).rejects.toThrow(Error); + it('hasVoted should throw error when no id is provided', () => { + expect(client.hasVoted('')).rejects.toThrow(Error); }); }); diff --git a/tests/V1Api.test.ts b/tests/V1Api.test.ts new file mode 100644 index 0000000..da6a3a8 --- /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 postBotCommands test', () => { + it('postBotCommands should work', () => { + expect(client.postBotCommands([{ + 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); + }); +}); From 6d866f5306b5c9fb7e9cd61805ca77dc9577d858 Mon Sep 17 00:00:00 2001 From: null8626 Date: Mon, 15 Sep 2025 23:10:16 +0700 Subject: [PATCH 15/29] refactor: use Bearer prefix for legacy and new tokens --- src/structs/Api.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/structs/Api.ts b/src/structs/Api.ts index 7e9ac25..79eff21 100644 --- a/src/structs/Api.ts +++ b/src/structs/Api.ts @@ -70,7 +70,7 @@ 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}`; @@ -248,7 +248,7 @@ export class V1Api extends Api { * @param {APIOptions} [options] API Options */ constructor(token: string, options: APIOptions = {}) { - super(`Bearer ${token}`, options); + super(token, options); } /** @@ -320,4 +320,4 @@ export class V1Api extends Api { throw err; } } -} \ No newline at end of file +} From 66adfe36e15b86b8a44dc0f5c5b05701a52c4c73 Mon Sep 17 00:00:00 2001 From: null8626 Date: Mon, 15 Sep 2025 23:12:49 +0700 Subject: [PATCH 16/29] doc: add @see Webhook#voteListener --- src/structs/Webhook.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/structs/Webhook.ts b/src/structs/Webhook.ts index ff5d287..bcf4ca6 100644 --- a/src/structs/Webhook.ts +++ b/src/structs/Webhook.ts @@ -6,6 +6,7 @@ export interface WebhookOptions { /** * Handles an error created by the function passed to webhook listeners * + * @see Webhook#voteListener * @default console.error */ error?: (error: Error) => void | Promise; From 3a234adc43f0dd0d9562c0def6a7fb8c7f0531a7 Mon Sep 17 00:00:00 2001 From: null8626 Date: Mon, 15 Sep 2025 23:47:06 +0700 Subject: [PATCH 17/29] doc: update documentation and readme --- README.md | 174 ++++++++++++++++++++------------------------- src/structs/Api.ts | 2 +- 2 files changed, 78 insertions(+), 98 deletions(-) diff --git a/README.md b/README.md index 4f9ac43..9c74ba3 100644 --- a/README.md +++ b/README.md @@ -7,16 +7,19 @@ The community-maintained Node.js library for Top.gg. - [Installation](#installation) - [Setting up](#setting-up) - [Usage](#usage) - - [Getting a bot](#getting-a-bot) - - [Getting several bots](#getting-several-bots) - - [Getting your project's voters](#getting-your-projects-voters) - - [Getting your project's vote information of a user](#getting-your-projects-vote-information-of-a-user) - - [Getting your bot's server count](#getting-your-bots-server-count) - - [Posting your bot's server count](#posting-your-bots-server-count) - - [Posting your bot's application commands list](#posting-your-bots-application-commands-list) - - [Automatically posting your bot's server count every few minutes](#automatically-posting-your-bots-server-count-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) + - [API v1](#api-v1) + - [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) + - [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 server count](#getting-your-bots-server-count) + - [Posting your bot's server count](#posting-your-bots-server-count) + - [Automatically posting your bot's server count every few minutes](#automatically-posting-your-bots-server-count-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) @@ -36,15 +39,15 @@ $ yarn add @top-gg/sdk ## Setting up -### CommonJS +### v1 ```js -const Topgg = require("@top-gg/sdk"); +import Topgg from "@top-gg/sdk"; -const client = new Topgg.Api(process.env.TOPGG_TOKEN); +const client = new Topgg.V1Api(process.env.TOPGG_TOKEN); ``` -### ES module +### v0 ```js import Topgg from "@top-gg/sdk"; @@ -54,113 +57,123 @@ const client = new Topgg.Api(process.env.TOPGG_TOKEN); ## Usage -### Getting a bot +### API v1 + +#### Getting your project's vote information of a user + +##### Discord ID ```js -const bot = await client.getBot("461521980492087297"); +const vote = await client.getVote("661200758510977084"); ``` -### Getting several bots +##### Top.gg ID ```js -const bots = await client.getBots(); +const vote = await client.getVote("8226924471638491136", "topgg"); ``` -### Getting your project's voters +#### Posting your bot's application commands list -#### First page +##### Discord.js ```js -const voters = await client.getVoters(); +const commands = (await bot.application.commands.fetch()).map(cmd => cmd.toJSON()); + +await client.postBotCommands(commands); ``` -#### Subsequent pages +##### Eris ```js -const voters = await client.getVoters(2); -``` +const commands = await bot.getCommands(); -### Getting your project's vote information of a user +await client.postBotCommands(commands); +``` -#### Discord ID +##### Discordeno ```js -const vote = await client.getVote("661200758510977084"); -``` +import { getApplicationCommands } from "discordeno"; -#### Top.gg ID +const commands = await getApplicationCommands(bot); -```js -const vote = await client.getVote("8226924471638491136", "topgg"); +await client.postBotCommands(commands); ``` -### Getting your bot's server count +##### Harmony ```js -const serverCount = await client.getBotServerCount(); +const commands = await bot.interactions.commands.all(); + +await client.postBotCommands(commands); ``` -### Posting your bot's server count +##### Oceanic ```js -await client.postBotServerCount(bot.getServerCount()); +const commands = await bot.application.getGlobalCommands(); + +await client.postBotCommands(commands); ``` -### Posting your bot's application commands list +### API v0 -#### Discord.js +#### Getting a bot ```js -const commands = (await bot.application.commands.fetch()).map(cmd => cmd.toJSON()); - -await client.postBotCommands(commands); +const bot = await client.getBot("461521980492087297"); ``` -#### Eris +#### Getting several bots ```js -const commands = await bot.getCommands(); - -await client.postBotCommands(commands); +const bots = await client.getBots(); ``` -#### Discordeno +#### Getting your project's voters + +##### First page ```js -import { getApplicationCommands } from "discordeno"; +const voters = await client.getVoters(); +``` -const commands = await getApplicationCommands(bot); +##### Subsequent pages -await client.postBotCommands(commands); +```js +const voters = await client.getVoters(2); ``` -#### Harmony +#### Check if a user has voted for your project ```js -const commands = await bot.interactions.commands.all(); - -await client.postBotCommands(commands); +const hasVoted = await client.hasVoted("661200758510977084"); ``` -#### Oceanic +#### Getting your bot's server count ```js -const commands = await bot.application.getGlobalCommands(); +const serverCount = await client.getBotServerCount(); +``` -await client.postBotCommands(commands); +#### Posting your bot's server count + +```js +await client.postBotServerCount(bot.getServerCount()); ``` -### Automatically posting your bot's server count every few minutes +#### Automatically posting your bot's server count 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 +##### NPM ```sh $ npm i topgg-autoposter ``` -#### Yarn +##### Yarn ```sh $ yarn add topgg-autoposter @@ -168,21 +181,6 @@ $ yarn add topgg-autoposter Then in your code: -#### CommonJS - -```js -const { AutoPoster } = require("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 server count to Top.gg!"); -}); -``` - -#### ES module - ```js import { AutoPoster } from "topgg-autoposter"; @@ -194,33 +192,33 @@ AutoPoster(process.env.TOPGG_TOKEN, client).on("posted", () => { }); ``` -### Checking if the weekend vote multiplier is active +#### Checking if the weekend vote multiplier is active ```js const isWeekend = await client.isWeekend(); ``` -### Generating widget URLs +#### Generating widget URLs -#### Large +##### Large ```js const widgetUrl = Topgg.Widget.large(Topgg.WidgetType.DiscordBot, "574652751745777665"); ``` -#### Votes +##### Votes ```js const widgetUrl = Topgg.Widget.votes(Topgg.WidgetType.DiscordBot, "574652751745777665"); ``` -#### Owner +##### Owner ```js const widgetUrl = Topgg.Widget.owner(Topgg.WidgetType.DiscordBot, "574652751745777665"); ``` -#### Social +##### Social ```js const widgetUrl = Topgg.Widget.social(Topgg.WidgetType.DiscordBot, "574652751745777665"); @@ -232,24 +230,6 @@ const widgetUrl = Topgg.Widget.social(Topgg.WidgetType.DiscordBot, "574652751745 With express: -##### CommonJS - -```js -const { Webhook } = require("@top-gg/sdk"); -const express = require("express"); - -const app = express(); -const webhook = new Webhook(process.env.MY_TOPGG_WEBHOOK_SECRET); - -app.post("/votes", webhook.voteListener(vote => { - console.log(`A user with the ID of ${vote.voterId} has voted us on Top.gg!`); -})); - -app.listen(8080); -``` - -##### ES module - ```js import { Webhook } from "@top-gg/sdk"; import express from "express"; diff --git a/src/structs/Api.ts b/src/structs/Api.ts index 79eff21..bc1773d 100644 --- a/src/structs/Api.ts +++ b/src/structs/Api.ts @@ -198,7 +198,7 @@ export class Api extends EventEmitter { * * @example * ```js - * const hasVoted = await client.hasVoted("205680187394752512"); + * const hasVoted = await client.hasVoted("661200758510977084"); * ``` * * @param {Snowflake} id User ID From b2652b2ddea3dd01512d2a79f9986aab05fbf518 Mon Sep 17 00:00:00 2001 From: null8626 Date: Mon, 15 Sep 2025 23:58:35 +0700 Subject: [PATCH 18/29] meta: make SDK description clearer --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 8d1ded0..f2e0afe 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@top-gg/sdk", "version": "3.2.0", - "description": "Official Top.gg Node SDK", + "description": "A community-maintained Node.js API Client for the Top.gg API.", "main": "./dist/index.js", "scripts": { "test": "jest --verbose", From 89be620243db4bc3ef35f3a673b04cdfec91e553 Mon Sep 17 00:00:00 2001 From: null8626 Date: Tue, 16 Sep 2025 10:23:07 +0700 Subject: [PATCH 19/29] revert: revert breaking changes to webhooks --- README.md | 4 +- src/structs/Webhook.ts | 137 +++++++++++++++++++++++------------------ src/typings.ts | 27 ++++---- 3 files changed, 94 insertions(+), 74 deletions(-) diff --git a/README.md b/README.md index 9c74ba3..149382d 100644 --- a/README.md +++ b/README.md @@ -237,8 +237,8 @@ import express from "express"; const app = express(); const webhook = new Webhook(process.env.MY_TOPGG_WEBHOOK_SECRET); -app.post("/votes", webhook.voteListener(vote => { - console.log(`A user with the ID of ${vote.voterId} has voted us on Top.gg!`); +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/src/structs/Webhook.ts b/src/structs/Webhook.ts index bcf4ca6..c5ebb1b 100644 --- a/src/structs/Webhook.ts +++ b/src/structs/Webhook.ts @@ -4,9 +4,8 @@ import { WebhookPayload } from "../typings"; export interface WebhookOptions { /** - * Handles an error created by the function passed to webhook listeners + * Handles an error created by the function passed to Webhook.listener() * - * @see Webhook#voteListener * @default console.error */ error?: (error: Error) => void | Promise; @@ -17,17 +16,22 @@ export interface WebhookOptions { * * @example * ```js - * const { Webhook } = require("@top-gg/sdk"); * const express = require("express"); - * + * const { Webhook } = require("@top-gg/sdk"); + * * const app = express(); - * const webhook = new Webhook(process.env.MY_TOPGG_WEBHOOK_SECRET); - * - * app.post("/votes", webhook.voteListener(vote => { - * console.log(`A user with the ID of ${vote.voterId} has voted us on Top.gg!`); + * const wh = new Webhook("webhookauth123"); + * + * app.post("/dblwebhook", wh.listener((vote) => { + * // vote is your vote object e.g + * console.log(vote.user); // => 321714991050784770 * })); - * - * app.listen(8080); + * + * app.listen(80); + * + * // In this situation, your TopGG Webhook dashboard should look like + * // URL = http://your.server.ip:80/dblwebhook + * // Authorization: webhookauth123 * ``` * * @link {@link https://docs.top.gg/resources/webhooks/#schema | Webhook Data Schema} @@ -39,43 +43,43 @@ export class Webhook { /** * Create a new webhook client instance * - * @param {?string} authorization Webhook authorization to verify requests + * @param 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 + error: options.error ?? console.error, }; } - private _formatVotePayload(body: any): WebhookPayload { - return { - receiverId: (body.bot ?? body.guild)!, - voterId: body.user, - isTest: body.type === "test", - isWeekend: body.isWeekend, - query: body.query ?? Object.fromEntries(new URLSearchParams(body.query)) - }; + private _formatIncoming( + body: WebhookPayload & { query: string } + ): WebhookPayload { + const out: WebhookPayload = { ...body }; + if (body?.query?.length > 0) + out.query = Object.fromEntries(new URLSearchParams(body.query)); + return out; } - private _parseRequest(req: Request, res: Response): Promise { + private _parseRequest( + req: Request, + res: Response + ): Promise { return new Promise((resolve) => { if ( this.authorization && req.headers.authorization !== this.authorization ) return res.status(401).json({ error: "Unauthorized" }); - // parse json - if (req.body) return resolve(req.body); + if (req.body) return resolve(this._formatIncoming(req.body)); getBody(req, {}, (error, body) => { if (error) return res.status(422).json({ error: "Malformed request" }); try { - resolve(JSON.parse(body.toString("utf8"))); + const parsed = JSON.parse(body.toString("utf8")); + + resolve(this._formatIncoming(parsed)); } catch { res.status(400).json({ error: "Invalid body" }); resolve(false); @@ -84,10 +88,32 @@ export class Webhook { }); } - private _listener( - formatFn: (data: any) => T, - callbackFn: ( - payload: T, + /** + * Listening function for handling webhook requests + * + * @example + * ```js + * app.post("/webhook", wh.listener((vote) => { + * console.log(vote.user); // => 395526710101278721 + * })); + * ``` + * + * @example + * ```js + * // Throwing an error to resend the webhook + * app.post("/webhook/", wh.listener((vote) => { + * // for example, if your bot is offline, you should probably not handle votes and try again + * if (bot.offline) throw new Error('Bot offline'); + * })); + * ``` + * + * @param fn Vote handling function, this function can also throw an error to + * allow for the webhook to resend from Top.gg + * @returns An express request handler + */ + public listener( + fn: ( + payload: WebhookPayload, req?: Request, res?: Response, next?: NextFunction @@ -99,11 +125,10 @@ export class Webhook { next: NextFunction ): Promise => { const response = await this._parseRequest(req, res); - if (!response) return; try { - await callbackFn(formatFn(response), req, res, next); + await fn(response, req, res, next); if (!res.headersSent) { res.sendStatus(204); @@ -117,36 +142,28 @@ export class Webhook { } /** - * Listening function for handling webhook requests + * 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("/votes", webhook.voteListener(vote => { - * console.log(`A user with the ID of ${vote.voterId} has voted us on Top.gg!`); - * })); + * app.post("/dblwebhook", wh.middleware(), (req, res) => { + * // req.vote is your payload e.g + * console.log(req.vote.user); // => 395526710101278721 + * }); * ``` - * - * @example - * ```js - * // Throwing an error to resend the webhook - * app.post("/votes", webhook.voteListener(vote => { - * // For example, if your bot is offline, you should probably not handle votes and try again. - * if (bot.offline) throw new Error('Bot offline'); - * })); - * ``` - * - * @param {(payload: WebhookPayload, req?: Request, res?: Response, next?: NextFunction) => void | Promise} fn Vote handling function, this function can also throw an error to - * allow for the webhook to resend from Top.gg - * @returns An express request handler */ - public voteListener( - fn: ( - payload: WebhookPayload, - req?: Request, - res?: Response, - next?: NextFunction - ) => void | Promise - ) { - return this._listener(this._formatVotePayload, fn); + 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/typings.ts b/src/typings.ts index 6f54cd5..278d0fc 100644 --- a/src/typings.ts +++ b/src/typings.ts @@ -101,18 +101,15 @@ export interface ShortUser { } export interface WebhookPayload { - /** The ID of the project that received a vote. */ - receiverId: Snowflake; - /** The ID of the Top.gg user who voted. */ - voterId: Snowflake; - /** - * Whether this vote is just a test done from the page settings. - */ - isTest: boolean; - /** - * Whether the weekend multiplier is in effect, meaning users votes count as - * two - */ + /** 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") */ + 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: @@ -121,3 +118,9 @@ export interface WebhookPayload { } | string; } + +declare module "express" { + export interface Request { + vote?: WebhookPayload; + } +} From 6fe8cf46176f625141376b2c7553c6348966cb98 Mon Sep 17 00:00:00 2001 From: null8626 Date: Thu, 18 Sep 2025 22:41:18 +0700 Subject: [PATCH 20/29] revert: revert breaking changes --- src/structs/Api.ts | 70 +++++++++++++++++++---- src/typings.ts | 135 +++++++++++++++++++++++++++++++++++++++++++-- tests/Api.test.ts | 34 ++++++++---- 3 files changed, 211 insertions(+), 28 deletions(-) diff --git a/src/structs/Api.ts b/src/structs/Api.ts index bc1773d..c53ff7f 100644 --- a/src/structs/Api.ts +++ b/src/structs/Api.ts @@ -10,9 +10,11 @@ import { Snowflake, BotInfo, BotsResponse, + BotStats, ShortUser, BotsQuery, Vote, + UserInfo, UserSource, } from "../typings"; @@ -107,37 +109,58 @@ export class Api extends EventEmitter { } /** - * Post your Discord bot's server count to Top.gg + * Post your bot's stats to Top.gg * * @example * ```js - * await client.postBotServerCount(bot.getServerCount()); + * await api.postStats({ + * serverCount: 28199, + * }); * ``` * - * @param {number} serverCount Server count + * @param {object} stats Stats object + * @param {number} stats.serverCount Server count + * @returns {BotStats} Passed object */ - public async postBotServerCount(serverCount: number): Promise { - if ((serverCount ?? 0) <= 0) throw new Error("Missing server count"); + 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: serverCount, + server_count: stats.serverCount, }); /* eslint-enable camelcase */ + + return stats; } /** - * Get your Discord bot's server count + * Get your bot's stats * * @example * ```js - * const serverCount = await client.getBotServerCount(); + * await api.getStats(); + * // => + * { + * serverCount: 28199, + * shardCount: null, + * shards: [] + * } * ``` * - * @returns {number} Your bot's server count + * @returns {BotStats} Your bot's stats */ - public async getBotServerCount(): Promise { - return (await this._request("GET", "/bots/stats")).server_count; + 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: [], + }; } /** @@ -156,6 +179,29 @@ export class Api extends EventEmitter { 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 + */ // 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." + ); + } + /** * Get a list of bots * @@ -189,7 +235,7 @@ export class Api extends EventEmitter { * @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 getVoters(page?: number): Promise { + public async getVotes(page?: number): Promise { return this._request("GET", `/bots/${this.options.id}/votes`, { page: page ?? 1 }); } diff --git a/src/typings.ts b/src/typings.ts index 278d0fc..a0b17c0 100644 --- a/src/typings.ts +++ b/src/typings.ts @@ -1,14 +1,14 @@ +/** Discord ID */ +export type Snowflake = string; + export interface APIOptions { /** Top.gg API token */ token?: string; /** Client ID to use */ - id?: string; + 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"; @@ -19,8 +19,32 @@ export interface BotInfo { 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 */ @@ -37,16 +61,34 @@ export interface BotInfo { 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 */ @@ -58,11 +100,90 @@ export interface BotInfo { }; } +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; +} + +/** + * @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; +} + 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" + * + * @deprecated No longer supported by Top.gg API v0. + */ + 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. */ @@ -96,6 +217,12 @@ export interface ShortUser { 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; } diff --git a/tests/Api.test.ts b/tests/Api.test.ts index fccdd98..89259e7 100644 --- a/tests/Api.test.ts +++ b/tests/Api.test.ts @@ -5,19 +5,29 @@ import { BOT, BOT_STATS, VOTES } from './mocks/data'; /* mock token */ const client = new Api('.eyJfdCI6IiIsImlkIjoiMzY0ODA2MDI5ODc2NTU1Nzc2In0=.'); -describe('API postBotServerCount test', () => { - it('postBotServerCount with invalid negative server count should throw error', () => { - expect(client.postBotServerCount(-1)).rejects.toThrow(Error); +describe('API postStats test', () => { + it('postStats without server count should throw error', async () => { + await expect(client.postStats({ shardCount: 0 })).rejects.toThrow(Error); }); - it('postBotServerCount should return 200', async () => { - await expect(client.postBotServerCount(1)).resolves.toBeUndefined(); + 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 getBotServerCount test', () => { - it('getBotServerCount should return 200 when bot is found', async () => { - expect(client.getBotServerCount()).resolves.toStrictEqual(BOT_STATS.server_count); +describe('API getStats test', () => { + it('getStats should return 200 when bot is found', async () => { + expect(client.getStats()).resolves.toStrictEqual({ + serverCount: BOT_STATS.server_count, + shardCount: BOT_STATS.shard_count, + shards: BOT_STATS.shards + }); }); }); @@ -35,9 +45,9 @@ describe('API getBot test', () => { }); }); -describe('API getVoters test', () => { - it('getVoters should return 200 when token is provided', () => { - expect(client.getVoters()).resolves.toStrictEqual(VOTES); +describe('API getVotes test', () => { + it('getVotes should return 200 when token is provided', () => { + expect(client.getVotes()).resolves.toEqual(VOTES); }); }); @@ -55,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 From 840abb8dba842d25e67eae2f213f547cce8413e0 Mon Sep 17 00:00:00 2001 From: null8626 Date: Fri, 3 Oct 2025 23:33:06 +0700 Subject: [PATCH 21/29] doc: update readme --- README.md | 37 +++++++++++++++++++++++++++---------- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 149382d..95c4922 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,17 @@ # Top.gg Node.js SDK +> For more information, see the documentation here: https://topgg.js.org. + The community-maintained Node.js library for Top.gg. ## Chapters - [Installation](#installation) + - [NPM](#npm) + - [Yarn](#yarn) - [Setting up](#setting-up) + - [v1](#v1) + - [v0](#v0) - [Usage](#usage) - [API v1](#api-v1) - [Getting your project's vote information of a user](#getting-your-projects-vote-information-of-a-user) @@ -15,16 +21,18 @@ The community-maintained Node.js library for Top.gg. - [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 server count](#getting-your-bots-server-count) - - [Posting your bot's server count](#posting-your-bots-server-count) - - [Automatically posting your bot's server count every few minutes](#automatically-posting-your-bots-server-count-every-few-minutes) + - [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) + ## Installation + ### NPM ```sh @@ -39,6 +47,7 @@ $ yarn add @top-gg/sdk ## Setting up + ### v1 ```js @@ -61,6 +70,7 @@ const client = new Topgg.Api(process.env.TOPGG_TOKEN); #### Getting your project's vote information of a user + ##### Discord ID ```js @@ -75,6 +85,7 @@ const vote = await client.getVote("8226924471638491136", "topgg"); #### Posting your bot's application commands list + ##### Discord.js ```js @@ -133,6 +144,7 @@ const bots = await client.getBots(); #### Getting your project's voters + ##### First page ```js @@ -151,22 +163,26 @@ const voters = await client.getVoters(2); const hasVoted = await client.hasVoted("661200758510977084"); ``` -#### Getting your bot's server count +#### Getting your bot's statistics ```js -const serverCount = await client.getBotServerCount(); +const stats = await client.getStats(); ``` -#### Posting your bot's server count +#### Posting your bot's statistics ```js -await client.postBotServerCount(bot.getServerCount()); + +await api.postStats({ + serverCount: bot.getServerCount(), +}); ``` -#### Automatically posting your bot's server count every few minutes +#### 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 @@ -188,7 +204,7 @@ import { AutoPoster } from "topgg-autoposter"; const client = Discord.Client(); AutoPoster(process.env.TOPGG_TOKEN, client).on("posted", () => { - console.log("Successfully posted server count to Top.gg!"); + console.log("Successfully posted statistics to Top.gg!"); }); ``` @@ -200,6 +216,7 @@ const isWeekend = await client.isWeekend(); #### Generating widget URLs + ##### Large ```js @@ -235,7 +252,7 @@ import { Webhook } from "@top-gg/sdk"; import express from "express"; const app = express(); -const webhook = new Webhook(process.env.MY_TOPGG_WEBHOOK_SECRET); +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!`); From 15fb73a1a7e3bb37ffc8cd3225d62d51dd10336f Mon Sep 17 00:00:00 2001 From: null8626 Date: Tue, 7 Oct 2025 12:49:17 +0700 Subject: [PATCH 22/29] ci: bump github workflow dependency versions --- .github/workflows/build.yml | 6 +++--- .github/workflows/publish.yml | 4 ++-- .github/workflows/test.yml | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) 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 From 1ddf708c964aceb5f0cbecf070658d958327e5ab Mon Sep 17 00:00:00 2001 From: null8626 Date: Fri, 10 Oct 2025 11:40:01 +0700 Subject: [PATCH 23/29] doc: fix less accurate naming --- src/structs/Api.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/structs/Api.ts b/src/structs/Api.ts index c53ff7f..9373f21 100644 --- a/src/structs/Api.ts +++ b/src/structs/Api.ts @@ -19,7 +19,7 @@ import { } from "../typings"; /** - * Top.gg v0 API Client + * Top.gg API v0 Client * * @example * ```js @@ -274,7 +274,7 @@ export class Api extends EventEmitter { } /** - * Top.gg v1 API Client + * Top.gg API v1 Client * * @example * ```js From 59c5257553cdee89f12dda552c2c609320191d67 Mon Sep 17 00:00:00 2001 From: null8626 Date: Fri, 10 Oct 2025 11:54:07 +0700 Subject: [PATCH 24/29] doc: objects, not dicts --- src/structs/Api.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/structs/Api.ts b/src/structs/Api.ts index 9373f21..2daa1f1 100644 --- a/src/structs/Api.ts +++ b/src/structs/Api.ts @@ -322,7 +322,7 @@ export class V1Api extends Api { * await client.postBotCommands(commands); * ``` * - * @param {APIApplicationCommand[]} commands A list of application commands in raw Discord API JSON dicts. This cannot be empty. + * @param {APIApplicationCommand[]} commands A list of application commands in raw Discord API JSON objects. This cannot be empty. */ public async postBotCommands(commands: APIApplicationCommand[]): Promise { await this._request("POST", "/v1/projects/@me/commands", commands); From aa408ac638973f2229c3242578cd6a611c065183 Mon Sep 17 00:00:00 2001 From: null8626 Date: Fri, 10 Oct 2025 13:40:37 +0700 Subject: [PATCH 25/29] doc: documentation tweaks --- README.md | 36 +++++++++++++++++++++++------------- src/structs/Api.ts | 21 +++++++++++++++++++-- 2 files changed, 42 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 95c4922..76bdd24 100644 --- a/README.md +++ b/README.md @@ -7,11 +7,7 @@ The community-maintained Node.js library for Top.gg. ## Chapters - [Installation](#installation) - - [NPM](#npm) - - [Yarn](#yarn) - [Setting up](#setting-up) - - [v1](#v1) - - [v0](#v0) - [Usage](#usage) - [API v1](#api-v1) - [Getting your project's vote information of a user](#getting-your-projects-vote-information-of-a-user) @@ -29,10 +25,8 @@ The community-maintained Node.js library for Top.gg. - [Webhooks](#webhooks) - [Being notified whenever someone voted for your project](#being-notified-whenever-someone-voted-for-your-project) - ## Installation - ### NPM ```sh @@ -47,8 +41,9 @@ $ yarn add @top-gg/sdk ## Setting up +### API v1 -### v1 +Note that API v1 also includes API v0. ```js import Topgg from "@top-gg/sdk"; @@ -56,7 +51,7 @@ import Topgg from "@top-gg/sdk"; const client = new Topgg.V1Api(process.env.TOPGG_TOKEN); ``` -### v0 +### API v0 ```js import Topgg from "@top-gg/sdk"; @@ -70,7 +65,6 @@ const client = new Topgg.Api(process.env.TOPGG_TOKEN); #### Getting your project's vote information of a user - ##### Discord ID ```js @@ -85,7 +79,6 @@ const vote = await client.getVote("8226924471638491136", "topgg"); #### Posting your bot's application commands list - ##### Discord.js ```js @@ -128,6 +121,26 @@ const commands = await bot.application.getGlobalCommands(); await client.postBotCommands(commands); ``` +##### Raw + +```js +await client.postBotCommands([ + { + 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 @@ -144,7 +157,6 @@ const bots = await client.getBots(); #### Getting your project's voters - ##### First page ```js @@ -182,7 +194,6 @@ await api.postStats({ 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 @@ -216,7 +227,6 @@ const isWeekend = await client.isWeekend(); #### Generating widget URLs - ##### Large ```js diff --git a/src/structs/Api.ts b/src/structs/Api.ts index 2daa1f1..d2a95f2 100644 --- a/src/structs/Api.ts +++ b/src/structs/Api.ts @@ -19,7 +19,7 @@ import { } from "../typings"; /** - * Top.gg API v0 Client + * Top.gg API v0 client * * @example * ```js @@ -274,7 +274,7 @@ export class Api extends EventEmitter { } /** - * Top.gg API v1 Client + * Top.gg API v1 client * * @example * ```js @@ -320,6 +320,23 @@ export class V1Api extends Api { * const commands = await bot.application.getGlobalCommands(); * * await client.postBotCommands(commands); + * + * // Raw: + * await client.postBotCommands([ + * { + * 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. From 9f88be37d6f3878832e9eee79ed1ace5d6df1a3c Mon Sep 17 00:00:00 2001 From: null8626 Date: Fri, 10 Oct 2025 17:25:58 +0700 Subject: [PATCH 26/29] feat: rename postBotCommands to postCommands --- README.md | 12 ++++++------ src/structs/Api.ts | 6 +++--- tests/V1Api.test.ts | 6 +++--- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 76bdd24..1ed342c 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ const vote = await client.getVote("8226924471638491136", "topgg"); ```js const commands = (await bot.application.commands.fetch()).map(cmd => cmd.toJSON()); -await client.postBotCommands(commands); +await client.postCommands(commands); ``` ##### Eris @@ -92,7 +92,7 @@ await client.postBotCommands(commands); ```js const commands = await bot.getCommands(); -await client.postBotCommands(commands); +await client.postCommands(commands); ``` ##### Discordeno @@ -102,7 +102,7 @@ import { getApplicationCommands } from "discordeno"; const commands = await getApplicationCommands(bot); -await client.postBotCommands(commands); +await client.postCommands(commands); ``` ##### Harmony @@ -110,7 +110,7 @@ await client.postBotCommands(commands); ```js const commands = await bot.interactions.commands.all(); -await client.postBotCommands(commands); +await client.postCommands(commands); ``` ##### Oceanic @@ -118,13 +118,13 @@ await client.postBotCommands(commands); ```js const commands = await bot.application.getGlobalCommands(); -await client.postBotCommands(commands); +await client.postCommands(commands); ``` ##### Raw ```js -await client.postBotCommands([ +await client.postCommands([ { options: [], name: 'test', diff --git a/src/structs/Api.ts b/src/structs/Api.ts index d2a95f2..da2bf86 100644 --- a/src/structs/Api.ts +++ b/src/structs/Api.ts @@ -319,10 +319,10 @@ export class V1Api extends Api { * // Oceanic: * const commands = await bot.application.getGlobalCommands(); * - * await client.postBotCommands(commands); + * await client.postCommands(commands); * * // Raw: - * await client.postBotCommands([ + * await client.postCommands([ * { * options: [], * name: 'test', @@ -341,7 +341,7 @@ export class V1Api extends Api { * * @param {APIApplicationCommand[]} commands A list of application commands in raw Discord API JSON objects. This cannot be empty. */ - public async postBotCommands(commands: APIApplicationCommand[]): Promise { + public async postCommands(commands: APIApplicationCommand[]): Promise { await this._request("POST", "/v1/projects/@me/commands", commands); } diff --git a/tests/V1Api.test.ts b/tests/V1Api.test.ts index da6a3a8..eeff856 100644 --- a/tests/V1Api.test.ts +++ b/tests/V1Api.test.ts @@ -4,9 +4,9 @@ import { VOTE } from './mocks/data'; /* mock token */ const client = new V1Api('.eyJfdCI6IiIsImlkIjoiMzY0ODA2MDI5ODc2NTU1Nzc2In0=.'); -describe('API postBotCommands test', () => { - it('postBotCommands should work', () => { - expect(client.postBotCommands([{ +describe('API postCommands test', () => { + it('postCommands should work', () => { + expect(client.postCommands([{ id: '1', type: 1, application_id: '1', From eb64cb1c8e47f17be08ae03fed43420c989ae31e Mon Sep 17 00:00:00 2001 From: null8626 Date: Fri, 10 Oct 2025 17:42:17 +0700 Subject: [PATCH 27/29] doc: readme tweak --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 1ed342c..3cb7a41 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ $ yarn add @top-gg/sdk ### API v1 -Note that API v1 also includes API v0. +> **NOTE**: API v1 also includes API v0. ```js import Topgg from "@top-gg/sdk"; From 2c159e0e7d53da6c6e69cd2f95f554844cb7e861 Mon Sep 17 00:00:00 2001 From: null <60427892+null8626@users.noreply.github.com> Date: Tue, 21 Oct 2025 00:08:52 +0700 Subject: [PATCH 28/29] doc: fix API version links in README.md --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 3cb7a41..1034499 100644 --- a/README.md +++ b/README.md @@ -9,10 +9,10 @@ The community-maintained Node.js library for Top.gg. - [Installation](#installation) - [Setting up](#setting-up) - [Usage](#usage) - - [API v1](#api-v1) + - [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) + - [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) @@ -269,4 +269,4 @@ app.post("/votes", webhook.listener(vote => { })); app.listen(8080); -``` \ No newline at end of file +``` From 40cae1c693599e30bfd7c945a8c8af4f641727d5 Mon Sep 17 00:00:00 2001 From: null <60427892+null8626@users.noreply.github.com> Date: Tue, 21 Oct 2025 17:23:04 +0700 Subject: [PATCH 29/29] doc: remove for more information link from README --- README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/README.md b/README.md index 1034499..dab0c59 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,5 @@ # Top.gg Node.js SDK -> For more information, see the documentation here: https://topgg.js.org. - The community-maintained Node.js library for Top.gg. ## Chapters