Client.js

const Fetch = require('node-fetch').default; // Only for linting
const util = require('util'); // eslint-disable-line no-unused-vars

const isObject = obj => !Array.isArray(obj) && obj === Object(obj);
const check = require('./util/check.js');

const ok = /2\d\d/;

/**
 * @external Store
 * @see {@link https://github.com/iREDMe/red-store}
 */
const Store = require('@ired_me/red-store');

const Bot = require('./structures/Bot.js');
const User = require('./structures/User.js');
const Upvote = require('./structures/Upvote.js');
const Stats = require('./structures/Stats.js');
const { Ratelimit, FetchError } = require('./structures/errors.js');
const { ClientOpts, FetchOpts, PostOpts, MultiFetchOpts } = require('./structures/options.js');

/**
 * Main client class for interacting to botlist.space
 */
class Client {
	/**
	 * @param {ClientOptions} [options] The options to configure.
	 */
	constructor(options = ClientOpts) {
		/**
		 * The ClientOpts.
		 * @type {ClientOptions}
		 */
		this.options = ClientOpts;

		this.edit(options, true);

		/**
		 * Every bot cached, mapped by their IDs.
		 * @type {Store<string, Bot>}
		 */
		this.bots = new Store();

		/**
		 * Every user cached, mapped by their IDs.
		 * @type {Store<string, User>}
		 */
		this.users = new Store();

		/**
		 * An array of the latest fetched Statistics, from oldest to newest.
		 * @type {Stats[]}
		 */
		this.stats = [];
	}

	async get(point, version, headers = {}) {
		let endpoint = this.endpoint + version + point;
		endpoint += Object.entries(headers).map((e, i) => (i ? '&' : '?') + e[0] + '=' + e[1]).join('');
		const i = await Fetch(endpoint);
		if (i.status === 429) throw new Ratelimit(i.headers, version + point);
		const contents = await i.json();
		if (contents.code && !ok.test(contents.code)) throw new FetchError(i, contents.message);
		else return contents;
	}

	async authGet(point, version, Authorization, headers = {}) {
		let endpoint = this.endpoint + version + point;
		endpoint += Object.entries(headers).map((e, i) => (i ? '&' : '?') + e[0] + '=' + e[1]).join('');
		const i = await Fetch(endpoint, {
			headers: {
				Authorization: Authorization
			}
		});
		if (i.status === 429) throw new Ratelimit(i.headers, version + point);
		const contents = await i.json();
		if (contents.code && !ok.test(contents.code)) throw new FetchError(i, contents.message);
		else return contents;
	}

	async post(point, version, Authorization, body) {
		const i = await Fetch(this.endpoint + version + point, {
			method: 'post',
			headers: {
				Authorization: Authorization,
				'Content-Type': 'application/json'
			},
			body: JSON.stringify(body),
		});
		if (i.status === 429) throw new Ratelimit(i.headers, version + point);
		const contents = await i.json();
		if (contents.code && !ok.test(contents.code)) throw new FetchError(i, contents.message);
		else return contents;
	}

	/**
	 * The endpoint to use for interaction with botlist.space.
	 * The version number is missing; Fulfilled only when fetching/posting.
	 * @readonly
	 * @type {string}
	 */
	get endpoint() {
		return 'https://api.botlist.space/v';
	}

	/**
	 * Edit the options of the Client.
	 * @param {ClientOptions} [options={}] The options to change.
	 * @param {boolean} [preset=false] If true, uses the default ClientOpts as a target copy. Otherwise, {@link Client#options} is used.
	 * @returns {ClientOptions}
	 */
	edit(options = {}, preset = false) {
		if (!isObject(options)) throw new TypeError('options must be an object.');
		const toCheck = Object.assign(preset ? ClientOpts : this.options, options);
		check.edit(toCheck);

		if (toCheck.statsLimit < this.options.statsLimit) while (this.stats.length > toCheck.statsLimit) this.stats.shift();

		// Give some properties of the ClientOpts
		FetchOpts.cache = MultiFetchOpts.cache = toCheck.cache;
		FetchOpts.version = MultiFetchOpts.version = PostOpts.version = toCheck.version;
		FetchOpts.botToken = MultiFetchOpts.botToken = PostOpts.botToken = toCheck.botToken;

		return this.options = toCheck;
	}

