import type { APIRequest, InvalidRequestWarningData, RateLimitData, REST, ResponseLike, } from 'discord.js' import env from 'env-var' import { pino as createLogger, type Logger, type LoggerOptions } from 'pino' import { formatUser, type SleetClient, SleetModule } from 'sleetcord' import { interactionToString } from '../utils/stringify.ts' const NODE_ENV: string = env.get('NODE_ENV').required().asString() const USE_PINO_PRETTY: boolean = env.get('USE_PINO_PRETTY').required().asBool() const LOG_LEVEL_ENV: string | undefined = env.get('LOG_LEVEL').asString() export const LOG_LEVEL = LOG_LEVEL_ENV ?? (NODE_ENV === 'development' ? 'debug' : 'info') const loggerOptions: LoggerOptions = { level: LOG_LEVEL, } if (USE_PINO_PRETTY) { loggerOptions.transport = { target: 'pino-dev', } } export const baseLogger: Logger = createLogger(loggerOptions) export const eventLogger: Logger = baseLogger.child({ module: 'event' }) export const djsLogger: Logger = baseLogger.child({ module: 'discord.js' }) const djsName = { name: 'discord.js' } export const logging: SleetModule = new SleetModule( { name: 'logging', }, { clientReady(client) { const { application, shard, readyAt } = client eventLogger.info(`Ready at : ${readyAt.toISOString()}`) eventLogger.info(`Logged in : ${formatUser(client.user, { markdown: false })}`) eventLogger.info(`Guild Approx: ${application.approximateGuildCount}`) if (shard) { eventLogger.info(`Shard Count : ${shard.count}`) } djsLogger.info({ djsName, type: 'client-ready' }, 'Client is ready!') }, load() { this.client.rest.on('invalidRequestWarning', (...args) => onInvalidRequestWarning(this.sleet, ...args), ) this.client.rest.on('rateLimited', (...args) => onRateLimited(this.sleet, ...args)) this.client.rest.on('response', (...args) => onResponse(this.sleet, this.client.rest, ...args), ) }, unload() { this.client.rest.off('invalidRequestWarning', (...args) => onInvalidRequestWarning(this.sleet, ...args), ) this.client.rest.off('rateLimited', (...args) => onRateLimited(this.sleet, ...args)) this.client.rest.off('response', (...args) => onResponse(this.sleet, this.client.rest, ...args), ) }, error(error) { djsLogger.error({ ...moduleName(this.sleet), error, type: 'djs-error' }) }, warn(warning) { djsLogger.warn({ ...moduleName(this.sleet), type: 'djs-warn' }, warning) }, debug(debug) { djsLogger.trace({ ...moduleName(this.sleet), type: 'djs-debug' }, debug) }, shardReady(shardId, unavailableGuilds) { const unavailable = unavailableGuilds ? ` with ${unavailableGuilds.size} unavailable guilds` : '' djsLogger.info( { ...djsName, type: 'shard-ready', shardId, unavailableGuilds }, `Shard ${shardId} ready${unavailable}`, ) }, shardDisconnect(closeEvent, shardId) { djsLogger.warn( { ...djsName, type: 'shard-disconnect', shardId, code: closeEvent.code }, `Shard ${shardId} disconnected with code ${closeEvent.code}`, ) }, shardReconnecting(shardId) { djsLogger.info( { ...djsName, type: 'shard-reconnecting', shardId }, `Shard ${shardId} reconnecting`, ) }, shardResume(shardId, replayedEvents) { djsLogger.info( { ...djsName, type: 'shard-resume', shardId, replayedEvents }, `Shard ${shardId} resumed with ${replayedEvents} events`, ) }, shardError(error, shardId) { djsLogger.error( { ...djsName, error: { name: error.name, message: error.message, stack: error.stack, }, type: 'shard-error', }, `Shard ${shardId} errored: ${error.message}`, ) }, guildCreate(guild) { const info = [ `name: ${guild.name}`, `id: ${guild.id}`, `owner: ${guild.ownerId}`, `members: ${guild.memberCount}`, ].join(', ') djsLogger.info( { ...moduleName(this.sleet), type: 'guild-create', guildId: guild.id }, `Guild create (${info})`, ) }, guildDelete(guild) { const info = [ `name: ${guild.name}`, `id: ${guild.id}`, `owner: ${guild.ownerId}`, `members: ${guild.memberCount}`, ].join(', ') djsLogger.info( { ...moduleName(this.sleet), type: 'guild-delete', guildId: guild.id }, `Guild delete (${info})`, ) }, sleetError(message, error) { eventLogger.error( { ...moduleName(this.sleet), type: 'sleet-error', error, message }, error instanceof Error ? error.message : String(error), ) }, sleetWarn(warning, data) { eventLogger.warn({ ...moduleName(this.sleet), type: 'sleet-warn', data, warning }, warning) }, sleetDebug(debug, data) { eventLogger.debug({ ...moduleName(this.sleet), type: 'sleet-debug', data, debug }, debug) }, applicationInteractionError(module, interaction, error) { eventLogger.error( { name: module.name, commandName: interaction.commandName, type: 'interaction-error', error, }, error instanceof Error ? error.message : String(error), ) }, autocompleteInteractionError(module, interaction, error) { eventLogger.error( { name: module.name, commandName: interaction.commandName, type: 'autocomplete-error', error, }, error instanceof Error ? error.message : String(error), ) }, // interactionCreate(interaction) { // const str = interactionToString(interaction) // logger.debug( // { // userId: interaction.user.id, // interactionId: interaction.id, // }, // `[INTR] ${str}`, // ) // }, runModule(module, interaction) { eventLogger.debug( { name: module.name, type: 'run-module', userId: interaction.user.id, interactionId: interaction.id, }, interactionToString(interaction), ) }, loadModule(module, qualifiedName) { eventLogger.info( { name: module.name, type: 'load-module', qualifiedName: qualifiedName, }, 'Loaded module', ) }, unloadModule(module, qualifiedName) { eventLogger.info( { name: module.name, type: 'unload-module', qualifiedName: qualifiedName, }, 'Unloaded module', ) }, }, ) /** * Regexes to censor tokens from paths * * Regexes should have 3 capture groups: * 1. The path before the token * 2. The token itself * 3. The path after the token */ const CENSOR_REGEXES: RegExp[] = [ /^((?:https:\/\/discord(?:app)?\.com\/api\/v\d{2})?\/interactions\/\d{17,19}\/)(.*)(\/callback.*)/, /^((?:https:\/\/discord(?:app)?\.com\/api\/v\d{2})?\/webhooks\/\d{17,19}\/)(.*)(\/messages.*|$)/, ] /** * Attempts to "censor" a path by replacing tokens with :token * * Though technically not required (at least for interactions, since those tokens * expire after a couple seconds/15 mins), it makes logs a lot easier to read and parse * @param path The path to censor * @returns The censored path */ export function censorPath(path: string): string { let newPath = path for (const regex of CENSOR_REGEXES) { newPath = newPath.replace(regex, '$1:token$3') } return newPath } /** * Get the name of the currently-running module from the running module store, if available * * Returned as an object so it can be spread into context objects * @returns The name of the currently running module, or undefined if there is none */ function moduleName(sleet: SleetClient): { name: string } | undefined { const module = sleet.runningModuleStore.getStore() if (module) { return { name: module.name } } return undefined } function onInvalidRequestWarning( sleet: SleetClient, invalidRequestInfo: InvalidRequestWarningData, ) { djsLogger.warn( { ...moduleName(sleet), type: 'invalid-request', invalidRequest: invalidRequestInfo }, 'Invalid Request Warning: %o', invalidRequestInfo, ) } function onRateLimited(sleet: SleetClient, rateLimitInfo: RateLimitData) { djsLogger.warn( { ...moduleName(sleet), type: 'ratelimit', ratelimit: rateLimitInfo }, 'Ratelimited: %o', rateLimitInfo, ) } function onResponse(sleet: SleetClient, rest: REST, req: APIRequest, res: ResponseLike) { const path = `${req.method} ${censorPath(req.path)} ${res.status} ${res.statusText}` if (res.status === undefined) { djsLogger.debug( { ...moduleName(sleet), type: 'rest', path, globalRemaining: rest.globalRemaining, }, `${path} [🌎 ${rest.globalRemaining}]`, ) return } const ratelimit = { limit: res.headers.get('x-ratelimit-limit'), remaining: res.headers.get('x-ratelimit-remaining'), resetAfter: res.headers.get('x-ratelimit-reset-after'), retryAfter: res.headers.get('retry-after'), bucket: res.headers.get('x-ratelimit-bucket'), global: res.headers.get('x-ratelimit-global'), scope: res.headers.get('x-ratelimit-scope'), } const ratelimitLine = ratelimit.remaining ? `[${ratelimit.remaining}/${ratelimit.limit} (${ratelimit.resetAfter}s) ${ratelimit.bucket}${ ratelimit.scope ? ` ${ratelimit.scope}` : '' }${ratelimit.global ? '!' : ''}${ ratelimit.retryAfter ? ` retry in ${ratelimit.retryAfter}s` : '' }] ` : '' let body = '' if (res.status >= 400) { const bodyBuilder: string[] = [] if (req.method !== 'GET') { bodyBuilder.push('\nRequest:\n') bodyBuilder.push(JSON.stringify(req.data.body, null, 2)) if (!res.bodyUsed && res.body !== null) { bodyBuilder.push('\nResponse:\n') if (res instanceof Response && !res.body.locked) { bodyBuilder.push(JSON.stringify(res.clone().body, null, 2)) } else { bodyBuilder.push(JSON.stringify(res.body, null, 2)) } } } body = bodyBuilder.join('') } djsLogger.debug( { ...moduleName(sleet), type: 'rest', path, status: res.status, method: req.method, ratelimit, globalRemaining: rest.globalRemaining, }, `${path} ${ratelimitLine}[🌎 ${rest.globalRemaining}]${body}`, ) }