Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@
"@opensea/stream-js": "^0.2.1",
"@supabase/supabase-js": "^2.29.0",
"@urql/exchange-retry": "^1.0.0",
"axios": "^1.6.0",
"body-parser": "^1.15.2",
"croner": "^6.0.7",
"discord.js": "^14.16.3",
Expand Down Expand Up @@ -67,7 +66,7 @@
"@graphql-codegen/typescript-resolvers": "^2.7.6",
"@graphql-codegen/typescript-urql": "^3.7.3",
"@graphql-codegen/urql-introspection": "^2.2.1",
"@types/node": "20.1.2",
"@types/node": "^22.19.15",
"@types/node-cron": "^3.0.8",
"@types/node-localstorage": "^1.3.3",
"@types/phoenix": "^1.6.6",
Expand Down
87 changes: 63 additions & 24 deletions src/Classes/APIBots/ApiPollBot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,28 @@ import {
getOSName,
} from './utils'

import axios, { AxiosRequestConfig, AxiosResponse } from 'axios'
import { logger } from '../../logger'

interface FetchConfig {
headers?: Record<string, string>
timeout?: number
validateStatus?: (status: number) => boolean
}

interface ApiResponse<T = unknown> {
status: number
headers: Headers
data: T
}

class HttpStatusError extends Error {
response: { status: number; statusText: string; headers: Headers }
constructor(status: number, statusText: string, headers: Headers) {
super(`Request failed with status code ${status}`)
this.name = 'HttpStatusError'
this.response = { status, statusText, headers }
}
}
/** Abstract parent class for all API Poll Bots */
export class APIPollBot {
apiEndpoint: string
Expand Down Expand Up @@ -187,19 +207,39 @@ export class APIPollBot {
*/
protected async getWithRetry(
url: string,
config: AxiosRequestConfig,
config: FetchConfig,
retries = 3,
initialDelayMs = 1000
): Promise<AxiosResponse> {
): Promise<ApiResponse> {
let attempt = 0
let lastError: unknown
while (attempt <= retries) {
const controller = new AbortController()
const timeoutId = config.timeout
? setTimeout(() => controller.abort(), config.timeout)
: undefined
try {
const response = await axios.get(url, config)
const response = await fetch(url, {
headers: config.headers,
signal: controller.signal,
})
if (timeoutId !== undefined) clearTimeout(timeoutId)

// Handle 429 returned as a non-throwing response (validateStatus)
const validateStatus =
config.validateStatus ?? ((s) => s >= 200 && s < 300)
if (!validateStatus(response.status)) {
throw new HttpStatusError(
response.status,
response.statusText,
response.headers
)
}

// Handle 429 returned as a non-throwing response (validateStatus allows it)
if (response.status === 429) {
if (attempt === retries) return response
if (attempt === retries) {
return { status: response.status, headers: response.headers, data: null }
}
const retryAfter = this.parseRetryAfter(response.headers)
const delay =
retryAfter ?? Math.min(initialDelayMs * Math.pow(2, attempt), 30000)
Expand All @@ -212,24 +252,19 @@ export class APIPollBot {
continue
}

return response
const data = await response.json()
return { status: response.status, headers: response.headers, data }
} catch (err: unknown) {
if (timeoutId !== undefined) clearTimeout(timeoutId)
lastError = err
const axiosErr = err as {
code?: string
response?: { status?: number; headers?: Record<string, string> }
message?: string
}
const code = axiosErr?.code || axiosErr?.response?.status
const isTimeout =
axiosErr?.code === 'ECONNABORTED' ||
/timeout/i.test(axiosErr?.message || '')
const isReset = axiosErr?.code === 'ECONNRESET'
const status = axiosErr?.response?.status
const isTimeout = err instanceof Error && err.name === 'AbortError'
const isNetworkError = err instanceof TypeError
const httpErr = err instanceof HttpStatusError ? err : null
const status = httpErr?.response?.status
const isRateLimited = status === 429
const shouldRetry =
isTimeout ||
isReset ||
isNetworkError ||
isRateLimited ||
(typeof status === 'number' && status >= 500 && status < 600)

Expand All @@ -240,7 +275,7 @@ export class APIPollBot {
let delay: number
if (isRateLimited) {
// Use Retry-After header if available, otherwise longer backoff for 429s
const retryAfter = this.parseRetryAfter(axiosErr?.response?.headers)
const retryAfter = this.parseRetryAfter(httpErr?.response?.headers)
delay =
retryAfter ??
Math.min(initialDelayMs * Math.pow(2, attempt + 1), 30000)
Expand All @@ -249,8 +284,12 @@ export class APIPollBot {
}
const jitter = Math.floor(delay * 0.25 * (Math.random() * 2 - 1))
const sleepMs = Math.max(250, delay + jitter)
const errCode =
status ??
(isTimeout ? 'TIMEOUT' : isNetworkError ? 'NETWORK_ERROR' : 'UNKNOWN')
const errMessage = err instanceof Error ? err.message : 'unknown'
logger.warn(
{ attempt: attempt + 1, retries, url, code: code || status, errMessage: axiosErr?.message || 'unknown', sleepMs },
{ attempt: attempt + 1, retries, url, code: errCode, errMessage, sleepMs },
'GET retry after error'
)
await new Promise((res) => setTimeout(res, sleepMs))
Expand All @@ -264,9 +303,9 @@ export class APIPollBot {
* Parse the Retry-After header value into milliseconds
* Supports both seconds (integer) and HTTP-date formats
*/
private parseRetryAfter(headers?: Record<string, unknown>): number | null {
const retryAfter = headers?.['retry-after']
if (!retryAfter || typeof retryAfter !== 'string') return null
private parseRetryAfter(headers?: Headers): number | null {
const retryAfter = headers?.get('retry-after')
if (!retryAfter) return null

const seconds = parseInt(retryAfter, 10)
if (!isNaN(seconds)) {
Expand Down
6 changes: 3 additions & 3 deletions src/Classes/APIBots/OpenSeaListBot.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import axios from 'axios'
import { Client, ColorResolvable, EmbedBuilder } from 'discord.js'
import {
BAN_ADDRESSES,
Expand Down Expand Up @@ -109,8 +108,9 @@ export class OpenSeaListBot {
try {
// Get Art Blocks metadata response for the item (same as ReservoirListBot)
const tokenApiUrl = getTokenApiUrl(nftInfo.chainId, contractAddress, tokenId)
const artBlocksResponse = await axios.get(tokenApiUrl)
const artBlocksData = artBlocksResponse?.data as ArtBlocksTokenData
const artBlocksData = (await fetch(tokenApiUrl).then((r) =>
r.json()
)) as ArtBlocksTokenData
const tokenUrl = getTokenUrl(
artBlocksData.external_url ?? '',
nftInfo.chainId,
Expand Down
6 changes: 3 additions & 3 deletions src/Classes/APIBots/OpenSeaSaleBot.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import axios from 'axios'
import { Client, EmbedBuilder } from 'discord.js'
import { formatEther } from 'viem'
import {
Expand Down Expand Up @@ -185,8 +184,9 @@ export class OpenSeaSaleBot {
try {
// Get Art Blocks metadata response for the item (same as ReservoirSaleBot)
const tokenApiUrl = getTokenApiUrl(sale.chainId, contractAddress, tokenId)
const artBlocksResponse = await axios.get(tokenApiUrl)
const artBlocksData = artBlocksResponse?.data as ArtBlocksTokenData
const artBlocksData = (await fetch(tokenApiUrl).then((r) =>
r.json()
)) as ArtBlocksTokenData

const tokenUrl = getTokenUrl(
artBlocksData.external_url ?? '',
Expand Down
27 changes: 15 additions & 12 deletions src/Classes/APIBots/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,10 @@ import {
STUDIO_CONTRACTS,
} from '../../index'
import { CollectionType } from '../MintBot'
import { AxiosError } from 'axios'
import { createPublicClient, http, fallback } from 'viem'
import { mainnet } from 'viem/chains'
dotenv.config()

import axios from 'axios'
import { logger } from '../../logger'

// Configure viem with multiple RPC providers for reliability
Expand Down Expand Up @@ -163,16 +161,16 @@ export async function getOSName(address: string): Promise<string> {
name = cached.name
} else {
try {
const response = await axios.get(
const res = await fetch(
`https://api.opensea.io/api/v2/accounts/${address}`,
{
headers: {
Accept: 'application/json',
'x-api-key': process.env.OPENSEA_API_KEY,
'x-api-key': process.env.OPENSEA_API_KEY ?? '',
},
}
)
const responseBody = response?.data
const responseBody = await res.json() as { detail?: string; username?: string }
if (responseBody?.detail) {
throw new Error(responseBody.detail)
}
Expand Down Expand Up @@ -388,17 +386,22 @@ export async function replaceVideoWithGIF(url: string) {

// some GIFs are not available, so we fallback to PNG
try {
const resp = await axios.get(gifURL)
const resp = await fetch(gifURL)

if (resp.headers['content-length'] === '0') {
if (!resp.ok) {
if (resp.status === 404) {
logger.info('GIF not found, returning PNG')
} else {
logger.info({ status: resp.status, gifURL }, 'Error on fetching token API for GIF')
}
return url.replace('mp4', 'png')
}

if (resp.headers.get('content-length') === '0') {
throw new Error('GIF size 0 for ' + gifURL)
}
} catch (e: unknown) {
if (e instanceof AxiosError && e.response?.status === 404) {
logger.info('GIF not found, returning PNG')
} else {
logger.info({ err: e, gifURL }, 'Error on fetching token API for GIF')
}
logger.info({ err: e, gifURL }, 'Error on fetching token API for GIF')
return url.replace('mp4', 'png')
}

Expand Down
22 changes: 15 additions & 7 deletions src/Classes/MintBot.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { Client, EmbedBuilder, TextChannel } from 'discord.js'
import { artIndexerBot, mintBot, projectConfig } from '../index'
import axios from 'axios'
import {
MINT_UTM,
buildArtBlocksTokenURL,
Expand Down Expand Up @@ -128,13 +127,22 @@ export class MintBot {

// Check image with timeout and content-type validation
try {
const imageRes = await axios.get(assetUrl, {
timeout: 10000, // 10 second timeout
validateStatus: (status) => status === 200,
headers: { Accept: 'image/*' },
})
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 10000)
let imageRes: Response
try {
imageRes = await fetch(assetUrl, {
headers: { Accept: 'image/*' },
signal: controller.signal,
})
} finally {
clearTimeout(timeoutId)
}
if (imageRes.status !== 200) {
throw new Error(`Unexpected status ${imageRes.status}`)
}

const contentType = imageRes.headers['content-type']
const contentType = imageRes.headers.get('content-type')
if (!contentType?.startsWith('image/')) {
logger.info({ id, contentType }, 'Invalid content type for mint')
return
Expand Down
14 changes: 6 additions & 8 deletions src/Classes/ProjectBot.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import axios from 'axios'
import {
Channel,
Collection,
Expand All @@ -7,6 +6,7 @@ import {
TextChannel,
} from 'discord.js'
import {
ArtBlocksTokenData,
PROJECTBOT_BUY_UTM,
PROJECTBOT_EXPLORE_UTM,
PROJECTBOT_UTM,
Expand Down Expand Up @@ -362,14 +362,13 @@ export class ProjectBot {
try {
logger.info({ projectName: this.projectName }, 'sending birthday message')

const artBlocksResponse = await axios.get(
const artBlocksData = await fetch(
getTokenApiUrl(
this.chainId,
this.coreContract,
`${this.projectNumber * ONE_MILLION}`
)
)
const artBlocksData = await artBlocksResponse.data
).then((r) => r.json()) as ArtBlocksTokenData
let assetUrl = artBlocksData?.preview_asset_url
if (
!artBlocksData ||
Expand Down Expand Up @@ -420,14 +419,13 @@ export class ProjectBot {
CHANNEL_BLOCK_TALK
) as TextChannel

const artBlocksResponse = await axios.get(
const artBlocksData = await fetch(
getTokenApiUrl(
this.chainId,
this.coreContract,
`${this.projectNumber * ONE_MILLION}`
)
)
const artBlocksData = await artBlocksResponse.data
).then((r) => r.json()) as ArtBlocksTokenData
const assetUrl = artBlocksData?.preview_asset_url

// Send congratulations message
Expand All @@ -438,7 +436,7 @@ export class ProjectBot {
const embedContent = new EmbedBuilder()
.setColor('#9370DB')
.setTitle(title)
.setImage(assetUrl)
.setImage(assetUrl ?? null)
.setDescription(description)
if (blockTalk) {
blockTalk.send({ embeds: [embedContent] })
Expand Down
17 changes: 7 additions & 10 deletions src/Classes/TriviaBot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,7 @@ import { ProjectBot } from './ProjectBot'
import { Client, EmbedBuilder, Message, TextChannel } from 'discord.js'
import { projectConfig } from '..'
import { randomColor } from '../Utils/smartBotResponse'
import axios from 'axios'
import { getTokenApiUrl, replaceVideoWithGIF } from './APIBots/utils'
import { ArtBlocksTokenData, getTokenApiUrl, replaceVideoWithGIF } from './APIBots/utils'
import { getAllTriviaScores, updateTriviaScore } from '../Data/supabase'
import { logger } from '../logger'

Expand Down Expand Up @@ -181,11 +180,10 @@ Next question:`
Math.floor(Math.random() * project.editionSize) +
project.projectNumber * 1e6

const artBlocksResponse = await axios.get(
const artBlocksData = await fetch(
getTokenApiUrl(project.chainId, project.coreContract, `${tokenNumber}`)
)
const artBlocksData = artBlocksResponse.data
const assetUrl = await replaceVideoWithGIF(artBlocksData.preview_asset_url)
).then((r) => r.json()) as ArtBlocksTokenData
const assetUrl = await replaceVideoWithGIF(artBlocksData.preview_asset_url ?? '')

embed.setDescription(question)
embed.setImage(assetUrl)
Expand All @@ -202,11 +200,10 @@ Next question:`
Math.floor(Math.random() * project.editionSize) +
project.projectNumber * 1e6

const artBlocksResponse = await axios.get(
const artBlocksData = await fetch(
getTokenApiUrl(project.chainId, project.coreContract, `${tokenNumber}`)
)
const artBlocksData = artBlocksResponse.data
const assetUrl = await replaceVideoWithGIF(artBlocksData.preview_asset_url)
).then((r) => r.json()) as ArtBlocksTokenData
const assetUrl = await replaceVideoWithGIF(artBlocksData.preview_asset_url ?? '')

embed.setDescription(question)
embed.setImage(assetUrl)
Expand Down
Loading
Loading