	/**
	 * Fetch botlist.space statistics.
	 * @param {FetchOptions} [options={}] Options to pass. (Ignores cache)
	 * @returns {Promise<Stats>} The statistics.
	 */
	async fetchStats(options = {}) {
		const { cache, raw, version } = check.fetch(Object.assign(FetchOpts, options));
		if (!isObject(options)) throw new TypeError('options must be an object.');
		const contents = await this.get('/statistics', version);

		if (cache) this.stats.push(new Stats(contents));
		while (this.stats.length > this.options.statsLimit) this.stats.shift();
		return raw ? contents : new Stats(contents);
	}

	/**
	 * Fetch all bots listed on botlist.space.
	 * @param {MultiFetchOptions} [options={}] Options to pass.
	 * @returns {Promise<Bot[] | Store<string, Bot>>}
	 */
	async fetchBots(options = {}) {
		const { cache, mapify, raw, version } = check.multi(Object.assign(MultiFetchOpts, options));
		if (!isObject(options)) throw new TypeError('options must be an object.');

		const contents = await this.get('/bots', version);
		if (cache) this.bots = this.bots.concat(new Store(contents.map(bot => [bot.id, new Bot(bot, this)])));
		if (mapify) return new Store(contents.map(bot => [bot.id, raw ? bot : new Bot(bot, this)]));
		else return raw ? contents : contents.map(bot => new Bot(bot, this));
	}

	/**
	 * Fetch a bot listed on botlist.space.
	 * @param {string | FetchOptions} [id=this.options.botID] The ID of the bot to fetch. Not required if this.options.botID is set.
	 * Can be {@link FetchOptions}, uses [options.botID]({@link ClientOpts#bot}) if so
	 * @param {FetchOptions} [options={}] Options to pass.
	 * @returns {Promise<Bot>} A bot object.
	 */
	async fetchBot(id = this.options.botID, options = {}) {
		if (isObject(id)) {
			options = id;
			id = this.options.botID;
		}
		const { cache, raw, version } = check.fetch(Object.assign(FetchOpts, options));

		if (typeof id === 'undefined' || id === null) throw new ReferenceError('id must be defined.');
		if (typeof id !== 'string' && !isObject(id)) throw new TypeError('id must be a string.');
		if (!isObject(options)) throw new TypeError('options must be an object.');

		const contents = await this.get(`/bots/${id}`, version);
		if (cache) this.bots.set(contents.id, new Bot(contents));
		return raw ? contents : new Bot(contents);
	}

	/**
	 * Fetch a bot's upvotes in the current month. Requires a bot token.
	 * @param {string | MultiFetchOptions} [id=this.options.botID] The bot ID to fetch upvotes from.
	 * Can be {@link FetchOptions}, uses [options.botID]({@link ClientOpts#bot}) if so
	 * @param {MultiFetchOptions} [options={}] Options to pass.
	 * @returns {Promise<Upvote[] | Store<string, Upvote>>} An array of upvotes.s
	 */
	async fetchUpvotes(id = this.options.botID, options = {}) {
		if (isObject(id)) {
			options = id;
			id = this.options.botID;
		}
		const { cache, raw, version, botToken, mapify, } = check.multi(Object.assign(MultiFetchOpts, options));
		if (!botToken) throw new ReferenceError('options.botToken must be defined.');

		if (typeof id === 'undefined' || id === null) throw new ReferenceError('id must be defined.');
		if (typeof id !== 'string' && !isObject(id)) throw new TypeError('id must be a string.');
		if (!isObject(options)) throw new TypeError('options must be an object.');

		const contents = await this.authGet(`/bots/${id}/upvotes`, version, botToken);
		if (cache) for (const c of contents) this.users.set(c.user.id, new User(c.user));
		if (mapify) return new Store(contents.map(c => [c.user.id, raw ? c : new Upvote(c, id)]));
		else return raw ? contents : contents.map(c => new Upvote(c, id));
	}

