dbots
Version:
Discord bot list poster and stats retriever
368 lines (315 loc) • 11.6 kB
text/typescript
import {
CustomEvent,
CustomService,
eventHandler,
PosterOptions,
ServiceKey,
SupportedEvents
} from '../Utils/Constants'
import { ensurePromise } from '../Utils/EnsurePromise'
import { errors } from '../Utils/DBotsError'
import { ClientFiller, getClientFiller } from './ClientFiller'
import { Service } from './Service'
import allSettled, { PromiseRejection } from 'promise.allsettled'
const { Error: DBotsError, TypeError } = errors
export interface manualPostOptions {
/** The server count to post to the service */
serverCount: number
/** The user count to post to the service */
userCount?: number
/** The voice connection count to post to the service */
voiceConnections?: number
}
/** A class that posts server count to listing site(s). */
export class Poster {
// #region Properties
/** The client that will be used to fetch the stats */
client: PosterOptions['client']
/** An array of custom services that the poster uses */
customServices: CustomService[]
/** The API keys that the poster is using */
apiKeys: Record<ServiceKey, string>
/** The options the poster was built with */
readonly options: PosterOptions
/** The list of event handlers for every custom event */
handlers: Record<CustomEvent, eventHandler[]>
/** The client filler used in the poster */
private _clientFiller: ClientFiller | null
/** Interval that posts to all services */
private _interval?: number // eslint-disable-line no-undef
// #endregion
/**
* @constructor
* @param options The options needed to construct the poster
*/
constructor(options: PosterOptions) {
if (!options || typeof options !== 'object')
throw new DBotsError('INVALID_POSTER_OPTIONS')
this.client = options.client
this._clientFiller = null
this.customServices = options.customServices || []
this.apiKeys = options.apiKeys || {}
this.options = options
if (typeof options.useSharding !== 'boolean') options.useSharding = true
if (!this.client && !options.clientID)
throw new DBotsError('NO_CLIENT_OR_ID')
if (this.client && !options.clientID)
Object.assign(options, {
clientID: this.clientFiller?.clientID,
shard: this.clientFiller?.shard
})
if (!options.useSharding) options.shard = undefined
this.handlers = {} as Record<CustomEvent, eventHandler[]>
for (const event of SupportedEvents) this.handlers[event] = []
}
/** The client filler used in the poster */
private get clientFiller(): ClientFiller | undefined {
return (
this._clientFiller ||
(this.options.clientLibrary && this.client
? (this._clientFiller = getClientFiller(
this.options.clientLibrary,
this.client
))
: undefined)
)
}
/**
* Retrieves the current server count of the client/shard.
* @returns Amount of servers the client/shard is in
*/
getServerCount(): Promise<number> {
if (this.options.serverCount)
// @ts-expect-error
return ensurePromise(this.options.serverCount)
if (!this.client) throw new DBotsError('NO_CLIENT', 'server')
if (!this.options.clientLibrary)
throw new DBotsError('UNKNOWN_CLIENT', 'server')
return Promise.resolve(this.clientFiller?.serverCount || 0)
}
/**
* Retrieves the current user count of the client/shard.
* @returns Amount of users the client/shard is connected with
*/
getUserCount(): Promise<number> {
if (this.options.userCount)
// @ts-expect-error
return ensurePromise(this.options.userCount) as Promise<number>
if (!this.client) throw new DBotsError('NO_CLIENT', 'user')
if (!this.options.clientLibrary)
throw new DBotsError('UNKNOWN_CLIENT', 'user')
return Promise.resolve(this.clientFiller?.userCount || 0)
}
/**
* Retrieves the current voice connection count of the client/shard.
* @returns Number of active voice connections
*/
getVoiceConnections(): Promise<number> {
if (this.options.voiceConnections)
// @ts-expect-error
return ensurePromise(this.options.voiceConnections) as Promise<number>
if (!this.client) throw new DBotsError('NO_CLIENT', 'voice connection')
if (!this.options.clientLibrary)
throw new DBotsError('UNKNOWN_CLIENT', 'voice connection')
return Promise.resolve(this.clientFiller?.voiceConnections || 0)
}
/**
* Creates an interval that posts to all services.
* @param interval The time (in ms) to reach to post to all {@link Service}s again
* @returns The interval that is responsible for posting
* @emits Poster#autopostSuccess
* @emits Poster#autopostFail
*/
startInterval(interval = 1800000) {
this._interval && clearTimeout(this._interval)
this._interval = setInterval(
() =>
this.post()
.then((result) => {
this.runHandlers('autopostSuccess', result)
return result
})
.catch((error) => this.runHandlers('autopostFail', error)),
interval
) as unknown as number
return this._interval
}
/** Destroys the current interval. */
stopInterval() {
if (this._interval) clearTimeout(this._interval)
}
/**
* Gets a service, autofilling its API key if the poster has it.
* @param service The service to get
*/
getService(
service: ServiceKey
): (Service | CustomService) | typeof Service | undefined {
const serviceClass = Service.get(service, this.customServices)
if (!serviceClass) return undefined
const keyName = serviceClass.aliases.find((key: string) =>
Object.keys(this.apiKeys).includes(key)
)
if (keyName) return new serviceClass(this.apiKeys[keyName])
else return serviceClass
}
/**
* Posts the current clients server count to a service.
* @param service The service to post to
* @see Poster#postManual
* @returns The result(s) of the post
* @emits Poster#postSuccess
* @emits Poster#postFail
*/
post(service: ServiceKey | 'all' = 'all'): Promise<object | object[]> {
const _this = this
return new Promise((resolve, reject) => {
return Promise.all([
_this.getServerCount(),
_this.getUserCount(),
_this.getVoiceConnections()
])
.then(([serverCount, userCount, voiceConnections]) => {
_this
.postManual(service, { serverCount, userCount, voiceConnections })
.then(resolve)
.catch(reject)
})
.catch(reject)
})
}
/**
* Manually posts a server count to a service.
* @param service The service to post to
* @param counts An object containing the tallies of servers, users and voice connections
* @returns The result(s) of the post
*/
postManual(
service: ServiceKey | 'all',
counts: manualPostOptions
): Promise<object | object[]> {
const { serverCount, userCount, voiceConnections } = counts
if (!service) service = 'all'
if (!this.apiKeys && !this.options.post)
return Promise.reject(new DBotsError('NO_API_KEYS'))
if (service === 'custom')
// @ts-expect-error
return ensurePromise(
// @ts-expect-error
this.options.post,
this.options.clientID,
serverCount,
this.options.shard
)
if (service === 'all') {
const services = Object.keys(this.apiKeys)
if (this.options.post) services.push('custom')
return allSettled
.call(
Promise,
services.map((k) =>
this.postManual(k, { serverCount, userCount, voiceConnections })
)
)
.then((requests) => {
const rejected: PromiseRejection<any>[] = [],
hostnames: string[] = []
for (const r of requests) {
if (r.status == 'rejected') {
rejected.push(r)
// @ts-expect-error
if (r.reason?.config?.url) {
// @ts-expect-error
const hostname = new URL(r.reason.config.url).hostname
if (hostname && !hostnames.includes(hostname))
hostnames.push(hostname)
} else hostnames.push('???')
}
}
if (rejected.length > 0) {
let msg = `${rejected.length} request${
rejected.length == 1 ? '' : 's'
} have been rejected.\n`
if (hostnames.length > 0)
msg += `Failing hostnames: ${hostnames.join(', ')}\n`
msg += 'Please check the error from the following responses.\n'
msg += rejected
.map((rej) => {
const reason = rej.reason || rej
return reason &&
typeof reason == 'object' &&
!(reason instanceof Error)
? JSON.stringify(reason, null, 2)
: reason.toString()
})
.join('\n')
throw new DBotsError('GENERIC', msg)
} else {
// @ts-expect-error
return requests.map((r) => r.value)
}
})
}
if (!Object.keys(this.apiKeys).includes(service))
return Promise.reject(new DBotsError('SERVICE_NO_KEY', service))
const serviceClass = Service.get(service, this.customServices)
if (!serviceClass)
return Promise.reject(new TypeError('INVALID_SERVICE', service))
return new Promise((resolve, reject) => {
serviceClass
.post({
token: this.apiKeys[service],
clientID: this.options.clientID || '',
shard: this.options.shard,
serverCount,
userCount,
voiceConnections
})
.then((result) => {
this.runHandlers('postSuccess', result)
resolve(result)
})
.catch((error) => {
this.runHandlers('postFail', error)
reject(error)
})
})
}
/**
* Adds an handler for an event.
* @param event The name of the event to add the handler to
* @param handler The function that is run with the event
* @returns The array of handlers currently set for that event
*/
addHandler(event: CustomEvent, handler: eventHandler): eventHandler[] {
if (!SupportedEvents.includes(event))
throw new TypeError('UNSUPPORTED_EVENT', 'add')
if (typeof handler != 'function') throw new DBotsError('HANDLER_INVALID')
this.handlers[event].push(handler)
return this.handlers[event]
}
/**
* Removes an handler for an event.
* @param event The name of the event to remove the handler from
* @param handler The function that is run with the event
* @returns The array of handlers currently set for that event
*/
removeHandler(event: CustomEvent, handler: eventHandler): eventHandler[] {
if (!SupportedEvents.includes(event))
throw new TypeError('UNSUPPORTED_EVENT', 'remove')
if (typeof handler != 'function') throw new DBotsError('HANDLER_INVALID')
const index = this.handlers[event].indexOf(handler)
if (index >= 0) this.handlers[event].splice(index, 1)
return this.handlers[event]
}
/**
* Manually triggers an event with custom arguments.
* @param {CustomEvent} event The name of the event to run the handlers for
* @param {...any} args The arguments to pass to the handlers
*/
runHandlers(event: CustomEvent, ...args: any[]) {
if (!SupportedEvents.includes(event))
throw new TypeError('UNSUPPORTED_EVENT', 'run')
for (const handler of this.handlers[event]) ensurePromise(handler, ...args)
}
}