const Axios = require('axios').default
const Express = require('express')
const { urlencoded } = require('body-parser')
const EventEmitter = require('events')
const RawBody = require('raw-body')
const botlistCache = require('../resources/botlists.json')
const Utils = require('../Utilities/ClassUtils.js')
// Cached Object keys for Comparing with User DisBotlist Data
const CacheObjectkeys = Object.keys(botlistCache.Botlists)
// Default PostApiBodyParams Structure for typings
const postApiBody = {
bot_id: '',
server_count: 0,
shards: [],
shard_id: '',
shard_count: 0,
}
/**
* @class BotLists -> Botlists Class
* @extends EventEmitter -> Extended for event handling of vote and posted and error event listeners
*/
class BotLists extends EventEmitter {
#postRetryAfterTimer = undefined
#autoPostedTimerId = undefined
#autoPostCaches = undefined
/**
* @constructor
* @param {string | void} webhookEndpoint -> Webhook Endpoint for "https://ipaddress:port/webhookEndpoint" to make selective path for Webhook's HTTP Post Request
* @param {Object} botlistData -> Botlists Data as ' { "topgg" : { authorizationToken: "xxx-secrettokenhere-xxx",authorizationValue: "xxx-selfmade-AuthorizationValue-xxx", } } ' for comparing fetching token and Json data from resourcess
* @param {string | number | void | "8080"} listenerPortNumber -> Port Number for Express's App to listen , By Default it listens in 8080 as default HTTP port
* @param {string | number | void | "localhost"} ipAddress -> Ip Adddress as "127.0.0.1" or "www.google.com" | No need to add http or https Protocol , just the domain or IP value for it
* @param {string | void | "https://github.com/SidisLiveYT/discord-botlists"} redirectUrl -> Redirect Url for get Request on Webhook Post Url for making it more cooler , By Default -> Github Repo
*/
constructor(
webhookEndpoint = undefined,
botlistData = undefined,
listenerPortNumber = 8080,
ipAddress = 'localhost',
redirectUrl = 'https://github.com/SidisLiveYT/discord-botlists',
) {
super()
this.webhookEndpoint = webhookEndpoint
this.redirectUrl = redirectUrl
this.ipAddress = ipAddress
this.botlistData = botlistData ?? botlistCache.Botlists
this.listenerPortNumber =
Number.isNaN(listenerPortNumber) &&
Number(listenerPortNumber) > 0 &&
Number(listenerPortNumber) < 65535
? 8080
: Number(listenerPortNumber)
this.expressApp = Express()
this.expressApp.use(
urlencoded({
extended: true,
}),
)
}
/**
* start() -> Starting Webhook for Vote Event Trigger
* @param {string | void} webhookEndpoint Webhook Endpoint for "https://ipaddress:port/webhookEndpoint" to make selective path for Webhook's HTTP Post Request
* @param {string | void | 'https://github.com/SidisLiveYT/discord-botlists'} redirectUrl -> Redirect Url for get Request on Webhook Post Url for making it more cooler , By Default -> Github Repo
* @param {boolean | void | true} eventTrigger -> Event Trigger for console.log() function | By Default = true
* @returns {any} Depends on Incomplete Body or Request , it can return false or complete request.body in Json Format
*/
async start(
webhookEndpoint = undefined,
redirectUrl = 'https://github.com/SidisLiveYT/discord-botlists',
eventTrigger = true,
) {
/**
* Express App listening on a Particular Port for Ignoring other Invalid Requests | For example containers of Pterodactyl Server
*/
this.expressApp.listen(Number(this.listenerPortNumber), () => (eventTrigger
? console.log(
`🎬 "discord-botlists" expressApp is Listening at Port: ${this.listenerPortNumber}`,
)
: undefined))
/**
* @var {apiUrl} -> Apiurl for WebhookEndPoint as for this.expressApp.post() request
*/
const apiUrl = `/${
webhookEndpoint ?? this.webhookEndpoint ?? 'discord-botlists'
}`
eventTrigger
? console.log(
`🎬 Webhook-Server is accepting votes Webhooks at - http://${this.ipAddress}:${this.listenerPortNumber}${apiUrl}`,
)
: undefined
/**
* Redirect Any Request to redirectUrl for skipping Error Message
*/
this.expressApp.get(apiUrl, (request, response) => {
this.emit('request', 'Get-Redirect', request, response, new Date())
response.redirect(redirectUrl ?? this.redirectUrl)
return undefined
})
/**
* Post Request for any Upcoming Webhook Request from /webhook-endpoint
*/
this.expressApp.post(
apiUrl,
(request, response) => new Promise((resolve) => {
try {
this.emit('request', 'Post-Votes', request, response, new Date())
let bodyJson
/**
* parsing Auth Secret Token present in Orignal Webhook page for comparing actual request if its okay
*/
const AuthParsingResults = this.#parseAuthorization(request)
// Invalid or Un-Authorized UnHandShake Request from Server to Client Side
if (!AuthParsingResults && AuthParsingResults === undefined) {
this.emit(
'error',
'UnAuthoization/Invalid-AuOth Code is Detected',
AuthParsingResults,
new Date(),
)
return response.status(400).send({
ok: false,
status: 400,
message: 'Invalid/Un-Authorized Authorization token',
})
}
/**
* if Body went brr.. then Body should be fetched from Stream.<Readable> from request
* Check if any error and send 422 for Incomplete Body
*/
if (!(request.body && Object.entries(request?.body)?.length > 0)) {
return RawBody(request, {}, (error, actualBody) => {
if (error)
return response.status(422).send({
ok: false,
status: 422,
message: 'Malformed Request Received',
})
try {
// Body Json Parsing from Actual Body as in String
bodyJson = JSON.parse(actualBody?.toString('utf8'))
this.emit(
'vote',
AuthParsingResults.name,
{ ...bodyJson },
new Date(),
)
response.status(200).send({
ok: true,
status: 200,
message: 'Webhook has been Received',
})
return resolve({ ...bodyJson })
} catch (err) {
response.status(400).send({
ok: false,
status: 400,
message: 'Invalid Body Received',
})
resolve(false)
}
return false
})
} else bodyJson = JSON.parse(request?.body?.toString('utf8'))
/**
* "vote" event trigger for Event Emitter Category
*/
this.emit(
'vote',
AuthParsingResults?.name,
{ ...bodyJson },
new Date(),
)
response.status(200).send({
ok: true,
status: 200,
message: 'Webhook has been Received',
})
return resolve({ ...bodyJson })
} catch (error) {
response.status(500).send({
ok: false,
status: 500,
message: 'Internal error',
})
resolve(false)
}
return false
}),
)
return this.expressApp
}
/**
* post() -> Posting Stats of the Current Bot to Multiple Botlists mentioned by
* @param {postApiBody} apiBody Api-Body for Posting Data as per params for API requirements and strictly for if required
* @param {boolean | void | true} eventOnPost What if event to be triggered on Post or should be closed
* @param {Object | void } AxioshttpConfigs To Add Proxies to avoid Ratelimit
* @param {boolean | void | false} forcePosting Force Posting and ignoring in-built Ratelimit function | Users can face un-expected ratelimit from API
* @param {Boolean | void | true} IgnoreErrors Boolean Value for Ignoring Request Handling Errors
* @returns {Promise<Boolean>} Booelan Response on success or failure
*/
async poststats(
apiBody = postApiBody,
eventOnPost = true,
AxioshttpConfigs = undefined,
forcePosting = false,
IgnoreErrors = true,
) {
if (
!(
apiBody?.bot_id &&
typeof apiBody?.bot_id === 'string' &&
apiBody?.bot_id?.length > 0
) ||
!(apiBody?.server_count && parseInt(apiBody?.server_count ?? 0) > 0) ||
Object.entries(apiBody)?.length <= 1
)
return void !IgnoreErrors
? this.emit(
'error',
'Failure: Invalid bot_id or server_count is Detected',
apiBody,
new Date(),
)
: undefined
else if (this.#autoPostedTimerId) {
return void !IgnoreErrors
? this.emit(
'error',
'Failure: Auto-Poster is Already in Progress',
this.#autoPostedTimerId,
new Date(),
)
: undefined
} else if (
!forcePosting &&
this.#postRetryAfterTimer &&
(this.#postRetryAfterTimer - new Date().getTime()) / 1000 > 0
) {
return void !IgnoreErrors
? this.emit(
'error',
`Failure: There is a Cooldown on Bot Stats Post Method | Retry After -> "${
(this.#postRetryAfterTimer - new Date().getTime()) / 1000
} Seconds"`,
apiBody,
new Date(),
)
: undefined
}
apiBody = { ...postApiBody, ...apiBody }
apiBody.server_count = parseInt(apiBody?.server_count ?? 0)
apiBody.shard_count =
apiBody?.shard_count &&
typeof apiBody?.shard_count === 'number' &&
parseInt(apiBody?.shard_count) > 0
? parseInt(apiBody?.shard_count)
: undefined
apiBody.shard_id =
apiBody?.shard_id &&
typeof apiBody?.shard_id === 'string' &&
apiBody?.shard_id?.length > 0
? apiBody?.shard_id
: undefined
apiBody.shards =
apiBody?.shards &&
Array.isArray(apiBody?.shards) &&
apiBody?.shards?.length > 0
? apiBody?.shards
: undefined
const Cached = await this.#poststats(
Utils.PostBodyParse(apiBody, this.botlistData),
AxioshttpConfigs ?? undefined,
IgnoreErrors,
)
if (!Cached) return false
else if (eventOnPost) this.emit('posted', Cached, new Date())
return true
}
/**
* autoPoster() -> Auto-osting Stats of the Current Bot to Multiple Botlists mentioned
* @param {postApiBody} apiBody Api-Body for Posting Data as per params for API requirements and strictly for if required
* @param {Object | void} AxioshttpConfigs To Add Proxies to avoid Ratelimit
* @param {number | void} Timer Time in Milli-Seconds to Post at every Interval Gap
* @param {boolean | void} eventOnPost What if event to be triggered on Post or should be closed
* @param {Boolean | void} IgnoreErrors Boolean Value for Ignoring Request Handling Errors
* @returns {NodeJS.Timer} Node Timer Id to Cancel for further purposes
*/
autoPoster(
apiBody = postApiBody,
AxioshttpConfigs = undefined,
Timer = 2 * 60 * 1000,
eventOnPost = true,
IgnoreErrors = true,
) {
if (
!(
apiBody?.bot_id &&
typeof apiBody?.bot_id === 'string' &&
apiBody?.bot_id?.length > 0
) ||
!(apiBody?.server_count && parseInt(apiBody?.server_count ?? 0) > 0) ||
Object.entries(apiBody)?.length <= 1
)
return void this.emit(
'error',
'Failure: Invalid bot_id or server_count is Detected',
apiBody,
new Date(),
)
else if (
!(
Timer &&
!Number.isNaN(Timer) &&
parseInt(Timer) >= 2 * 60 * 1000 &&
parseInt(Timer) <= 24 * 60 * 60 * 1000
)
) {
return void this.emit(
'error',
'Failure: Invalid Timer Value is Detected | Timer should be less than a Day and greater than 2 * 60 * 1000 milliseconds and Value should be in Milli-Seconds or leave undefined for defualt Timer Value',
apiBody,
new Date(),
)
}
if (this.#autoPostedTimerId) {
return void !IgnoreErrors
? this.emit(
'error',
'Failure: Auto-Poster is Already in Progress',
this.#autoPostedTimerId,
new Date(),
)
: undefined
} else if (
this.#postRetryAfterTimer &&
(this.#postRetryAfterTimer - new Date().getTime()) / 1000 > 0
) {
return void !IgnoreErrors
? this.emit(
'error',
`Failure: There is a Cooldown on Bot Stats Post Method | Retry After -> "${
(this.#postRetryAfterTimer - new Date().getTime()) / 1000
} Seconds"`,
apiBody,
new Date(),
)
: undefined
}
apiBody = { ...postApiBody, ...apiBody }
apiBody.server_count = parseInt(apiBody?.server_count ?? 0)
apiBody.shard_count =
apiBody?.shard_count &&
typeof apiBody?.shard_count === 'number' &&
parseInt(apiBody?.shard_count) > 0
? parseInt(apiBody?.shard_count)
: undefined
apiBody.shard_id =
apiBody?.shard_id &&
typeof apiBody?.shard_id === 'string' &&
apiBody?.shard_id?.length > 0
? apiBody?.shard_id
: undefined
apiBody.shards =
apiBody?.shards &&
Array.isArray(apiBody?.shards) &&
apiBody?.shards?.length > 0
? apiBody?.shards
: undefined
return this.#customTimeout(
apiBody,
eventOnPost ?? false,
AxioshttpConfigs,
Timer,
IgnoreErrors,
)
}
/**
* @private
* @param {postApiBody} postData Api-Body for Posting Data as per params for API requirements and strictly for if required
* @param {Object} AxioshttpConfigs Axios HTTP Post Request Config
* @param {Boolean | void | false} IgnoreErrors Boolean Value for Ignoring Request Handling Errors
* @returns {Object | void} Returns success and failure data formated or undefined on failure
*/
async #poststats(
postData,
AxioshttpConfigs = undefined,
IgnoreErrors = false,
) {
if (!postData) return undefined
const AxiosResponse = await Axios.post(
botlistCache?.apiPostUrl,
postData,
AxioshttpConfigs
? Utils.parseHTTPRequestOption(AxioshttpConfigs)
: undefined,
).catch((error) => {
this.emit(
'request',
'Post-Bot-Stats',
undefined,
error?.message,
new Date(),
)
if (error.response?.status > 200 && error.response?.status < 429) {
!IgnoreErrors
? this.emit(
'error',
`Received Response Status Error | Status Code -> ${
error?.response?.status
} Message from API Server : ${
error?.message ?? '<Hidden Message>'
}`,
error?.response?.data ?? error?.message,
new Date(),
)
: undefined
this.#autoPostedTimerId
? this.#customTimeout(
undefined,
undefined,
undefined,
undefined,
IgnoreErrors,
true,
)
: undefined
} else if (error?.response?.status === 429) {
!IgnoreErrors
? this.emit(
'error',
`Received Response Status Error | Status Code -> 429 | Message from API Server : "Ratelimited IP [${
error?.response?.data?.ratelimit_ip ?? '127.0.0.1'
}] and Retry Post Stats After -> ${
parseInt(error?.response?.data?.retry_after) ?? 80
} Seconds"`,
error?.response?.data ?? error?.message,
new Date(),
)
: undefined
this.#postRetryAfterTimer = error?.response?.data?.retry_after
? parseInt(error?.response?.data?.retry_after) * 1000 +
new Date().getTime()
: undefined
this.#autoPostedTimerId
? this.#customTimeout(
undefined,
undefined,
undefined,
undefined,
IgnoreErrors,
true,
parseInt(error?.response?.data?.retry_after ?? 0) * 1000,
)
: undefined
}
return undefined
})
this.emit('request', 'Post-Bot-Stats', undefined, AxiosResponse, new Date())
if (!AxiosResponse) return undefined
else if (AxiosResponse?.status > 200 && AxiosResponse?.status < 429) {
!IgnoreErrors
? this.emit(
'error',
`Received Response Status Error | Status Code -> ${
AxiosResponse?.status
} Message from API Server : ${
AxiosResponse?.data?.message ?? '<Hidden Message>'
}`,
AxiosResponse?.data,
new Date(),
)
: undefined
this.#autoPostedTimerId
? this.#customTimeout(
undefined,
undefined,
undefined,
undefined,
IgnoreErrors,
true,
)
: undefined
} else if (AxiosResponse?.status === 429) {
!IgnoreErrors
? this.emit(
'error',
`Received Response Status Error | Status Code -> 429 | Message from API Server : "Ratelimited IP [${
AxiosResponse?.data?.ratelimit_ip ?? '127.0.0.1'
}] and Retry Post Stats After -> ${
parseInt(AxiosResponse?.data?.retry_after) ?? 80
} Seconds"`,
AxiosResponse?.data,
new Date(),
)
: undefined
this.#postRetryAfterTimer = AxiosResponse?.data?.retry_after
? parseInt(AxiosResponse?.data?.retry_after) * 1000 +
new Date().getTime()
: undefined
this.#autoPostedTimerId
? this.#customTimeout(
undefined,
undefined,
undefined,
undefined,
IgnoreErrors,
true,
parseInt(AxiosResponse?.data?.retry_after) * 1000,
)
: undefined
} else if (
(AxiosResponse?.status === 200 && AxiosResponse?.data) ||
AxiosResponse?.data?.success ||
AxiosResponse?.data?.failure
) {
this.#postRetryAfterTimer = undefined
return Utils.PostResponseParse(
AxiosResponse?.data?.success ?? [],
AxiosResponse?.data?.failure ?? [],
)
}
return undefined
}
/**
*
* @param {postApiBody} apiBody Api-Body for Posting Data as per params for API requirements and strictly for if required
* @param {boolean | void} eventOnPost What if event to be triggered on Post or should be closed
* @param {Object | void} AxioshttpConfigs To Add Proxies to avoid Ratelimit
* @param {number | void} Timer Time in Milli-Seconds to Post at every Interval Gap
* @param {boolean} clearTimeoutBoolean Clear Timedout Boolean for renewing the Auto-Posting Interval
* @param {number | void} extraTime Extra Time in Milli-Seconds for 429 Error Data
* @returns {string | void}
*/
#customTimeout(
apiBody,
eventOnPost,
AxioshttpConfigs,
Timer = 2 * 60 * 1000,
IgnoreErrors = true,
clearTimeoutBoolean = false,
extraTime = 0,
) {
if (apiBody && Timer)
this.#autoPostCaches = {
apiBody,
eventOnPost,
AxioshttpConfigs,
Timer,
IgnoreErrors,
}
if (clearTimeoutBoolean && this.#autoPostedTimerId)
clearTimeout(this.#autoPostedTimerId)
this.#autoPostedTimerId = setInterval(async () => {
const Cached = await this.#poststats(
Utils.PostBodyParse(this.#autoPostCaches?.apiBody, this.botlistData),
this.#autoPostCaches?.AxioshttpConfigs,
this.#autoPostCaches?.IgnoreErrors,
)
if (!Cached) return false
else if (this.#autoPostCaches?.eventOnPost)
this.emit('posted', Cached, new Date())
return undefined
}, this.#autoPostCaches.Timer + extraTime)
return this.#autoPostedTimerId
}
/**
* @private
* @param {JSON} request Axios Request Data from GET-Votes location Function
* @returns {Object | boolean | void} Returns Auth Success Rate or false on failure
*/
#parseAuthorization(request) {
let count = 0
while (count < CacheObjectkeys.length) {
if (
this.botlistData[CacheObjectkeys[count]] &&
(this.botlistData[CacheObjectkeys[count]]?.authorizationValue ||
this.botlistData[CacheObjectkeys[count]]?.authorizationToken) &&
(((this.botlistData[CacheObjectkeys[count]]?.tokenHeadername &&
request.get(
`${this.botlistData[CacheObjectkeys[count]]?.tokenHeadername}`,
)) ??
(botlistCache.Botlists[CacheObjectkeys[count]]?.tokenHeadername &&
request.get(
`${
botlistCache.Botlists[CacheObjectkeys[count]]?.tokenHeadername
}`,
)) ??
request.get('Authorization')) ===
this.botlistData[CacheObjectkeys[count]]?.authorizationValue ||
((this.botlistData[CacheObjectkeys[count]]?.tokenHeadername &&
request.get(
`${this.botlistData[CacheObjectkeys[count]]?.tokenHeadername}`,
)) ??
(botlistCache.Botlists[CacheObjectkeys[count]]?.tokenHeadername &&
request.get(
`${
botlistCache.Botlists[CacheObjectkeys[count]]?.tokenHeadername
}`,
)) ??
request.get('Authorization')) ===
this.botlistData[CacheObjectkeys[count]]?.authorizationToken)
)
return botlistCache.Botlists[CacheObjectkeys[count]]
else ++count
}
return false
}
}
module.exports = BotLists