diff --git a/package.json b/package.json index b1afeee..b7fe14a 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,8 @@ "node-html-parser": "^3.2.0", "node-localstorage": "^3.0.5", "openai": "^4.73.0", + "pino": "^10.3.1", + "pino-pretty": "^13.1.3", "reconnecting-websocket": "^4.4.0", "sharp": "^0.32.6", "timeago.js": "^4.0.2", diff --git a/scripts/replay-mints.ts b/scripts/replay-mints.ts new file mode 100644 index 0000000..f9f7a70 --- /dev/null +++ b/scripts/replay-mints.ts @@ -0,0 +1,462 @@ +/** + * replay-mints.ts + * + * Replays missed mint posts for a specific project to a Discord channel. + * + * Usage: + * ts-node scripts/replay-mints.ts --project-id [options] + * + * Required: + * --project-id Hasura project ID (format: -) + * e.g. 0x13aae6f9599880edbb7d144bb13f1212cee99533-0 + * + * Options: + * --channel Discord channel name to replay into (default: studio-mints) + * --rate Max posts per minute (default: 200, ~100 req/10s limit) + * --dry-run Print what would be posted without actually posting + * --prod Use production channels.json (default: dev channels) + * --limit Only replay up to this many mints (default: unlimited) + */ + +import * as dotenv from 'dotenv' +import { + Client, + EmbedBuilder, + GatewayIntentBits, + TextChannel, +} from 'discord.js' +import fetch from 'node-fetch' +import * as path from 'path' +import * as fs from 'fs' + +dotenv.config() + +// ─── Config ───────────────────────────────────────────────────────────────── + +const HASURA_ENDPOINT = 'https://data.artblocks.io/v1/graphql' +const MINT_UTM = '?utm_source=discord&utm_medium=artbot&utm_campaign=mint' +const BASE_AB_PROFILE = 'https://www.artblocks.io/user/' + +// ─── Arg parsing ──────────────────────────────────────────────────────────── + +function parseArgs(): { + projectId: string + channelName: string + ratePerMinute: number + dryRun: boolean + prod: boolean + limit: number | null +} { + const args = process.argv.slice(2) + let projectId = '' + let channelName = 'studio-mints' + let ratePerMinute = 200 + let dryRun = false + let prod = process.env.ARTBOT_IS_PROD === 'true' + let limit: number | null = null + + for (let i = 0; i < args.length; i++) { + switch (args[i]) { + case '--project-id': + projectId = args[++i] + break + case '--channel': + channelName = args[++i] + break + case '--rate': + ratePerMinute = parseInt(args[++i], 10) + break + case '--dry-run': + dryRun = true + break + case '--prod': + prod = true + break + case '--limit': + limit = parseInt(args[++i], 10) + break + default: + console.warn(`Unknown argument: ${args[i]}`) + } + } + + if (!projectId) { + console.error('Error: --project-id is required') + console.error( + 'Example: ts-node scripts/replay-mints.ts --project-id 0x13aae6f9599880edbb7d144bb13f1212cee99533-0' + ) + process.exit(1) + } + + return { projectId, channelName, ratePerMinute, dryRun, prod, limit } +} + +// ─── Channel ID lookup ─────────────────────────────────────────────────────── + +function getChannelId(channelName: string, prod: boolean): string { + const channelsFile = prod + ? path.join(__dirname, '../src/ProjectConfig/channels.json') + : path.join(__dirname, '../src/ProjectConfig/channels_dev.json') + + let channels: Record + try { + channels = JSON.parse(fs.readFileSync(channelsFile, 'utf8')) + } catch { + // Fall back to production channels if dev file doesn't exist + channels = JSON.parse( + fs.readFileSync( + path.join(__dirname, '../src/ProjectConfig/channels.json'), + 'utf8' + ) + ) + } + + const entry = Object.entries(channels).find(([, v]) => v.name === channelName) + if (!entry) { + throw new Error(`Channel "${channelName}" not found in channels config`) + } + return entry[0] +} + +// ─── Hasura query ──────────────────────────────────────────────────────────── + +interface TokenRow { + token_id: string + invocation: number + owner_address: string + project: { + name: string + contract_address: string + contract: { + default_vertical?: { + vertical?: { + name?: string + } + } + } + } +} + +async function fetchProjectTokens(projectId: string): Promise { + const query = ` + query GetProjectTokensForReplay($project_id: String!) { + tokens_metadata( + where: { project_id: { _eq: $project_id } } + order_by: { invocation: asc } + ) { + token_id + invocation + owner_address + project { + name + contract_address + contract { + default_vertical { + vertical { + name + } + } + } + } + } + } + ` + + const headers: Record = { + 'Content-Type': 'application/json', + } + if (process.env.HASURA_GRAPHQL_ADMIN_SECRET) { + headers['x-hasura-admin-secret'] = process.env.HASURA_GRAPHQL_ADMIN_SECRET + } + + const response = await fetch(HASURA_ENDPOINT, { + method: 'POST', + headers, + body: JSON.stringify({ query, variables: { project_id: projectId } }), + }) + + const json = (await response.json()) as { + data?: { tokens_metadata: TokenRow[] } + errors?: { message: string }[] + } + + if (json.errors) { + throw new Error( + `Hasura error: ${json.errors.map((e) => e.message).join(', ')}` + ) + } + if (!json.data) { + throw new Error('No data returned from Hasura') + } + + return json.data.tokens_metadata +} + +// ─── URL builders ──────────────────────────────────────────────────────────── + +function buildMediaUrl( + chainId: number, + contractAddress: string, + tokenId: string +): string { + return `https://media-proxy.artblocks.io/${chainId}/${contractAddress.toLowerCase()}/${tokenId}.png` +} + +function buildGeneratorUrl( + chainId: number, + contractAddress: string, + tokenId: string +): string { + return `https://generator.artblocks.io/${chainId}/${contractAddress.toLowerCase()}/${tokenId}` +} + +function buildArtBlocksTokenUrl( + chainId: number, + contractAddress: string, + tokenId: string +): string { + return `https://www.artblocks.io/token/${chainId}/${contractAddress}/${tokenId}` +} + +// ─── Discord channel history reader ────────────────────────────────────────── + +/** + * Fetches all messages from a Discord channel and returns the set of + * invocation numbers that have already been posted for the given project name. + * + * The embed title format is: "Minted: {projectName} #{invocation}" + */ +async function fetchPostedInvocations( + channel: TextChannel, + projectName: string +): Promise> { + const posted = new Set() + const titlePrefix = `Minted: ${projectName} #` + + console.log( + `\nScanning last 100 messages in #${channel.name} for previously posted "${projectName}" mints...` + ) + + const messages = await channel.messages.fetch({ limit: 100 }) + for (const msg of messages.values()) { + for (const embed of msg.embeds) { + const title = embed.title ?? '' + if (title.startsWith(titlePrefix)) { + const invocationStr = title.slice(titlePrefix.length) + const invocation = parseInt(invocationStr, 10) + if (!isNaN(invocation)) { + posted.add(invocation) + } + } + } + } + + console.log(` Scanned ${messages.size} messages. Found ${posted.size} already-posted invocations.`) + return posted +} + +// ─── ENS / address display ─────────────────────────────────────────────────── + +function shortAddress(address: string): string { + return `${address.slice(0, 6)}...${address.slice(-4)}` +} + +// ─── Main ──────────────────────────────────────────────────────────────────── + +async function main() { + const { projectId, channelName, ratePerMinute, dryRun, prod, limit } = + parseArgs() + + const msPerPost = Math.ceil((60 / ratePerMinute) * 1000) + + console.log('─'.repeat(60)) + console.log(` Replay Mints Script`) + console.log('─'.repeat(60)) + console.log(` Project ID : ${projectId}`) + console.log(` Channel : ${channelName}`) + console.log( + ` Rate : ${ratePerMinute} posts/min (~${msPerPost}ms between posts)` + ) + console.log( + ` Mode : ${dryRun ? 'DRY RUN (no posts will be made)' : 'LIVE'}` + ) + console.log(` Channels : ${prod ? 'production' : 'dev'}`) + if (limit) console.log(` Limit : ${limit} mints`) + console.log('─'.repeat(60)) + + // Resolve channel ID + let channelId: string + try { + channelId = getChannelId(channelName, prod) + } catch (err) { + console.error(`\nError: ${(err as Error).message}`) + process.exit(1) + } + console.log(`\nTarget channel ID: ${channelId}`) + + // Fetch tokens from Hasura + console.log(`\nFetching tokens for project "${projectId}" from Hasura...`) + let tokens: TokenRow[] + try { + tokens = await fetchProjectTokens(projectId) + } catch (err) { + console.error(`\nFailed to fetch tokens: ${(err as Error).message}`) + process.exit(1) + } + + if (tokens.length === 0) { + console.log( + '\nNo tokens found for this project. Double-check the project ID.' + ) + process.exit(0) + } + + const firstToken = tokens[0] + const projectName = firstToken.project.name + const contractAddress = firstToken.project.contract_address + console.log(`\nProject: "${projectName}" — ${tokens.length} total tokens`) + console.log(`Contract: ${contractAddress}`) + + console.log(process.env.DISCORD_TOKEN) + // Connect to Discord + if (!process.env.DISCORD_TOKEN) { + console.error('\nError: DISCORD_TOKEN environment variable is not set.') + process.exit(1) + } + + const client = new Client({ + intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages], + }) + + await new Promise((resolve, reject) => { + client.once('ready', () => { + console.log(`\nDiscord bot connected as: ${client.user?.tag}`) + resolve() + }) + client.once('error', reject) + client.login(process.env.DISCORD_TOKEN) + }) + + const discordChannel = client.channels.cache.get(channelId) as + | TextChannel + | undefined + if (!discordChannel) { + console.error( + `\nError: Channel ${channelId} (${channelName}) not found or bot lacks access.` + ) + await client.destroy() + process.exit(1) + } + + // Read existing posts to determine which invocations are already covered + const alreadyPosted = await fetchPostedInvocations( + discordChannel, + projectName + ) + + // Determine which tokens need to be replayed + const toReplay = tokens.filter((t) => !alreadyPosted.has(t.invocation)) + + console.log( + `\n${toReplay.length} mints need to be replayed (out of ${tokens.length} total)` + ) + + if (toReplay.length === 0) { + console.log('Nothing to do — all mints already posted!') + await client.destroy() + process.exit(0) + } + + // Apply optional limit + const batch = limit != null ? toReplay.slice(0, limit) : toReplay + + if (limit != null && toReplay.length > limit) { + console.log( + `Limiting replay to first ${limit} mints (${ + toReplay.length - limit + } will be skipped)` + ) + } + + // Infer chain ID — mainnet (1) is the default; expand this if needed + // The Hasura token_id for mainnet looks like: "0xcontract-tokenId" + // For now we default to chain 1 (mainnet) since studio contracts are mainnet + const CHAIN_ID = 1 + + if (dryRun) { + console.log('\n[DRY RUN] Would replay the following invocations:') + batch.forEach((t) => + console.log( + ` • ${projectName} #${t.invocation} (token ${ + t.token_id + }, owner ${shortAddress(t.owner_address)})` + ) + ) + await client.destroy() + process.exit(0) + } + + // Post mints with rate limiting + console.log( + `\nStarting replay... (posting ${batch.length} mints at ${ratePerMinute}/min)\n` + ) + + for (let i = 0; i < batch.length; i++) { + const token = batch[i] + const tokenName = `${projectName} #${token.invocation}` + const imageUrl = buildMediaUrl(CHAIN_ID, contractAddress, token.token_id) + const generatorUrl = buildGeneratorUrl( + CHAIN_ID, + contractAddress, + token.token_id + ) + const artblocksUrl = buildArtBlocksTokenUrl( + CHAIN_ID, + contractAddress, + token.token_id + ) + const ownerProfile = `${BASE_AB_PROFILE}${token.owner_address}${MINT_UTM}` + + const embed = new EmbedBuilder() + .setTitle(`Minted: ${tokenName}`) + .setURL(artblocksUrl + MINT_UTM) + .setImage(imageUrl) + .setColor('#c9fdc9') + .addFields( + { + name: 'Minted by', + value: `[${shortAddress(token.owner_address)}](${ownerProfile})`, + inline: true, + }, + { + name: 'Live Script', + value: `[Generator](${generatorUrl + MINT_UTM})`, + inline: true, + } + ) + + try { + await discordChannel.send({ embeds: [embed] }) + console.log(` [${i + 1}/${batch.length}] Posted: ${tokenName}`) + } catch (err) { + console.error( + ` [${i + 1}/${batch.length}] Failed to post ${tokenName}: ${ + (err as Error).message + }` + ) + } + + // Rate limit: wait between posts (skip delay after the last one) + if (i < batch.length - 1) { + await new Promise((resolve) => setTimeout(resolve, msPerPost)) + } + } + + console.log(`\nDone! Replayed ${batch.length} mints to #${channelName}.`) + await client.destroy() +} + +main().catch((err) => { + console.error('Fatal error:', err) + process.exit(1) +}) diff --git a/src/Classes/APIBots/ApiPollBot.ts b/src/Classes/APIBots/ApiPollBot.ts index df73822..74b4721 100644 --- a/src/Classes/APIBots/ApiPollBot.ts +++ b/src/Classes/APIBots/ApiPollBot.ts @@ -7,6 +7,7 @@ import { } from './utils' import axios, { AxiosRequestConfig, AxiosResponse } from 'axios' +import { logger } from '../../logger' /** Abstract parent class for all API Poll Bots */ export class APIPollBot { apiEndpoint: string @@ -85,7 +86,7 @@ export class APIPollBot { */ async pollApi() { if (this.isPolling) { - console.log('Skipping poll — previous poll still in-flight') + logger.info('Skipping poll - previous poll still in-flight') return } this.isPolling = true @@ -104,8 +105,9 @@ export class APIPollBot { return } if (response.status >= 400) { - console.warn( - `API poll non-2xx response (${response.status}) for ${this.apiEndpoint}` + logger.warn( + { status: response.status, endpoint: this.apiEndpoint }, + 'API poll non-2xx response' ) return } @@ -124,8 +126,9 @@ export class APIPollBot { this.handleRateLimit() return } - console.warn( - `Error polling ${this.apiEndpoint} - status: ${status} ${statusText} message: ${message}` + logger.warn( + { endpoint: this.apiEndpoint, status, statusText, message }, + 'Error polling endpoint' ) } finally { this.isPolling = false @@ -144,8 +147,9 @@ export class APIPollBot { 5 * 60 * 1000 ) if (newRate !== this.refreshRateMs) { - console.warn( - `Rate limited (429) — slowing polling from ${this.refreshRateMs}ms to ${newRate}ms (${this.consecutiveRateLimits} consecutive 429s)` + logger.warn( + { fromMs: this.refreshRateMs, toMs: newRate, consecutiveRateLimits: this.consecutiveRateLimits }, + 'Rate limited (429) - slowing polling' ) this.refreshRateMs = newRate this.restartPolling() @@ -159,8 +163,9 @@ export class APIPollBot { if (this.consecutiveRateLimits > 0) { this.consecutiveRateLimits = 0 if (this.refreshRateMs !== this.baseRefreshRateMs) { - console.log( - `Rate limit cleared — restoring polling rate to ${this.baseRefreshRateMs}ms` + logger.info( + { baseRefreshRateMs: this.baseRefreshRateMs }, + 'Rate limit cleared - restoring polling rate' ) this.refreshRateMs = this.baseRefreshRateMs this.restartPolling() @@ -198,10 +203,9 @@ export class APIPollBot { const retryAfter = this.parseRetryAfter(response.headers) const delay = retryAfter ?? Math.min(initialDelayMs * Math.pow(2, attempt), 30000) - console.warn( - `GET retry ${ - attempt + 1 - }/${retries} for ${url} - rate limited (429), waiting ${delay}ms` + logger.warn( + { attempt: attempt + 1, retries, url, delayMs: delay }, + 'GET retry - rate limited (429)' ) await new Promise((res) => setTimeout(res, delay)) attempt++ @@ -245,10 +249,9 @@ export class APIPollBot { } const jitter = Math.floor(delay * 0.25 * (Math.random() * 2 - 1)) const sleepMs = Math.max(250, delay + jitter) - console.warn( - `GET retry ${attempt + 1}/${retries} for ${url} after error (${ - code || status - }): ${axiosErr?.message || 'unknown'} - waiting ${sleepMs}ms` + logger.warn( + { attempt: attempt + 1, retries, url, code: code || status, errMessage: axiosErr?.message || 'unknown', sleepMs }, + 'GET retry after error' ) await new Promise((res) => setTimeout(res, sleepMs)) attempt++ @@ -285,7 +288,7 @@ export class APIPollBot { * @param {*} responseData - Dict parsed from API request json */ async handleAPIResponse(responseData: unknown) { - console.warn('handleAPIResponse function not implemented!', responseData) + logger.warn({ responseData }, 'handleAPIResponse function not implemented') } /** @@ -294,7 +297,7 @@ export class APIPollBot { * @param msg - Event info dict */ async buildDiscordMessage(msg: unknown) { - console.warn('buildDiscordMessage function not implemented!', msg) + logger.warn({ msg }, 'buildDiscordMessage function not implemented') } async osName(address: string): Promise { diff --git a/src/Classes/APIBots/OpenSeaEventsPollBot.ts b/src/Classes/APIBots/OpenSeaEventsPollBot.ts index 4337ea5..f102995 100644 --- a/src/Classes/APIBots/OpenSeaEventsPollBot.ts +++ b/src/Classes/APIBots/OpenSeaEventsPollBot.ts @@ -7,6 +7,7 @@ import { NormalizedOpenSeaSale, } from './OpenSeaSaleBot' // No utils needed here; SaleBot handles embed/tweet logic +import { logger } from '../../logger' // OpenSea Events API Types based on the provided payload examples export interface OpenSeaEventResponse { @@ -148,7 +149,7 @@ export class OpenSeaEventsPollBot extends APIPollBot { let eventsNewerThanTimestamp = 0 if (!responseData.asset_events || responseData.asset_events.length === 0) { - console.log(`OpenSea API: No events in ${pageInfo} page`) + logger.info({ pageInfo }, 'OpenSea API: No events in page') return } @@ -184,7 +185,7 @@ export class OpenSeaEventsPollBot extends APIPollBot { // Filter by tracked contracts first const asset = event.event_type === 'sale' ? event.nft : event.asset if (!asset) { - console.warn(`${event.event_type} event missing asset data:`, event) + logger.warn({ eventType: event.event_type }, 'Event missing asset data') continue } @@ -203,34 +204,36 @@ export class OpenSeaEventsPollBot extends APIPollBot { const saleId = event.transaction || compositeId if (this.streamSaleIds.has(saleId)) { // Sale already processed by stream - skip - console.log(`Skipping ${saleId} event`) + logger.info({ saleId }, 'Skipping event already processed by stream') continue } // This is a missed sale! Log it and process - console.log( - `🚨 OpenSea API: Found MISSED SALE - ${asset.name} (#${asset.identifier}) from contract ${contractAddress}` + logger.info( + { name: asset.name, identifier: asset.identifier, contractAddress }, + 'OpenSea API: Found MISSED SALE' ) try { await this.handleSaleEvent(event) processedEventsCount++ } catch (err) { - console.error(`Error sending OpenSea API sale message`, err) + logger.error({ err }, 'Error sending OpenSea API sale message') } } // Log summary for important events or debugging - - console.log( - `OpenSea API: Page ${pageNumber} - Processed: ${processedEventsCount} events, Total: ${totalEventsInPage}` + logger.info( + { pageNumber, processedEventsCount, totalEventsInPage }, + 'OpenSea API page summary' ) // Handle pagination - continue until we've processed all available sales if (responseData.next) { if (pageNumber >= MAX_PAGINATION_PAGES) { - console.warn( - `OpenSea API: Reached maximum pagination limit (${MAX_PAGINATION_PAGES} pages), stopping pagination` + logger.warn( + { maxPages: MAX_PAGINATION_PAGES }, + 'OpenSea API: Reached maximum pagination limit, stopping pagination' ) } else if (eventsNewerThanTimestamp === 0 && pageNumber > 1) { // Stop pagination when we reach events older than our timestamp @@ -246,7 +249,7 @@ export class OpenSeaEventsPollBot extends APIPollBot { pageNumber + 1 ) } catch (err) { - console.error('Error fetching paginated events:', err) + logger.error({ err }, 'Error fetching paginated events') } } } @@ -289,7 +292,7 @@ export class OpenSeaEventsPollBot extends APIPollBot { return response.data } catch (error) { - console.error('Error fetching next page of OpenSea events:', error) + logger.error({ err: error }, 'Error fetching next page of OpenSea events') throw error } } @@ -334,7 +337,7 @@ export class OpenSeaEventsPollBot extends APIPollBot { */ async handleSaleEvent(saleEvent: OpenSeaEvent) { if (!saleEvent.nft) { - console.warn('Sale event missing nft data') + logger.warn('Sale event missing nft data') return } diff --git a/src/Classes/APIBots/OpenSeaListBot.ts b/src/Classes/APIBots/OpenSeaListBot.ts index f9d2f65..f7de5ae 100644 --- a/src/Classes/APIBots/OpenSeaListBot.ts +++ b/src/Classes/APIBots/OpenSeaListBot.ts @@ -21,6 +21,7 @@ import { ArtBlocksTokenData, } from './utils' import { ItemListedEvent } from '@opensea/stream-js' +import { logger } from '../../logger' /** * Bot for handling OpenSea stream listing events @@ -58,7 +59,7 @@ export class OpenSeaListBot { try { await this.buildDiscordMessage(event.payload) } catch (err) { - console.error('Error processing OpenSea listing event:', err) + logger.error({ err }, 'Error processing OpenSea listing event') } } @@ -72,7 +73,7 @@ export class OpenSeaListBot { // Parse the NFT ID to get contract and token info const nftInfo = parseNftId(payload.item.nft_id) if (!nftInfo) { - console.warn('Could not parse NFT ID:', payload.item.nft_id) + logger.warn({ nftId: payload.item.nft_id }, 'Could not parse NFT ID') return } @@ -91,7 +92,7 @@ export class OpenSeaListBot { Math.abs(this.recentListings[tokenId].price - price) <= IDENTICAL_TOLERANCE ) { - console.log(`Skipping identical OpenSea relisting for ${tokenId}`) + logger.info({ tokenId }, 'Skipping identical OpenSea relisting') return } this.recentListings[tokenId] = { price, timestamp: Date.now() } @@ -101,7 +102,7 @@ export class OpenSeaListBot { // Skip banned addresses if (BAN_ADDRESSES.has(owner)) { - console.log(`Skipping OpenSea listing from banned address: ${owner}`) + logger.info({ owner }, 'Skipping OpenSea listing from banned address') return } @@ -168,7 +169,7 @@ export class OpenSeaListBot { embed.setURL(platformUrl + LISTING_UTM) } if (artBlocksData.collection_name) { - console.log(artBlocksData.name + ' OpenSea LIST') + logger.info({ name: artBlocksData.name }, 'OpenSea LIST') sendEmbedToListChannels( this.bot, embed, @@ -177,7 +178,7 @@ export class OpenSeaListBot { ) } } catch (error) { - console.error('Error building OpenSea listing message:', error) + logger.error({ err: error }, 'Error building OpenSea listing message') } } diff --git a/src/Classes/APIBots/OpenSeaSaleBot.ts b/src/Classes/APIBots/OpenSeaSaleBot.ts index 3d45b62..9d28953 100644 --- a/src/Classes/APIBots/OpenSeaSaleBot.ts +++ b/src/Classes/APIBots/OpenSeaSaleBot.ts @@ -26,6 +26,7 @@ import { import { ItemSoldEvent } from '@opensea/stream-js' import { TwitterBot } from '../TwitterBot' import { artIndexerBot } from '../..' +import { logger } from '../../logger' export interface NormalizedOpenSeaSale { source: 'stream' | 'api' @@ -78,17 +79,17 @@ export class OpenSeaSaleBot { try { const nftInfo = parseNftId(event.payload.item.nft_id) if (!nftInfo) { - console.warn('Could not parse NFT ID:', event.payload.item.nft_id) + logger.warn({ nftId: event.payload.item.nft_id }, 'Could not parse NFT ID') return } - console.log( - 'New stream sale event:', - event.payload.item.metadata.name, - 'timestamp:', - new Date(event.payload.event_timestamp).toISOString(), - 'sent at:', - event.sent_at + logger.info( + { + name: event.payload.item.metadata.name, + timestamp: new Date(event.payload.event_timestamp).toISOString(), + sentAt: event.sent_at, + }, + 'New stream sale event' ) const ethPrice = parseFloat( @@ -115,7 +116,7 @@ export class OpenSeaSaleBot { await this.handleNormalizedSale(normalizedSale) } catch (err) { - console.error('Error processing OpenSea sale event:', err) + logger.error({ err }, 'Error processing OpenSea sale event') } } @@ -151,9 +152,7 @@ export class OpenSeaSaleBot { const now = Date.now() const timeDiff = now - timestamp if (timeDiff > 1000 * 60 * 60 * 4) { - console.log( - `Skipping OpenSea sale from more than 4 hours ago: ${timestamp}` - ) + logger.info({ timestamp }, 'Skipping OpenSea sale from more than 4 hours ago') return } @@ -169,7 +168,7 @@ export class OpenSeaSaleBot { Math.abs(this.recentSales[compositeKey].price - price) <= IDENTICAL_TOLERANCE ) { - console.log(`Skipping identical OpenSea resale for ${compositeKey}`) + logger.info({ compositeKey }, 'Skipping identical OpenSea resale') return } this.recentSales[compositeKey] = { price, timestamp: Date.now() } @@ -179,7 +178,7 @@ export class OpenSeaSaleBot { // Skip banned addresses if (BAN_ADDRESSES.has(seller)) { - console.log(`Skipping OpenSea sale from banned address: ${seller}`) + logger.info({ seller }, 'Skipping OpenSea sale from banned address') return } @@ -263,7 +262,7 @@ export class OpenSeaSaleBot { } if (artBlocksData.collection_name) { - console.log(artBlocksData.name + ' OpenSea SALE') + logger.info({ name: artBlocksData.name }, 'OpenSea SALE') sendEmbedToSaleChannels( this.bot, embed, @@ -292,11 +291,11 @@ export class OpenSeaSaleBot { platform: platform, }) } catch (error) { - console.error('Error posting OpenSea sale to Twitter:', error) + logger.error({ err: error }, 'Error posting OpenSea sale to Twitter') } } } catch (error) { - console.error('Error building OpenSea sale message:', error) + logger.error({ err: error }, 'Error building OpenSea sale message') } } @@ -314,16 +313,12 @@ export class OpenSeaSaleBot { const isAB500 = artIndexerBot.isAB500(projectId) if (!isAB500) { - console.log('Skipping twitter sale for non-AB500 project', projectId) + logger.info({ projectId }, 'Skipping twitter sale for non-AB500 project') return false } if (sale.price < 0.1 && sale.currency.includes('ETH')) { - console.log( - 'Skipping twitter sale for low-value OpenSea sale', - sale.price, - sale.currency - ) + logger.info({ price: sale.price, currency: sale.currency }, 'Skipping twitter sale for low-value OpenSea sale') return false } diff --git a/src/Classes/APIBots/utils.ts b/src/Classes/APIBots/utils.ts index 01c49a6..844296f 100644 --- a/src/Classes/APIBots/utils.ts +++ b/src/Classes/APIBots/utils.ts @@ -12,6 +12,7 @@ import { mainnet } from 'viem/chains' dotenv.config() import axios from 'axios' +import { logger } from '../../logger' // Configure viem with multiple RPC providers for reliability const publicClient = createPublicClient({ @@ -89,9 +90,9 @@ async function getENSName(address: string): Promise { return name } catch (err) { retries++ - console.warn( - `ENS lookup error on ${address} (attempt ${retries}/${MAX_ENS_RETRIES})`, - err + logger.warn( + { err, address, attempt: retries, maxRetries: MAX_ENS_RETRIES }, + 'ENS lookup error' ) if (retries < MAX_ENS_RETRIES) { // Exponential backoff: 1s, 2s, 4s @@ -134,9 +135,9 @@ export async function resolveEnsName(ensName: string): Promise { return wallet } catch (err) { retries++ - console.warn( - `ENS resolve error on ${ensName} (attempt ${retries}/${MAX_ENS_RETRIES})`, - err + logger.warn( + { err, ensName, attempt: retries, maxRetries: MAX_ENS_RETRIES }, + 'ENS resolve error' ) if (retries < MAX_ENS_RETRIES) { await new Promise((r) => setTimeout(r, 1000 * Math.pow(2, retries - 1))) @@ -181,7 +182,7 @@ export async function getOSName(address: string): Promise { } catch (err) { // Probably rate limited - return empty string but don't cache name = '' - console.warn("Error getting user's OpenSea name:", (err as Error).message) + logger.warn({ err }, "Error getting user's OpenSea name") } } @@ -394,9 +395,9 @@ export async function replaceVideoWithGIF(url: string) { } } catch (e: unknown) { if (e instanceof AxiosError && e.response?.status === 404) { - console.log('GIF not found, returning PNG') + logger.info('GIF not found, returning PNG') } else { - console.log(`Error on fetching token API for ${gifURL}`, e) + logger.info({ err: e, gifURL }, 'Error on fetching token API for GIF') } return url.replace('mp4', 'png') } @@ -410,14 +411,14 @@ export async function replaceVideoWithGIF(url: string) { export const delay = (ms: number) => new Promise((res) => setTimeout(res, ms)) export const waitForStudioContracts = async (): Promise => { while (STUDIO_CONTRACTS.length === 0) { - console.log('Waiting for studio contracts to load...') + logger.info('Waiting for studio contracts to load') await delay(4000) } return STUDIO_CONTRACTS } export const waitForEngineContracts = async (): Promise => { while (ENGINE_CONTRACTS.length === 0) { - console.log('Waiting for engine contracts to load...') + logger.info('Waiting for engine contracts to load') await delay(5000) } return ENGINE_CONTRACTS @@ -456,13 +457,13 @@ export function parseNftId( ): { chainId: number; contractAddress: string; tokenId: string } | null { const parts = nftId.split('/') if (parts.length !== 3) { - console.warn('Invalid NFT ID format:', nftId) + logger.warn({ nftId }, 'Invalid NFT ID format') return null } const chainId = CHAIN_NAME_TO_ID[parts[0]] if (chainId === undefined) { - console.warn('Unknown chain in NFT ID:', parts[0], nftId) + logger.warn({ chain: parts[0], nftId }, 'Unknown chain in NFT ID') return null } diff --git a/src/Classes/ArtIndexerBot.ts b/src/Classes/ArtIndexerBot.ts index 19753e1..b659398 100644 --- a/src/Classes/ArtIndexerBot.ts +++ b/src/Classes/ArtIndexerBot.ts @@ -31,6 +31,7 @@ import { getProjectSlugUrl, } from './APIBots/utils' import { randomColor } from '../Utils/smartBotResponse' +import { logger } from '../logger' dotenv.config() // eslint-disable-next-line @typescript-eslint/no-var-requires @@ -121,18 +122,19 @@ export class ArtIndexerBot { } private logDictionarySizes() { - console.log('ArtIndexerBot Dictionary Sizes:') - console.log(`projects: ${Object.keys(this.projects).length}`) - console.log(`artists: ${Object.keys(this.artists).length}`) - console.log(`birthdays: ${Object.keys(this.birthdays).length}`) - console.log(`collections: ${Object.keys(this.collections).length}`) - console.log(`tags: ${Object.keys(this.tags).length}`) - console.log(`projectsById: ${Object.keys(this.projectsById).length}`) - console.log(`contracts: ${Object.keys(this.contracts).length}`) - console.log(`walletTokens: ${Object.keys(this.walletTokens).length}`) - console.log(`sets: ${Object.keys(this.sets).length}`) - console.log(`platforms: ${Object.keys(this.platforms).length}`) - console.log(`flagship: ${Object.keys(this.flagship).length}`) + logger.info({ + projects: Object.keys(this.projects).length, + artists: Object.keys(this.artists).length, + birthdays: Object.keys(this.birthdays).length, + collections: Object.keys(this.collections).length, + tags: Object.keys(this.tags).length, + projectsById: Object.keys(this.projectsById).length, + contracts: Object.keys(this.contracts).length, + walletTokens: Object.keys(this.walletTokens).length, + sets: Object.keys(this.sets).length, + platforms: Object.keys(this.platforms).length, + flagship: Object.keys(this.flagship).length, + }, 'ArtIndexerBot Dictionary Sizes') } async buildContracts() { @@ -145,7 +147,7 @@ export class ArtIndexerBot { } } } catch (e) { - console.error('Error in buildContracts', e) + logger.error({ err: e }, 'Error in buildContracts') } } @@ -153,7 +155,7 @@ export class ArtIndexerBot { try { this.sets = {} const setsArr = await getAllSets() - console.log(`ArtIndexerBot: Building ${setsArr.length} sets`) + logger.info({ count: setsArr.length }, 'ArtIndexerBot: Building sets') for (let i = 0; i < setsArr.length; i++) { const setName = setsArr[i].name if (typeof setName === 'string') { @@ -163,7 +165,7 @@ export class ArtIndexerBot { } this.sets['ab500'] = 'Art Blocks 500' } catch (e) { - console.error('Error in buildSets', e) + logger.error({ err: e }, 'Error in buildSets') } } @@ -171,8 +173,9 @@ export class ArtIndexerBot { try { this.clearDictionaries() const projects = await this.projectFetch() - console.log( - `ArtIndexerBot: Building ${projects.length} ProjectBots using: ${this.projectFetch.name}` + logger.info( + { count: projects.length, fetchName: this.projectFetch.name }, + 'ArtIndexerBot: Building ProjectBots' ) for (let i = 0; i < projects.length; i++) { const project = projects[i] @@ -277,7 +280,7 @@ export class ArtIndexerBot { }) }) } catch (err) { - console.error(`Error while initializing ArtIndexerBots\n${err}`) + logger.error({ err }, 'Error while initializing ArtIndexerBots') } } @@ -418,7 +421,7 @@ export class ArtIndexerBot { return } - console.log('Handling message', content) + logger.info({ content }, 'Handling message') if (content.toLowerCase().startsWith('#floor')) { msg.channel.send( @@ -433,7 +436,7 @@ export class ArtIndexerBot { await this.handleEntryMessage(msg) } catch (error) { msg.channel.send(`Sorry, I had trouble understanding that: ${content}`) - console.error('Error handling #entry message', error) + logger.error({ err: error }, 'Error handling #entry message') return } return @@ -445,7 +448,7 @@ export class ArtIndexerBot { await this.handleSetMessage(msg) } catch (error) { msg.channel.send(`Sorry, I had trouble understanding that: ${content}`) - console.error('Error handling #set message', error) + logger.error({ err: error }, 'Error handling #set message') return } return @@ -497,7 +500,7 @@ export class ArtIndexerBot { // use flagship contract token = await getMostRecentMintedFlagshipToken() } else if (!token) { - console.error('Bad value specified for recent', afterTheHash) + logger.error({ afterTheHash }, 'Bad value specified for recent') msg.channel.send('Sorry, I was not able to understand that.') return } @@ -513,14 +516,14 @@ export class ArtIndexerBot { projectBot.handleUpcomingMessage(msg, upcomingProjectDetails) return } catch (error) { - console.warn(error) + logger.warn({ err: error }, 'Error getting upcoming project') } } else { projectBot = await this.projectBotForMessage(projectKey, afterTheHash) } if (!projectBot) { - console.log("Wasn't able to parse message", content) + logger.info({ content }, "Wasn't able to parse message") return } @@ -634,7 +637,7 @@ export class ArtIndexerBot { const token = await getMostRecentMintedTokenByContracts(contracts) return token } catch (e) { - console.error('Error in getContractTokenForKey', e) + logger.error({ err: e }, 'Error in getContractTokenForKey') return null } } @@ -684,11 +687,7 @@ export class ArtIndexerBot { wallet: string, projectKey = '' ): Promise { - console.log( - `Getting random token${ - projectKey ? ` from ${projectKey}` : '' - } in wallet ${wallet}` - ) + logger.info({ projectKey, wallet }, 'Getting random token from wallet') // Resolve ENS name if ends in .eth if (wallet.toLowerCase().endsWith('.eth')) { const ensName = wallet @@ -775,7 +774,7 @@ export class ArtIndexerBot { const now = new Date() const [year, month, day] = now.toISOString().split('T')[0].split('-') const sentMessages: { [id: string]: boolean } = {} - console.log(`${this.birthdays[`${month}-${day}`]?.length} birthdays today!`) + logger.info({ count: this.birthdays[`${month}-${day}`]?.length }, 'birthdays today') if (this.birthdays[`${month}-${day}`]) { this.birthdays[`${month}-${day}`].forEach((projBot) => { if ( @@ -826,8 +825,9 @@ export class ArtIndexerBot { ) { return } - console.log( - `Sending minted out message for ${projectBot.projectName} with ${projectBot.maxEditionSize} tokens. Invocation: ${invocation}` + logger.info( + { projectName: projectBot.projectName, maxEditionSize: projectBot.maxEditionSize, invocation }, + 'Sending minted out message' ) projectBot.sendMintedOutMessage() @@ -1136,7 +1136,7 @@ export class ArtIndexerBot { msg.channel.send({ embeds: [embedContent] }) } catch (error) { - console.error('Error in handleSetMessage:', error) + logger.error({ err: error }, 'Error in handleSetMessage') msg.channel.send( `Sorry, there was an error retrieving data for the set "${setName}". Please try again later.` ) diff --git a/src/Classes/MintBot.ts b/src/Classes/MintBot.ts index 79211b1..9f7ee81 100644 --- a/src/Classes/MintBot.ts +++ b/src/Classes/MintBot.ts @@ -13,6 +13,7 @@ import { } from './APIBots/utils' import { ensOrAddress } from './APIBots/utils' import { TwitterBot } from './TwitterBot' +import { logger } from '../logger' // When true, only post mints from mainnet (chain ID 1). Non-mainnet mints are skipped. // Set to false in a couple weeks to enable L2/other chain mints. @@ -121,7 +122,7 @@ export class MintBot { try { new URL(assetUrl) } catch (e) { - console.log(`Invalid asset URL for mint ${id}: ${assetUrl}`) + logger.info({ id, assetUrl }, 'Invalid asset URL for mint') return } @@ -135,9 +136,7 @@ export class MintBot { const contentType = imageRes.headers['content-type'] if (!contentType?.startsWith('image/')) { - console.log( - `Invalid content type for mint ${id}: ${contentType}` - ) + logger.info({ id, contentType }, 'Invalid content type for mint') return } @@ -159,18 +158,18 @@ export class MintBot { await mint.postToDiscord() await this.postToTwitter(mint) } catch (e) { - console.log(`Error fetching image for mint ${id}:`, e) + logger.info({ err: e, id }, 'Error fetching image for mint') // Keep in queue to retry later return } } catch (e) { - console.log(`Error processing mint ${id}:`, e) + logger.info({ err: e, id }, 'Error processing mint') return } }) ) } catch (e) { - console.error('Error in checkAndPostMints:', e) + logger.error({ err: e }, 'Error in checkAndPostMints') } } @@ -184,25 +183,16 @@ export class MintBot { projectName: string, chainId: number ) { - console.log( - 'NEW MINT', - contractAddress, - tokenID, - owner, - 'chainId:', - chainId - ) + logger.info({ contractAddress, tokenID, owner, chainId }, 'NEW MINT') const id = `${contractAddress}-${tokenID}` if (!this.contractToChannel[contractAddress]) { - console.log('Skipping mint for contract not in config') + logger.info('Skipping mint for contract not in config') return } if (HIDE_NON_MAINNET_MINTS && chainId !== MAINNET_CHAIN_ID) { - console.log( - `Skipping mint for non-mainnet chain ${chainId} (HIDE_NON_MAINNET_MINTS=true)` - ) + logger.info({ chainId }, 'Skipping mint for non-mainnet chain (HIDE_NON_MAINNET_MINTS=true)') return } @@ -224,7 +214,7 @@ export class MintBot { startRoutine() { this.intervalId = setInterval(async () => { if (Object.keys(this.mintsToPost).length > 0) { - console.log(`${Object.keys(this.mintsToPost).length} mints to post`) + logger.info({ count: Object.keys(this.mintsToPost).length }, 'mints to post') } await this.checkAndPostMints() }, parseInt(MINT_REFRESH_TIME_SECONDS) * 1000) diff --git a/src/Classes/ProjectBot.ts b/src/Classes/ProjectBot.ts index 81e4135..801b272 100644 --- a/src/Classes/ProjectBot.ts +++ b/src/Classes/ProjectBot.ts @@ -27,6 +27,7 @@ import { CHANNEL_BLOCK_TALK, discordClient, triviaBot } from '..' import { ProjectHandlerHelper } from './ProjectHandlerHelper' import { UpcomingProjectDetailFragment } from '../../generated/graphql' import { getDayName, getMonthName, getDayOfMonth } from '../Utils/common' +import { logger } from '../logger' const ONE_MILLION = 1e6 @@ -166,7 +167,7 @@ export class ProjectBot { return } } catch (e) { - console.error('Error getting entry token for:', this.projectName, e) + logger.error({ err: e, projectName: this.projectName }, 'Error getting entry token') msg.channel.send( `Sorry, looks like there was an error retrieving the entry token for ${this.projectName}. Try again in a bit!` ) @@ -183,7 +184,7 @@ export class ProjectBot { try { this.sendRandomOob(msg) } catch (e) { - console.error('Error sending random OOB:', e) + logger.error({ err: e }, 'Error sending random OOB') msg.channel.send( `Huh, looks like there was an error getting a random sample from ${this.projectName}. Try again in a bit!` ) @@ -273,9 +274,9 @@ export class ProjectBot { try { tokenMetadata = await getToken(`${this.coreContract}-${tokenID}`) } catch (e) { - console.log( - `Error getting token metadata for ${msg.content}: ${this.coreContract}-${tokenID}`, - e + logger.info( + { err: e, msgContent: msg.content, tokenId: `${this.coreContract}-${tokenID}` }, + 'Error getting token metadata' ) return } @@ -359,7 +360,7 @@ export class ProjectBot { async sendBirthdayMessage(channels: Collection) { try { - console.log('sending birthday message(s) for:', this.projectName) + logger.info({ projectName: this.projectName }, 'sending birthday message') const artBlocksResponse = await axios.get( getTokenApiUrl( @@ -406,10 +407,9 @@ export class ProjectBot { const channel = channels.get(CHANNEL_BLOCK_TALK) as TextChannel channel?.send({ embeds: [embedContent] }) } catch (err) { - console.error( - 'Error sending birthday message for:', - this.projectName, - err + logger.error( + { err, projectName: this.projectName }, + 'Error sending birthday message' ) } return diff --git a/src/Classes/SchedulerBot.ts b/src/Classes/SchedulerBot.ts index 8aeaf05..ffcbb2f 100644 --- a/src/Classes/SchedulerBot.ts +++ b/src/Classes/SchedulerBot.ts @@ -4,6 +4,7 @@ import { Channel, Collection } from 'discord.js' import { ProjectConfig } from '../ProjectConfig/projectConfig' import { artIndexerBot } from '..' import { delay } from './APIBots/utils' +import { logger } from '../logger' import { Cron } from 'croner' @@ -26,12 +27,12 @@ export class ScheduleBot { async initialize() { try { await delay(INIT_DELAY) - console.log('Starting Scheduler...') + logger.info('Starting Scheduler') const bdayCron = Cron( '00 1,9,17 * * *', { timezone: 'America/Chicago', name: 'Bday' }, () => { - console.log('Birthday Time!') + logger.info('Birthday Time') artIndexerBot.checkBirthdays(this.channels) } ) @@ -45,22 +46,22 @@ export class ScheduleBot { { timezone: 'America/Chicago', name: 'Trivia' }, async () => { const wait = Math.random() * 1000 * 60 * 60 * triviaCadence - console.log(`Waiting ${wait / 60000} mins for trivia`) + logger.info({ waitMins: wait / 60000 }, 'Waiting for trivia') await delay(wait) if (isTriviaBlackoutTime()) { - console.log('Skipping Trivia during blackout times :(') + logger.info('Skipping Trivia during blackout times') return } - console.log('Trivia Time!') + logger.info('Trivia Time') artIndexerBot.askRandomTriviaQuestion() } ) this.cronJobs.push(triviaCron) } } catch (err) { - console.error('Error initializing ScheduleBot:', err) + logger.error({ err }, 'Error initializing ScheduleBot') } } diff --git a/src/Classes/TriviaBot.ts b/src/Classes/TriviaBot.ts index 54090c1..34eebec 100644 --- a/src/Classes/TriviaBot.ts +++ b/src/Classes/TriviaBot.ts @@ -8,6 +8,7 @@ import { randomColor } from '../Utils/smartBotResponse' import axios from 'axios' import { getTokenApiUrl, replaceVideoWithGIF } from './APIBots/utils' import { getAllTriviaScores, updateTriviaScore } from '../Data/supabase' +import { logger } from '../logger' export const CURRENT_SEASON = 'season_three' @@ -86,7 +87,7 @@ Next question:` text: 'Answer by using the #? command', }) const questionType = Math.floor(Math.random() * 3) - console.log(`Asking trivia question for ${project.projectName}`) + logger.info({ projectName: project.projectName }, 'Asking trivia question') try { switch (questionType) { @@ -107,7 +108,7 @@ Next question:` break } } catch (err) { - console.log('ERROR asking trivia question', err) + logger.error({ err }, 'Error asking trivia question') return } @@ -127,9 +128,9 @@ Next question:` embeds: [embed], }) .catch((err) => { - console.log( - `Error posting message in channel ${projectConfig.channels[CHANNEL_BLOCK_TALK].name} (id: ${CHANNEL_BLOCK_TALK})`, - err.message + logger.info( + { channelName: projectConfig.channels[CHANNEL_BLOCK_TALK].name, channelId: CHANNEL_BLOCK_TALK, errMessage: err.message }, + 'Error posting message in channel' ) })) ?? undefined } @@ -139,7 +140,7 @@ Next question:` embed: EmbedBuilder ): Promise { if (!this.model) { - console.log("Can't ask trivia question - no OpenAI API key") + logger.warn("Can't ask trivia question - no OpenAI API key") throw new Error("Can't ask trivia question - no OpenAI API key") } @@ -220,7 +221,7 @@ Next question:` try { score = await updateTriviaScore(msg.author.username) } catch (err) { - console.log('ERROR updating score', err) + logger.error({ err }, 'Error updating score') msg.reply( `Hmmm, you got it right but it seems there was an error updating your score. Pester Grant until he fixes it` ) @@ -255,7 +256,7 @@ Next question:` this.previousQuestionEmbed = undefined this.previousQuestion = undefined } catch (err) { - console.log('ERROR tallying', err) + logger.error({ err }, 'Error tallying') msg.reply('Oh no, looks like there was an unexpected error!') } } @@ -265,7 +266,7 @@ Next question:` try { scoreData = await getAllTriviaScores() } catch (err) { - console.log('ERROR getting leaderboard', err) + logger.error({ err }, 'Error getting leaderboard') msg.reply( 'Alas, looks like there was an unexpected error fetching the leaderboard!' ) diff --git a/src/Classes/TwitterBot.ts b/src/Classes/TwitterBot.ts index a9a28cc..d4f787f 100644 --- a/src/Classes/TwitterBot.ts +++ b/src/Classes/TwitterBot.ts @@ -26,6 +26,7 @@ import { updateLastTweetId, updateStatusRefreshToken, } from '../Data/supabase' +import { logger } from '../logger' // Twitter Bot Feature Toggles - Modify these to enable/disable functionality const TWITTER_LISTENING_ENABLED = false // Enables listening and responding to tweet mentions @@ -79,16 +80,14 @@ export class TwitterBot { // Check if Twitter is globally disabled if (!isTwitterGloballyEnabled) { - console.log( + logger.info( 'TwitterBot instantiated but Twitter is globally disabled via TWITTER_ENABLED environment variable' ) return } if (!appKey || !appSecret || !accessToken || !accessSecret) { - console.warn( - 'Twitter credentials are missing - not initializing TwitterBot' - ) + logger.warn('Twitter credentials are missing - not initializing TwitterBot') return } @@ -102,29 +101,25 @@ export class TwitterBot { // Only start listener if enabled via constant if (TWITTER_LISTENING_ENABLED) { - console.log('Starting Twitter listener') + logger.info('Starting Twitter listener') this.startSearchAndReplyRoutine() } else { - console.log( - 'Twitter listener disabled via TWITTER_LISTENING_ENABLED constant' - ) + logger.info('Twitter listener disabled via TWITTER_LISTENING_ENABLED constant') } } async startSearchAndReplyRoutine() { // Check if Twitter is globally disabled if (!isTwitterGloballyEnabled) { - console.log( - 'Twitter listener disabled via TWITTER_ENABLED environment variable' - ) + logger.info('Twitter listener disabled via TWITTER_ENABLED environment variable') return } try { this.lastTweetId = await getLastTweetId(prod) } catch (e) { - console.error('Error getting last tweet id:', e) - console.log('Aborting Twitter listener') + logger.error({ err: e }, 'Error getting last tweet id') + logger.info('Aborting Twitter listener') return } @@ -164,9 +159,7 @@ export class TwitterBot { error.rateLimitError && error.rateLimit ) { - console.log( - `Search rate limit hit! Limit will reset at timestamp ${error.rateLimit.reset}` - ) + logger.info({ resetTimestamp: error.rateLimit.reset }, 'Search rate limit hit') } else if ( error instanceof ApiResponseError && error.code === 400 && @@ -180,26 +173,23 @@ export class TwitterBot { ).message.split(' ') const lastId = BigInt(messageSplit[messageSplit.length - 1]) const adjustedLastId = (lastId + BigInt('100000000000')).toString() - console.log( - 'TwitterBot since_id is invalid - setting to', - adjustedLastId - ) + logger.info({ adjustedLastId }, 'TwitterBot since_id is invalid - setting to new value') this.lastTweetId = adjustedLastId } else { - console.error('Error searching Twitter:', error) + logger.error({ err: error }, 'Error searching Twitter') } return } if (!artbotTweets) { - console.log('No response from Twitter search') + logger.info('No response from Twitter search') return } - console.log('artbotTweets', artbotTweets?.meta?.result_count) + logger.info({ resultCount: artbotTweets?.meta?.result_count }, 'artbotTweets search result') if (artbotTweets.meta.result_count === 0) { - console.log('No new tweets found') + logger.info('No new tweets found') return } @@ -207,7 +197,7 @@ export class TwitterBot { try { this.replyToTweet(tweet) } catch (e) { - console.error(`Error responding to ${tweet.text}:`, e) + logger.error({ err: e, tweetText: tweet.text }, 'Error responding to tweet') } } if (artbotTweets.meta.newest_id) { @@ -228,10 +218,10 @@ export class TwitterBot { .match(/#(\?|\d*).+/g)?.[0] // Fetch the first hashtag and everything after it (until a newline) ?.trim() if (!cleanedTweet) { - console.warn(`Tweet '${tweet.text}' is not a supported action`) + logger.warn({ tweetText: tweet.text }, 'Tweet is not a supported action') return } - console.log(`Handling tweet ${tweet.id}: ${cleanedTweet}`) + logger.info({ tweetId: tweet.id, cleanedTweet }, 'Handling tweet') let projectBot let tokenId @@ -242,7 +232,7 @@ export class TwitterBot { projectBot = p tokenId = t } catch (e) { - console.error(e) + logger.error({ err: e }, 'Error handling tweet number') return } @@ -256,7 +246,7 @@ export class TwitterBot { try { media_id = await this.uploadMedia(assetUrl) } catch (error) { - console.error(`Error uploading media for ${assetUrl}:`, error) + logger.error({ err: error, assetUrl }, 'Error uploading media') return } @@ -284,7 +274,7 @@ export class TwitterBot { const tweetMessage = `${artBlocksData.name} by ${artBlocksData.artist} ${platform}\n\n${tokenUrl}` - console.log(`Replying to ${tweet.id} with ${artBlocksData.name}`) + logger.info({ tweetId: tweet.id, name: artBlocksData.name }, 'Replying to tweet') for (let i = 0; i < NUM_RETRIES; i++) { try { await this.twitterClient?.v2.reply(tweetMessage, tweet.id, { @@ -295,8 +285,9 @@ export class TwitterBot { return } catch (error) { if (error instanceof ApiResponseError && error.rateLimit) { - console.log( - `Rate limit hit on tweet sending: \nLimit: ${error.rateLimit.limit}\nRemaining: ${error.rateLimit.remaining}\nReset: ${error.rateLimit.reset}` + logger.info( + { limit: error.rateLimit.limit, remaining: error.rateLimit.remaining, reset: error.rateLimit.reset }, + 'Rate limit hit on tweet sending' ) const reset = new Date(error.rateLimit.reset * 1000) const now = new Date() @@ -308,17 +299,17 @@ export class TwitterBot { tweet.id ) } catch (e) { - console.error('Error sending status message:', e) + logger.error({ err: e }, 'Error sending status message') } return } else { - console.log(`Error replying to ${tweet.id}:`, error) - console.log(`Retrying (attempt ${i} of ${NUM_RETRIES})...`) + logger.info({ tweetId: tweet.id, err: error }, 'Error replying to tweet') + logger.info({ attempt: i, numRetries: NUM_RETRIES }, 'Retrying tweet reply') await delay(RETRY_DELAY_MS) } } } - console.error('Retry attempts failed :( - dropping tweet') + logger.error('Retry attempts failed - dropping tweet') } async uploadMedia(assetUrl: string): Promise { @@ -335,7 +326,7 @@ export class TwitterBot { // Can't resize videos, so try again with the png return await this.uploadMedia(assetUrl.replace('.mp4', '.png')) } - console.log('Resizing...') + logger.info('Resizing media') const ratio = TWITTER_MEDIA_BYTE_LIMIT / buff.length const metadata = await sharp(buff).metadata() buff = await sharp(buff) @@ -343,7 +334,7 @@ export class TwitterBot { .toBuffer() } - console.log('Uploading media to twitter...', assetUrl) + logger.info({ assetUrl }, 'Uploading media to Twitter') for (let i = 0; i < NUM_RETRIES; i++) { try { const mediaId = await this.twitterClient?.v2.uploadMedia(buff, { @@ -354,8 +345,8 @@ export class TwitterBot { } return mediaId } catch (err) { - console.log(`Error uploading ${assetUrl}:`, err) - console.log(`Retrying (attempt ${i} of ${NUM_RETRIES})...`) + logger.info({ err, assetUrl }, 'Error uploading media') + logger.info({ attempt: i, numRetries: NUM_RETRIES }, 'Retrying media upload') await delay(RETRY_DELAY_MS) } } @@ -375,30 +366,26 @@ export class TwitterBot { async _tweetMint(artBlock: Mint) { // Check if Twitter is globally disabled if (!isTwitterGloballyEnabled) { - console.log( - 'Twitter mint posting disabled via TWITTER_ENABLED environment variable' - ) + logger.info('Twitter mint posting disabled via TWITTER_ENABLED environment variable') return } // Check if Twitter mint posting is enabled if (!TWITTER_MINT_POSTING_ENABLED) { - console.log( - 'Twitter mint posting disabled via TWITTER_MINT_POSTING_ENABLED constant' - ) + logger.info('Twitter mint posting disabled via TWITTER_MINT_POSTING_ENABLED constant') return } const assetUrl = artBlock.image if (!artBlock.image) { - console.error('No artblock image defined', JSON.stringify(artBlock)) + logger.error({ artBlock: JSON.stringify(artBlock) }, 'No artblock image defined') return } const mediaId = await this.uploadMedia(assetUrl) if (!mediaId) { - console.error('no media id returned, not tweeting') + logger.error('no media id returned, not tweeting') return } @@ -407,7 +394,7 @@ export class TwitterBot { const tweetText = `${artBlock.tokenName} minted${ ownerText ? ` by ${ownerText}` : '' }. \n\n${artBlock.artblocksUrl + TWITTER_PROJECTBOT_UTM}` - console.log(`Tweeting ${tweetText}`) + logger.info({ tweetText }, 'Tweeting mint') const tweetRes = await this.twitterClient?.v2.tweet(tweetText, { text: tweetText, @@ -423,42 +410,38 @@ export class TwitterBot { try { await this._tweetMint(mint) } catch (e) { - console.error('Error posting to Twitter: ', e) + logger.error({ err: e }, 'Error posting to Twitter') } } // Have to log in with OAuth2 to access status account // Refresh token is stored in supabase async signIntoStatusAccount() { - console.log('Signing in to status account') + logger.info('Signing in to status account') const token = await getStatusRefreshToken() const API = new TwitterApi({ clientId: process.env.AB_TWITTER_CLIENT_ID ?? '', clientSecret: process.env.AB_TWITTER_CLIENT_SECRET ?? '', }) - console.log('Connecting with', token) + logger.info({ token }, 'Connecting with token') const { client: refreshedClient, refreshToken: newRefreshToken } = await API.refreshOAuth2Token(token) - console.log('Connected to status account! New token:', newRefreshToken) + logger.info({ newRefreshToken }, 'Connected to status account') await updateStatusRefreshToken(newRefreshToken ?? '') - console.log('Saved new refresh token') + logger.info('Saved new refresh token') this.twitterStatusAccount = refreshedClient } async sendStatusMessage(message: string, replyId?: string) { // Check if Twitter is globally disabled if (!isTwitterGloballyEnabled) { - console.log( - 'Twitter status messaging disabled via TWITTER_ENABLED environment variable' - ) + logger.info('Twitter status messaging disabled via TWITTER_ENABLED environment variable') return } // Check if Twitter listening is enabled (status messages are mainly for user interactions) if (!TWITTER_LISTENING_ENABLED) { - console.log( - 'Twitter status messaging disabled via TWITTER_LISTENING_ENABLED constant' - ) + logger.info('Twitter status messaging disabled via TWITTER_LISTENING_ENABLED constant') return } @@ -466,7 +449,7 @@ export class TwitterBot { await this.signIntoStatusAccount() } - console.log(`Sending status message ${replyId ? `to ${replyId}` : ''}`) + logger.info({ replyId }, 'Sending status message') if (replyId) { await this.twitterStatusAccount?.v2.reply(message, replyId) @@ -556,7 +539,7 @@ export class TwitterBot { } } catch (error) { // User profile not found, continue to ENS fallback - console.log(`No user profile found for address ${address}, trying ENS`) + logger.info({ address }, 'No user profile found for address, trying ENS') } // 4. Try ENS @@ -583,29 +566,25 @@ export class TwitterBot { }) { // Check if Twitter is globally disabled if (!isTwitterGloballyEnabled) { - console.log( - 'Twitter sale posting disabled via TWITTER_ENABLED environment variable' - ) + logger.info('Twitter sale posting disabled via TWITTER_ENABLED environment variable') return } // Check if Twitter sale posting is enabled if (!TWITTER_SALE_POSTING_ENABLED) { - console.log( - 'Twitter sale posting disabled via TWITTER_SALE_POSTING_ENABLED constant' - ) + logger.info('Twitter sale posting disabled via TWITTER_SALE_POSTING_ENABLED constant') return } try { - console.log(`Tweeting sale for ${saleData.tokenName}`) + logger.info({ tokenName: saleData.tokenName }, 'Tweeting sale') // Upload the token image to Twitter let media_id: string try { media_id = await this.uploadMedia(saleData.assetUrl) } catch (error) { - console.error(`Error uploading media for sale tweet:`, error) + logger.error({ err: error }, 'Error uploading media for sale tweet') return } @@ -627,15 +606,14 @@ export class TwitterBot { if (saleData.currency === 'ETH' || saleData.currency === 'WETH') { try { usdPrice = await this.convertEthToUsd(saleData.salePrice) - console.log( - `Converted ${saleData.salePrice} ${ - saleData.currency - } to $${usdPrice.toFixed(2)} USD using CoinGecko rate` + logger.info( + { salePrice: saleData.salePrice, currency: saleData.currency, usdPrice: usdPrice.toFixed(2) }, + 'Converted ETH to USD using CoinGecko rate' ) } catch (error) { - console.error( - `CoinGecko API failed, using original API USD price $${saleData.usdPrice} as fallback:`, - error + logger.error( + { err: error, fallbackUsdPrice: saleData.usdPrice }, + 'CoinGecko API failed, using original API USD price as fallback' ) // Keep using the original API usdPrice as fallback (more accurate than hardcoded guess) usdPrice = saleData.usdPrice @@ -669,7 +647,7 @@ export class TwitterBot { // Add view link tweetMessage += `\n\nview: ${saleData.tokenUrl + TWITTER_PROJECTBOT_UTM}` - console.log(`Posting sale tweet: ${saleData.tokenName}`) + logger.info({ tokenName: saleData.tokenName }, 'Posting sale tweet') // Post the tweet with retry logic for (let i = 0; i < NUM_RETRIES; i++) { @@ -679,12 +657,13 @@ export class TwitterBot { media_ids: [media_id], }, }) - console.log(`Successfully tweeted sale for ${saleData.tokenName}`) + logger.info({ tokenName: saleData.tokenName }, 'Successfully tweeted sale') return } catch (error) { if (error instanceof ApiResponseError && error.rateLimit) { - console.log( - `Rate limit hit on sale tweet: \nLimit: ${error.rateLimit.limit}\nRemaining: ${error.rateLimit.remaining}\nReset: ${error.rateLimit.reset}` + logger.info( + { limit: error.rateLimit.limit, remaining: error.rateLimit.remaining, reset: error.rateLimit.reset }, + 'Rate limit hit on sale tweet' ) const reset = new Date(error.rateLimit.reset * 1000) const now = new Date() @@ -695,19 +674,19 @@ export class TwitterBot { `Sale tweet rate limited :( Will retry in ${diffMinutes} minutes` ) } catch (e) { - console.error('Error sending rate limit status message:', e) + logger.error({ err: e }, 'Error sending rate limit status message') } return } else { - console.log(`Error posting sale tweet:`, error) - console.log(`Retrying (attempt ${i + 1} of ${NUM_RETRIES})...`) + logger.info({ err: error }, 'Error posting sale tweet') + logger.info({ attempt: i + 1, numRetries: NUM_RETRIES }, 'Retrying sale tweet') await delay(RETRY_DELAY_MS) } } } - console.error('Failed to post sale tweet after all retry attempts') + logger.error('Failed to post sale tweet after all retry attempts') } catch (error) { - console.error('Error in tweetSale:', error) + logger.error({ err: error }, 'Error in tweetSale') } } } diff --git a/src/Data/supabase.ts b/src/Data/supabase.ts index c69aedd..96c10e4 100644 --- a/src/Data/supabase.ts +++ b/src/Data/supabase.ts @@ -1,5 +1,6 @@ import { createClient } from '@supabase/supabase-js' import { CURRENT_SEASON } from '../Classes/TriviaBot' +import { logger } from '../logger' const supabaseClient = process.env.SUPABASE_URL && process.env.SUPABASE_API_KEY @@ -32,7 +33,7 @@ export const updateTriviaScore = async (username: string): Promise => { }) if (error) { - console.error('Error updating trivia score', error) + logger.error({ err: error }, 'Error updating trivia score') throw new Error(error.message) } diff --git a/src/ProjectConfig/projectConfig.ts b/src/ProjectConfig/projectConfig.ts index de55b7b..8384232 100644 --- a/src/ProjectConfig/projectConfig.ts +++ b/src/ProjectConfig/projectConfig.ts @@ -1,11 +1,12 @@ import { Message } from 'discord.js' import * as dotenv from 'dotenv' dotenv.config() +import { logger } from '../logger' const ARTBOT_IS_PROD = process.env.ARTBOT_IS_PROD && process.env.ARTBOT_IS_PROD.toLowerCase() == 'true' -console.log('ARTBOT_IS_PROD: ', ARTBOT_IS_PROD) +logger.info({ ARTBOT_IS_PROD }, 'ARTBOT_IS_PROD') const CHANNELS = ARTBOT_IS_PROD ? require('./channels.json') : require('./channels_dev.json') @@ -137,7 +138,7 @@ export class ProjectConfig { try { this.projectBots = await this.buildProjectBots(CHANNELS, PROJECT_BOTS) } catch (err) { - console.error(`Error while initializing ProjectBots: ${err}`) + logger.error({ err }, 'Error while initializing ProjectBots') } } @@ -181,8 +182,9 @@ export class ProjectConfig { // This loops through all bots that need to be instantiated asynchronously, // gets the relevant configuration from projectBotsJson, calls the subgraph // to get project information, and then initializes the project bot. - console.log( - `ProjectConfig: Initializing ${botsToInstatiate.size} project bots...` + logger.info( + { count: botsToInstatiate.size }, + 'ProjectConfig: Initializing project bots' ) const promises = Array.from(botsToInstatiate).map(async (botId: string) => { const [projectId, contractName] = botId.split('-') @@ -193,8 +195,9 @@ export class ProjectConfig { COLLAB_CONTRACTS[contractName] if (contractName && !configContract) { - console.warn( - `Bot ${botId} had a contractName, but there was no matching contract in partnerContracts.json. Has it been defined?` + logger.warn( + { botId }, + 'Bot had a contractName, but there was no matching contract in partnerContracts.json' ) return } @@ -265,7 +268,7 @@ export class ProjectConfig { ) if (!botName) { // only occurs when # messages are sent in observed channels without project bots - console.error(`Channel ID: ${channelID} does not have a ProjectBot`) + logger.error({ channelID }, 'Channel does not have a ProjectBot') return } this.projectBots[botName].handleNumberMessage(msg) diff --git a/src/Utils/activityTriager.ts b/src/Utils/activityTriager.ts index 9c246fc..7a4ad94 100644 --- a/src/Utils/activityTriager.ts +++ b/src/Utils/activityTriager.ts @@ -2,6 +2,7 @@ import { Client, EmbedBuilder, TextChannel } from 'discord.js' import * as dotenv from 'dotenv' import { CollectionType } from '../Classes/MintBot' import { projectConfig } from '..' +import { logger } from '../logger' dotenv.config() // Trade activity Discord channel IDs. @@ -120,7 +121,7 @@ function sendEmbedToChannel( ) { const channel = bot.channels?.cache?.get(channelId) as TextChannel if (!channel) { - console.log('Channel not found', channelId) + logger.info({ channelId }, 'Channel not found') return } channel @@ -128,9 +129,9 @@ function sendEmbedToChannel( embeds: [embed], }) .catch((err) => { - console.log( - `Error posting message in channel ${projectConfig.channels[channelId].name} (id: ${channelId})`, - err.message + logger.info( + { channelName: projectConfig.channels[channelId].name, channelId, errMessage: err.message }, + 'Error posting message in channel' ) }) } @@ -223,7 +224,7 @@ export function sendEmbedToSaleChannels( sendEmbedToChannel(bot, embed, PROOF) } } catch (e) { - console.warn(e) + logger.warn({ err: e }, 'Error sending embed to sale channels') } } @@ -271,6 +272,6 @@ export function sendEmbedToListChannels( sendEmbedToChannel(bot, embed, HODLERS_LISTINGS) } } catch (e) { - console.warn(e) + logger.warn({ err: e }, 'Error sending embed to listing channels') } } diff --git a/src/Utils/context.ts b/src/Utils/context.ts new file mode 100644 index 0000000..2b4ff54 --- /dev/null +++ b/src/Utils/context.ts @@ -0,0 +1,16 @@ +import { AsyncLocalStorage } from 'async_hooks' + +type LogContext = { + requestId?: string + [key: string]: unknown +} + +const storage = new AsyncLocalStorage() + +export const logContext = (context: LogContext, fn: () => T): T => { + return storage.run(context, fn) +} + +export const getRequestId = (): string | undefined => { + return storage.getStore()?.requestId +} diff --git a/src/Utils/logger.ts b/src/Utils/logger.ts new file mode 100644 index 0000000..49d2391 --- /dev/null +++ b/src/Utils/logger.ts @@ -0,0 +1,65 @@ +import pino from 'pino' +import { getRequestId } from './context' + +export { logContext, getRequestId } from './context' + +export const Logger = ({ + serviceName, + devMode, +}: { + /** + * The name of the service to show in logs metadata + * + * process.env.SERVICE_NAME will overwrite this value + */ + serviceName?: string + + /** If this is local development (pretty print the logs, etc) */ + devMode?: boolean + /** + * The log level to show. Defaults to "debug" + * + * process.env.LOG_LEVEL will overwrite this value + */ + level?: pino.Level +}) => { + // Datadog tags + const datadogTags = {} + + const baseLogOptions = !devMode + ? { + service: process.env.SERVICE_NAME || serviceName, + ddtags: buildDatadogTags(datadogTags), + } + : undefined + + // setup logging options + const options: pino.LoggerOptions = { + level: process.env.LOG_LEVEL || 'debug', + base: baseLogOptions, + transport: devMode + ? { + target: 'pino-pretty', + } + : undefined, + formatters: { + level: (label) => { + // use the name of the level instead of the number + return { level: label } + }, + }, + mixin() { + const requestId = getRequestId() + return requestId ? { requestId } : {} + }, + } + + return pino(options) +} + +const buildDatadogTags = (tags: Record) => { + return Object.entries(tags) + .filter(([_key, value]) => value !== undefined) + .map(([key, value]) => `${key}:${value}`) + .join(',') +} diff --git a/src/Utils/smartBotResponse.ts b/src/Utils/smartBotResponse.ts index 9ad6d7c..de30dfb 100644 --- a/src/Utils/smartBotResponse.ts +++ b/src/Utils/smartBotResponse.ts @@ -1,6 +1,7 @@ import { EmbedBuilder, ColorResolvable, Message } from 'discord.js' import * as dotenv from 'dotenv' import { artIndexerBot, projectConfig, triviaBot } from '..' +import { logger } from '../logger' dotenv.config() const fetch = require('node-fetch') @@ -270,7 +271,7 @@ async function generateGasPriceMessage(): Promise { :turtle: Slow: ${gasData.result.SafeGasPrice}` ) } catch (err) { - console.error('Error fetching gas prices:', err) + logger.error({ err }, 'Error fetching gas prices') return new EmbedBuilder() .setTitle(`:fuelpump: Gas Prices :fuelpump:`) .setColor(ARTBOT_WARNING) diff --git a/src/index.ts b/src/index.ts index a9753fa..4355333 100644 --- a/src/index.ts +++ b/src/index.ts @@ -37,6 +37,9 @@ import { } from './Classes/APIBots/utils' import { smartBotResponse } from './Utils/smartBotResponse' +import { logContext } from './Utils/logger' +import { randomUUID } from 'crypto' +import { logger } from './logger' // Misc. server configuration info. const DISCORD_TOKEN = process.env.DISCORD_TOKEN @@ -89,7 +92,7 @@ const PRODUCTION_MODE = process.env.PRODUCTION_MODE && process.env.PRODUCTION_MODE.toLowerCase() === 'true' -console.log('PRODUCTION_MODE: ', PRODUCTION_MODE) +logger.info({ PRODUCTION_MODE }, 'PRODUCTION_MODE') // App setup. const app = express() @@ -105,11 +108,7 @@ app.post( json: (data: unknown) => void } ) { - console.log( - 'received update with body:\n', - JSON.stringify(req.body, null, 2), - '\n' - ) + logger.info({ body: req.body }, 'received update with body') res.setHeader('Content-Type', 'application/json') res.json({ @@ -127,7 +126,7 @@ app.get( json: (data: unknown) => void } ) { - console.log('received get with body:\n', req.body, '\n') + logger.info({ body: req.body }, 'received get with body') res.setHeader('Content-Type', 'application/json') res.json({ @@ -167,20 +166,22 @@ app.post( const mintData = req.body.event.data.new if (req.headers.webhook_secret !== process.env.MINT_WEBHOOK_SECRET) { - console.log('Invalid mint webhook secret') + logger.warn('Invalid mint webhook secret') res.status(401).json({ status: 'unauthorized' }) return } - mintBot.addMint( - mintData.contract_address, - String(mintData.token_id), - mintData.owner_address, - String(mintData.invocation), - mintData.project_id, - mintData.project_name, - mintData.chain_id - ) + logContext({ requestId: randomUUID() }, () => { + mintBot.addMint( + mintData.contract_address, + String(mintData.token_id), + mintData.owner_address, + String(mintData.invocation), + mintData.project_id, + mintData.project_name, + mintData.chain_id + ) + }) res.setHeader('Content-Type', 'application/json') res.json({ success: true, @@ -189,7 +190,7 @@ app.post( ) app.listen(PORT, '0.0.0.0', function () { - console.log('Server is listening on port', PORT) + logger.info({ PORT }, 'Server is listening on port') }) app.get('/callback', (req: unknown, res: unknown) => { @@ -217,16 +218,16 @@ export const discordClient = new Client({ ], }) -console.log('Discord client created') -console.log('PRODUCTION_MODE:', PRODUCTION_MODE) -console.log('DISCORD_TOKEN exists:', !!DISCORD_TOKEN) +logger.info('Discord client created') +logger.info({ PRODUCTION_MODE }, 'PRODUCTION_MODE') +logger.info({ discordTokenExists: !!DISCORD_TOKEN }, 'DISCORD_TOKEN exists') const DISCORD_LOGIN_TIMEOUT = 30000 // 30 seconds const MAX_LOGIN_RETRIES = 5 let loginRetryCount = 0 async function attemptDiscordLogin() { - console.log('Attempting Discord login...') + logger.info('Attempting Discord login') try { const loginPromise = discordClient.login(DISCORD_TOKEN) @@ -238,10 +239,10 @@ async function attemptDiscordLogin() { }) await Promise.race([loginPromise, timeoutPromise]) - console.log('Discord login attempt successful') + logger.info('Discord login attempt successful') loginRetryCount = 0 } catch (error) { - console.error('Discord login failed:', error) + logger.error({ err: error }, 'Discord login failed') if (loginRetryCount < MAX_LOGIN_RETRIES) { loginRetryCount++ @@ -250,29 +251,28 @@ async function attemptDiscordLogin() { 5000 * Math.pow(2, loginRetryCount - 1), 80000 ) - console.log( - `Retrying login attempt ${loginRetryCount}/${MAX_LOGIN_RETRIES} in ${ - backoffTime / 1000 - }s...` + logger.info( + { loginRetryCount, MAX_LOGIN_RETRIES, backoffSeconds: backoffTime / 1000 }, + 'Retrying Discord login attempt' ) setTimeout(attemptDiscordLogin, backoffTime) } else { - console.error('Max login retries reached. Giving up.') + logger.error('Max login retries reached. Giving up.') process.exit(1) } } } discordClient.on('ready', () => { - console.log(`Logged in as ${discordClient.user?.tag}!`) + logger.info({ tag: discordClient.user?.tag }, 'Logged in to Discord') }) discordClient.on('error', (error) => { - console.error('Discord client error:', error) + logger.error({ err: error }, 'Discord client error') }) discordClient.on('disconnect', () => { - console.log('Discord client disconnected') + logger.info('Discord client disconnected') // Cleanup all bots botsToCleanup.forEach((bot) => bot.cleanup()) }) @@ -322,7 +322,7 @@ discordClient.on(Events.MessageCreate, async (msg) => { return } } catch (e) { - console.error('Error handling number message: ', e) + logger.error({ err: e }, 'Error handling number message') } // Handle special info questions that ArtBot knows how to answer. const artBotID = discordClient.user?.id ?? '' @@ -346,7 +346,7 @@ discordClient.on(Events.MessageCreate, async (msg) => { // Only instantiate TwitterBot if TWITTER_ENABLED is true const isTwitterEnabled = process.env.TWITTER_ENABLED?.toLowerCase() === 'true' -console.log('Twitter functionality enabled:', isTwitterEnabled) +logger.info({ isTwitterEnabled }, 'Twitter functionality enabled') const abTwitterBot = isTwitterEnabled ? new TwitterBot({ @@ -358,7 +358,7 @@ const abTwitterBot = isTwitterEnabled : undefined if (!isTwitterEnabled) { - console.log('TwitterBot disabled via TWITTER_ENABLED environment variable') + logger.info('TwitterBot disabled via TWITTER_ENABLED environment variable') } export const mintBot = new MintBot(discordClient, abTwitterBot) @@ -423,7 +423,7 @@ function getReconnectDelay(attemptNumber: number): number { // Initialize OpenSea Stream Client with error handling function initializeOpenSeaStreamClient(): OpenSeaStreamClient { - console.log('Initializing OpenSea Stream Client...') + logger.info('Initializing OpenSea Stream Client') const client = new OpenSeaStreamClient({ token: process.env.OPENSEA_API_KEY ?? '', @@ -432,7 +432,7 @@ function initializeOpenSeaStreamClient(): OpenSeaStreamClient { sessionStorage: LocalStorage, }, onError: (error: unknown) => { - console.error('❌ OpenSea Stream Client error:', error) + logger.error({ err: error }, 'OpenSea Stream Client error') connectionState.lastError = error as Error connectionState.isConnected = false @@ -443,16 +443,7 @@ function initializeOpenSeaStreamClient(): OpenSeaStreamClient { errorMessage.includes('502') || errorMessage.includes('Bad Gateway') ) { - console.log('🚨 Detected 502 Bad Gateway error - OpenSea server issue') - console.log(' This is typically caused by:') - console.log( - " • OpenSea's WebSocket gateway unable to reach backend services" - ) - console.log(' • Temporary server overload or maintenance') - console.log(' • Network issues between gateway and backend') - console.log( - ' ✅ Automatic reconnection will be attempted with exponential backoff' - ) + logger.warn('Detected 502 Bad Gateway error - OpenSea server issue. Automatic reconnection will be attempted with exponential backoff.') } // Schedule reconnection for connection errors @@ -468,7 +459,7 @@ function initializeOpenSeaStreamClient(): OpenSeaStreamClient { // The OpenSeaStreamClient handles connection internally using Phoenix channels // We'll rely on the onError callback and add additional monitoring - console.log('✅ OpenSea Stream Client initialized with error handling') + logger.info('OpenSea Stream Client initialized with error handling') connectionState.isConnected = true connectionState.reconnectAttempts = 0 connectionState.lastConnectTime = Date.now() @@ -488,32 +479,29 @@ function scheduleReconnection() { } if (connectionState.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) { - console.error( - `❌ Max reconnection attempts (${MAX_RECONNECT_ATTEMPTS}) reached. Stopping reconnection attempts.` - ) - console.log('📊 Falling back to API polling only for OpenSea events') + logger.error({ MAX_RECONNECT_ATTEMPTS }, 'Max reconnection attempts reached. Stopping reconnection attempts. Falling back to API polling only for OpenSea events.') return } const delay = getReconnectDelay(connectionState.reconnectAttempts) connectionState.reconnectAttempts++ - console.log( - `🔄 Scheduling OpenSea WebSocket reconnection attempt ${ - connectionState.reconnectAttempts - }/${MAX_RECONNECT_ATTEMPTS} in ${delay / 1000}s...` + logger.info( + { attempt: connectionState.reconnectAttempts, maxAttempts: MAX_RECONNECT_ATTEMPTS, delaySeconds: delay / 1000 }, + 'Scheduling OpenSea WebSocket reconnection attempt' ) reconnectTimeout = setTimeout(() => { - console.log( - `🔄 Attempting OpenSea WebSocket reconnection (attempt ${connectionState.reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})` + logger.info( + { attempt: connectionState.reconnectAttempts, maxAttempts: MAX_RECONNECT_ATTEMPTS }, + 'Attempting OpenSea WebSocket reconnection' ) try { // Reinitialize the stream client openseaStreamClient = initializeOpenSeaStreamClient() setupStreamEventHandlers() } catch (error) { - console.error('❌ Failed to reinitialize OpenSea Stream Client:', error) + logger.error({ err: error }, 'Failed to reinitialize OpenSea Stream Client') scheduleReconnection() } }, delay) @@ -528,26 +516,20 @@ function startHealthCheckMonitoring() { : Infinity if (!connectionState.isConnected) { - console.log( - '⚠️ OpenSea WebSocket health check: Connection lost, reconnection should be in progress' - ) + logger.warn('OpenSea WebSocket health check: Connection lost, reconnection should be in progress') } else if (timeSinceLastConnect > 600000) { // 10 minutes - console.log( - '✅ OpenSea WebSocket health check: Connection stable for', - Math.round(timeSinceLastConnect / 60000), - 'minutes' + logger.info( + { stableMinutes: Math.round(timeSinceLastConnect / 60000) }, + 'OpenSea WebSocket health check: Connection stable' ) } // Log connection stats if (connectionState.reconnectAttempts > 0) { - console.log( - `📊 OpenSea WebSocket stats: ${ - connectionState.reconnectAttempts - } reconnection attempts, currently ${ - connectionState.isConnected ? 'connected' : 'disconnected' - }` + logger.info( + { reconnectAttempts: connectionState.reconnectAttempts, isConnected: connectionState.isConnected }, + 'OpenSea WebSocket stats' ) } }, CONNECTION_HEALTH_CHECK_INTERVAL) @@ -578,12 +560,12 @@ function setupStreamEventHandlers() { // Process the listing with the old OpenSeaListBot for now // This is the primary source for listings openSeaListBot.handleListingEvent(event).catch((err) => { - console.error('Error processing OpenSea listing event:', err) + logger.error({ err }, 'Error processing OpenSea listing event') }) } } } catch (error) { - console.error('Error handling OpenSea listing event:', error) + logger.error({ err: error }, 'Error handling OpenSea listing event') } }) @@ -624,12 +606,12 @@ function setupStreamEventHandlers() { // Process the sale with the old OpenSeaSaleBot for now // This is the primary source for sales openSeaSaleBot.handleSaleEvent(event).catch((err: unknown) => { - console.error('Error processing OpenSea sale event:', err) + logger.error({ err }, 'Error processing OpenSea sale event') }) } } } catch (error) { - console.error('Error handling OpenSea sale event:', error) + logger.error({ err: error }, 'Error handling OpenSea sale event') } }) } @@ -661,13 +643,13 @@ if (typeof global !== 'undefined') { } // Initialize the stream client -console.log('Starting OpenSea WebSocket Stream integration...') +logger.info('Starting OpenSea WebSocket Stream integration') openseaStreamClient = initializeOpenSeaStreamClient() setupStreamEventHandlers() -// Log configuration -console.log( - `OpenSea Stream Config: Max reconnects=${MAX_RECONNECT_ATTEMPTS}, Initial delay=${INITIAL_RECONNECT_DELAY}ms, Max delay=${MAX_RECONNECT_DELAY}ms` +logger.info( + { MAX_RECONNECT_ATTEMPTS, INITIAL_RECONNECT_DELAY, MAX_RECONNECT_DELAY }, + 'OpenSea Stream Config' ) // OpenSea Stream Bots - Primary handlers, API polling for sales backfill @@ -681,7 +663,7 @@ let openSeaEventsBot: OpenSeaEventsPollBot | null = null // Initialize new OpenSea API polling bots const initOpenSeaApiPollingBots = async () => { - console.log('Initializing OpenSea API polling bots...') + logger.info('Initializing OpenSea API polling bots') // Wait for contracts to be loaded const studioContracts = await waitForStudioContracts() @@ -694,9 +676,7 @@ const initOpenSeaApiPollingBots = async () => { .concat(studioContracts) .concat(engineContracts) - console.log( - `Tracking ${allContracts.length} contracts for OpenSea API polling` - ) + logger.info({ count: allContracts.length }, 'Tracking contracts for OpenSea API polling') // OpenSea API configuration const OPENSEA_API_BASE = 'https://api.opensea.io/api/v2/events' @@ -718,9 +698,7 @@ const initOpenSeaApiPollingBots = async () => { ) botsToCleanup.push(openSeaEventsBot) - console.log( - 'OpenSea API polling bot initialized successfully (sales backfill only)' - ) + logger.info('OpenSea API polling bot initialized successfully (sales backfill only)') } // Helper function to check if an OpenSea NFT ID contains any of our tracked contracts @@ -747,13 +725,13 @@ if (PRODUCTION_MODE) { attemptDiscordLogin() // Initialize OpenSea API polling bots after Discord login initOpenSeaApiPollingBots().catch((err) => { - console.error('Error initializing OpenSea API polling bots:', err) + logger.error({ err }, 'Error initializing OpenSea API polling bots') }) } // Cleanup function for OpenSea WebSocket connections function cleanupOpenSeaConnections() { - console.log('Cleaning up OpenSea WebSocket connections...') + logger.info('Cleaning up OpenSea WebSocket connections') // Clear reconnection timeout if (reconnectTimeout) { @@ -771,10 +749,10 @@ function cleanupOpenSeaConnections() { if (openseaStreamClient) { try { openseaStreamClient.disconnect(() => { - console.log('OpenSea WebSocket disconnected successfully') + logger.info('OpenSea WebSocket disconnected successfully') }) } catch (error) { - console.error('Error disconnecting OpenSea WebSocket:', error) + logger.error({ err: error }, 'Error disconnecting OpenSea WebSocket') } } @@ -785,7 +763,7 @@ function cleanupOpenSeaConnections() { // Handle application shutdown process.on('SIGINT', () => { - console.log('Received SIGINT. Cleaning up...') + logger.info('Received SIGINT. Cleaning up.') // Cleanup OpenSea connections first cleanupOpenSeaConnections() // Cleanup all bots @@ -796,7 +774,7 @@ process.on('SIGINT', () => { }) process.on('SIGTERM', () => { - console.log('Received SIGTERM. Cleaning up...') + logger.info('Received SIGTERM. Cleaning up.') // Cleanup OpenSea connections first cleanupOpenSeaConnections() // Cleanup all bots diff --git a/src/logger.ts b/src/logger.ts new file mode 100644 index 0000000..454ff7f --- /dev/null +++ b/src/logger.ts @@ -0,0 +1,8 @@ +import { Logger } from './Utils/logger' + +const devMode = process.env.NODE_ENV !== 'production' + +export const logger = Logger({ + serviceName: 'artbot', + devMode, +}) diff --git a/tsconfig.json b/tsconfig.json index f48b727..916d935 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,5 +18,8 @@ "useUnknownInCatchVariables": false }, "include": ["src/**/*.ts", "src/**/*.js"], - "exclude": ["generated", "node_modules"] + "exclude": ["generated", "node_modules"], + "ts-node": { + "transpileOnly": true + } } diff --git a/yarn.lock b/yarn.lock index 1cc05dc..8ec057b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2275,6 +2275,13 @@ __metadata: languageName: node linkType: hard +"@pinojs/redact@npm:^0.4.0": + version: 0.4.0 + resolution: "@pinojs/redact@npm:0.4.0" + checksum: 10c0/4b311ba17ee0cf154ff9c39eb063ec04cd0d0017cb3750efcdf06c2d485df3e1095e13e872175993568c5568c23e4508dd877c981bbc9c5ae5e384d569efcdff + languageName: node + linkType: hard + "@pkgjs/parseargs@npm:^0.11.0": version: 0.11.0 resolution: "@pkgjs/parseargs@npm:0.11.0" @@ -3208,6 +3215,8 @@ __metadata: node-html-parser: "npm:^3.2.0" node-localstorage: "npm:^3.0.5" openai: "npm:^4.73.0" + pino: "npm:^10.3.1" + pino-pretty: "npm:^13.1.3" prettier: "npm:^2.6.2" reconnecting-websocket: "npm:^4.4.0" sharp: "npm:^0.32.6" @@ -3267,6 +3276,13 @@ __metadata: languageName: node linkType: hard +"atomic-sleep@npm:^1.0.0": + version: 1.0.0 + resolution: "atomic-sleep@npm:1.0.0" + checksum: 10c0/e329a6665512736a9bbb073e1761b4ec102f7926cce35037753146a9db9c8104f5044c1662e4a863576ce544fb8be27cd2be6bc8c1a40147d03f31eb1cfb6e8a + languageName: node + linkType: hard + "auto-bind@npm:~4.0.0": version: 4.0.0 resolution: "auto-bind@npm:4.0.0" @@ -3978,6 +3994,13 @@ __metadata: languageName: node linkType: hard +"colorette@npm:^2.0.7": + version: 2.0.20 + resolution: "colorette@npm:2.0.20" + checksum: 10c0/e94116ff33b0ff56f3b83b9ace895e5bf87c2a7a47b3401b8c3f3226e050d5ef76cf4072fb3325f9dc24d1698f9b730baf4e05eeaf861d74a1883073f4c98a40 + languageName: node + linkType: hard + "combined-stream@npm:^1.0.8": version: 1.0.8 resolution: "combined-stream@npm:1.0.8" @@ -4180,6 +4203,13 @@ __metadata: languageName: node linkType: hard +"dateformat@npm:^4.6.3": + version: 4.6.3 + resolution: "dateformat@npm:4.6.3" + checksum: 10c0/e2023b905e8cfe2eb8444fb558562b524807a51cdfe712570f360f873271600b5c94aebffaf11efb285e2c072264a7cf243eadb68f3eba0f8cc85fb86cd25df6 + languageName: node + linkType: hard + "debounce@npm:^1.2.0": version: 1.2.1 resolution: "debounce@npm:1.2.1" @@ -5161,6 +5191,13 @@ __metadata: languageName: node linkType: hard +"fast-copy@npm:^4.0.0": + version: 4.0.2 + resolution: "fast-copy@npm:4.0.2" + checksum: 10c0/f8e51483ce1761e3ba21a07cbd2149afd99a62230aa8025acc6ce4c818000a52c94ebb80960d63953ff01677b7886841f250395e462ad9a54bef39cce1834343 + languageName: node + linkType: hard + "fast-deep-equal@npm:3.1.3, fast-deep-equal@npm:^3.1.1, fast-deep-equal@npm:^3.1.3": version: 3.1.3 resolution: "fast-deep-equal@npm:3.1.3" @@ -5209,6 +5246,13 @@ __metadata: languageName: node linkType: hard +"fast-safe-stringify@npm:^2.1.1": + version: 2.1.1 + resolution: "fast-safe-stringify@npm:2.1.1" + checksum: 10c0/d90ec1c963394919828872f21edaa3ad6f1dddd288d2bd4e977027afff09f5db40f94e39536d4646f7e01761d704d72d51dce5af1b93717f3489ef808f5f4e4d + languageName: node + linkType: hard + "fast-text-encoding@npm:^1.0.0": version: 1.0.6 resolution: "fast-text-encoding@npm:1.0.6" @@ -5932,6 +5976,13 @@ __metadata: languageName: node linkType: hard +"help-me@npm:^5.0.0": + version: 5.0.0 + resolution: "help-me@npm:5.0.0" + checksum: 10c0/054c0e2e9ae2231c85ab5e04f75109b9d068ffcc54e58fb22079822a5ace8ff3d02c66fd45379c902ad5ab825e5d2e1451fcc2f7eab1eb49e7d488133ba4cacb + languageName: node + linkType: hard + "html-escaper@npm:^2.0.0": version: 2.0.2 resolution: "html-escaper@npm:2.0.2" @@ -7011,6 +7062,13 @@ __metadata: languageName: node linkType: hard +"joycon@npm:^3.1.1": + version: 3.1.1 + resolution: "joycon@npm:3.1.1" + checksum: 10c0/131fb1e98c9065d067fd49b6e685487ac4ad4d254191d7aa2c9e3b90f4e9ca70430c43cad001602bdbdabcf58717d3b5c5b7461c1bd8e39478c8de706b3fe6ae + languageName: node + linkType: hard + "js-sdsl@npm:^4.1.4": version: 4.1.5 resolution: "js-sdsl@npm:4.1.5" @@ -8007,6 +8065,13 @@ __metadata: languageName: node linkType: hard +"on-exit-leak-free@npm:^2.1.0": + version: 2.1.2 + resolution: "on-exit-leak-free@npm:2.1.2" + checksum: 10c0/faea2e1c9d696ecee919026c32be8d6a633a7ac1240b3b87e944a380e8a11dc9c95c4a1f8fb0568de7ab8db3823e790f12bda45296b1d111e341aad3922a0570 + languageName: node + linkType: hard + "on-finished@npm:2.4.1, on-finished@npm:~2.4.1": version: 2.4.1 resolution: "on-finished@npm:2.4.1" @@ -8332,6 +8397,66 @@ __metadata: languageName: node linkType: hard +"pino-abstract-transport@npm:^3.0.0": + version: 3.0.0 + resolution: "pino-abstract-transport@npm:3.0.0" + dependencies: + split2: "npm:^4.0.0" + checksum: 10c0/4486e1b9508110aaf963d07741ac98d660b974dd51d8ad42077d215118e27cda20c64da46c07c926898d52540aab7c6b9c37dc0f5355c203bb1d6a72b5bd8d6c + languageName: node + linkType: hard + +"pino-pretty@npm:^13.1.3": + version: 13.1.3 + resolution: "pino-pretty@npm:13.1.3" + dependencies: + colorette: "npm:^2.0.7" + dateformat: "npm:^4.6.3" + fast-copy: "npm:^4.0.0" + fast-safe-stringify: "npm:^2.1.1" + help-me: "npm:^5.0.0" + joycon: "npm:^3.1.1" + minimist: "npm:^1.2.6" + on-exit-leak-free: "npm:^2.1.0" + pino-abstract-transport: "npm:^3.0.0" + pump: "npm:^3.0.0" + secure-json-parse: "npm:^4.0.0" + sonic-boom: "npm:^4.0.1" + strip-json-comments: "npm:^5.0.2" + bin: + pino-pretty: bin.js + checksum: 10c0/36fa382521a893290c8f6a5b2ddc28dfb87fda1d161adb6b97d80bf7d24184970d0a7eab6f8ee45c39aff4b2ec3b2e533c756899798adc270010f34ba4411063 + languageName: node + linkType: hard + +"pino-std-serializers@npm:^7.0.0": + version: 7.1.0 + resolution: "pino-std-serializers@npm:7.1.0" + checksum: 10c0/d158615aa93ebdeac2d3912ad4227a23ef78cf14229e886214771f581e96eff312257f2d6368c75b2fbf53e5024eda475d81305014f4ed1a6d5eeab9107f6ef0 + languageName: node + linkType: hard + +"pino@npm:^10.3.1": + version: 10.3.1 + resolution: "pino@npm:10.3.1" + dependencies: + "@pinojs/redact": "npm:^0.4.0" + atomic-sleep: "npm:^1.0.0" + on-exit-leak-free: "npm:^2.1.0" + pino-abstract-transport: "npm:^3.0.0" + pino-std-serializers: "npm:^7.0.0" + process-warning: "npm:^5.0.0" + quick-format-unescaped: "npm:^4.0.3" + real-require: "npm:^0.2.0" + safe-stable-stringify: "npm:^2.3.1" + sonic-boom: "npm:^4.0.1" + thread-stream: "npm:^4.0.0" + bin: + pino: bin.js + checksum: 10c0/ae1c57f2baac85dd5d63a3500746d5ea1cfc4bfcbf356eaec94d42a782eeb80caa4d4614de43a036cf48e2aed46d855a7ff21b126f55a63811def52a894ef937 + languageName: node + linkType: hard + "pirates@npm:^4.0.4": version: 4.0.5 resolution: "pirates@npm:4.0.5" @@ -8414,6 +8539,13 @@ __metadata: languageName: node linkType: hard +"process-warning@npm:^5.0.0": + version: 5.0.0 + resolution: "process-warning@npm:5.0.0" + checksum: 10c0/941f48863d368ec161e0b5890ba0c6af94170078f3d6b5e915c19b36fb59edb0dc2f8e834d25e0d375a8bf368a49d490f080508842168832b93489d17843ec29 + languageName: node + linkType: hard + "promise-retry@npm:^2.0.1": version: 2.0.1 resolution: "promise-retry@npm:2.0.1" @@ -8525,6 +8657,13 @@ __metadata: languageName: node linkType: hard +"quick-format-unescaped@npm:^4.0.3": + version: 4.0.4 + resolution: "quick-format-unescaped@npm:4.0.4" + checksum: 10c0/fe5acc6f775b172ca5b4373df26f7e4fd347975578199e7d74b2ae4077f0af05baa27d231de1e80e8f72d88275ccc6028568a7a8c9ee5e7368ace0e18eff93a4 + languageName: node + linkType: hard + "range-parser@npm:~1.2.1": version: 1.2.1 resolution: "range-parser@npm:1.2.1" @@ -8608,6 +8747,13 @@ __metadata: languageName: node linkType: hard +"real-require@npm:^0.2.0": + version: 0.2.0 + resolution: "real-require@npm:0.2.0" + checksum: 10c0/23eea5623642f0477412ef8b91acd3969015a1501ed34992ada0e3af521d3c865bb2fe4cdbfec5fe4b505f6d1ef6a03e5c3652520837a8c3b53decff7e74b6a0 + languageName: node + linkType: hard + "reconnecting-websocket@npm:^4.4.0": version: 4.4.0 resolution: "reconnecting-websocket@npm:4.4.0" @@ -8827,6 +8973,13 @@ __metadata: languageName: node linkType: hard +"safe-stable-stringify@npm:^2.3.1": + version: 2.5.0 + resolution: "safe-stable-stringify@npm:2.5.0" + checksum: 10c0/baea14971858cadd65df23894a40588ed791769db21bafb7fd7608397dbdce9c5aac60748abae9995e0fc37e15f2061980501e012cd48859740796bea2987f49 + languageName: node + linkType: hard + "safer-buffer@npm:>= 2.1.2 < 3, safer-buffer@npm:>= 2.1.2 < 3.0.0": version: 2.1.2 resolution: "safer-buffer@npm:2.1.2" @@ -8841,6 +8994,13 @@ __metadata: languageName: node linkType: hard +"secure-json-parse@npm:^4.0.0": + version: 4.1.0 + resolution: "secure-json-parse@npm:4.1.0" + checksum: 10c0/52b3f8125ea974db1333a5b63e6a1df550c36c0d5f9a263911d6732812bd02e938b30be324dcbbb9da3ef9bf5a84849e0dd911f56544003d3c09e8eee12504de + languageName: node + linkType: hard + "semver@npm:^5.6.0": version: 5.7.2 resolution: "semver@npm:5.7.2" @@ -9156,6 +9316,15 @@ __metadata: languageName: node linkType: hard +"sonic-boom@npm:^4.0.1": + version: 4.2.1 + resolution: "sonic-boom@npm:4.2.1" + dependencies: + atomic-sleep: "npm:^1.0.0" + checksum: 10c0/f374e9c3dfe194d706d8ef8aed4e28bc10638f9952fd19bcbddf2cef05d53334ad9e9dca3e01e24e88dd88fb278c6b8c5b2ae5002494b289c4914aaea3d9eae9 + languageName: node + linkType: hard + "source-map-support@npm:0.5.13": version: 0.5.13 resolution: "source-map-support@npm:0.5.13" @@ -9173,6 +9342,13 @@ __metadata: languageName: node linkType: hard +"split2@npm:^4.0.0": + version: 4.2.0 + resolution: "split2@npm:4.2.0" + checksum: 10c0/b292beb8ce9215f8c642bb68be6249c5a4c7f332fc8ecadae7be5cbdf1ea95addc95f0459ef2e7ad9d45fd1064698a097e4eb211c83e772b49bc0ee423e91534 + languageName: node + linkType: hard + "sponge-case@npm:^1.0.1": version: 1.0.1 resolution: "sponge-case@npm:1.0.1" @@ -9361,6 +9537,13 @@ __metadata: languageName: node linkType: hard +"strip-json-comments@npm:^5.0.2": + version: 5.0.3 + resolution: "strip-json-comments@npm:5.0.3" + checksum: 10c0/daaf20b29f69fb51112698f4a9a662490dbb78d5baf6127c75a0a83c2ac6c078a8c0f74b389ad5e0519d6fc359c4a57cb9971b1ae201aef62ce45a13247791e0 + languageName: node + linkType: hard + "strip-json-comments@npm:~2.0.1": version: 2.0.1 resolution: "strip-json-comments@npm:2.0.1" @@ -9510,6 +9693,15 @@ __metadata: languageName: node linkType: hard +"thread-stream@npm:^4.0.0": + version: 4.0.0 + resolution: "thread-stream@npm:4.0.0" + dependencies: + real-require: "npm:^0.2.0" + checksum: 10c0/f0a47a673af574062df20140ec3e857d679365253fcaa98a76c167c9a053ee03291f4b25bd89b078c7f6a48f07f49d5a49e4f5598bb1c8a263ec15955a018fbd + languageName: node + linkType: hard + "through@npm:^2.3.6, through@npm:^2.3.8": version: 2.3.8 resolution: "through@npm:2.3.8"