	/**
	 * Fetch a user logged onto botlist.space.
	 * @param {string} id The user ID to fetch from the API.
	 * @param {FetchOptions} [options={}] Options to pass.
	 * @returns {Promise<User>} A user object.
	 */
	async fetchUser(id, options = {}) {
		const { cache, raw, version } = check.fetch(Object.assign(FetchOpts, options));
		if (typeof id === 'undefined' || id === null) throw new ReferenceError('id must be defined.');
		if (typeof id !== 'string') throw new TypeError('id must be a string.');
		if (!isObject(options)) throw new TypeError('options must be an object.');

		const contents = await this.get(`/users/${id}`, version);
		if (cache) this.users.set(contents.id, new User(contents));
		return raw ? contents : new User(contents);
	}

	/**
	 * Fetches all bots that a user owns.
	 * @param {string} id A user ID to fetch bots from.
	 * @param {MultiFetchOptions} [options={}] Options to pass.
	 * @returns {Promise<Bot[]>}
	 */
	async fetchBotsOfUser(id, options = {}) {
		const { cache, raw, version, mapify } = check.multi(Object.assign(MultiFetchOpts, options));
		if (typeof id === 'undefined' || id === null) throw new ReferenceError('id must be defined.');
		if (typeof id !== 'string') throw new TypeError('id must be a string.');
		if (!isObject(options)) throw new TypeError('options must be an object.');

		const contents = await this.get(`/users/${id}/bots`, version);
		if (cache) for (const bot of contents) this.bots.set(bot.id, new Bot(bot));
		if (mapify) return new Store(contents.map(b => [b.id, raw ? b : new Bot(b)]));
		else return raw ? contents : contents.map(b => new Bot(b));
	}

	/**
	 * Post your server count to botlist.space.
	 * @param {string | PostOptions | number | number[]} [id=this.options.botID]
	 * The bot ID to post server count for.
	 * Not required if a bot ID was supplied.
	 * Can be PostOpts if using the bot ID supplied from ClientOpts.
	 * Can also be {@link PostOpts#countOrShards} if a number/array of numbers/null.
	 * @param {PostOptions} [options={}]
	 * Options to pass.
	 * Overriden by the `id` parameter if `id` is PostOpts/number/array of numbers/null.
	 * @returns {object} An object that satisfies your low self-esteem reminding you it was successive on post.
	 */
	async postCount(id = this.options.botID, options = {}) {
		if (isObject(id)) {
			options = id;
			id = this.options.botID;
		} else if (typeof id === 'number' || Array.isArray(id) || id === null) {
			options.countOrShards = id;
			id = this.options.botID;
		}
		if (typeof id === 'undefined') throw new ReferenceError('id must be defined.');
		if (typeof id !== 'string' && !isObject(id)) throw new TypeError('id must be a string.');
		if (!isObject(options)) throw new TypeError('options must be an object.');
		const { version, botToken, countOrShards } = check.post(Object.assign(PostOpts, options));

		if (typeof botToken === 'undefined') throw new ReferenceError('options.botToken must be defined, or in ClientOpts.');
		if (typeof botToken !== 'string') throw new TypeError('options.botToken must be a string.');
		if (typeof countOrShards === 'undefined') throw new ReferenceError('options.countOrShards must be defined.');
		if (typeof countOrShards !== 'number' && !Array.isArray(countOrShards) && countOrShards !== null) throw new TypeError('options.countOrShards must be a number, array of numbers, or null.'); // eslint-disable-line max-len

		const body = Array.isArray(options.countOrShards) ? { shards: options.countOrShards } : { server_count: options.countOrShards };
		const contents = await this.post(`/bots/${id}`, version, botToken, body);
		return contents;
	}
}

module.exports = Client;