Skip to content

Commit e9ff267

Browse files
Merge pull request #723 from ArtBlocks/opensea-api
using opensea api for lists/sales
2 parents 5c59727 + 1c30e49 commit e9ff267

File tree

6 files changed

+828
-81
lines changed

6 files changed

+828
-81
lines changed

package.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
"@graphql-codegen/typescript-urql": "^3.7.3",
3737
"@graphql-codegen/urql-introspection": "^2.2.1",
3838
"@graphql-tools/utils": "^8.3.0",
39+
"@opensea/stream-js": "^0.2.1",
3940
"@reservoir0x/reservoir-sdk": "^2.4.32",
4041
"@supabase/supabase-js": "^2.29.0",
4142
"@types/node": "20.1.2",
@@ -64,6 +65,7 @@
6465
"ms": "^2.0.0",
6566
"node-fetch": "^2.6.1",
6667
"node-html-parser": "^3.2.0",
68+
"node-localstorage": "^3.0.5",
6769
"openai": "^4.73.0",
6870
"prettier": "^2.6.2",
6971
"react": "18.2.0",
@@ -76,6 +78,10 @@
7678
"urql": "^3.0.3",
7779
"viem": "2.17.4",
7880
"web3": "^1.7.0",
79-
"ws": "^8.17.1"
81+
"ws": "^8.18.3"
82+
},
83+
"devDependencies": {
84+
"@types/node-localstorage": "^1.3.3",
85+
"@types/phoenix": "^1.6.6"
8086
}
8187
}
Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
import axios from 'axios'
2+
import { Client, ColorResolvable, EmbedBuilder } from 'discord.js'
3+
import {
4+
BAN_ADDRESSES,
5+
sendEmbedToListChannels,
6+
} from '../../Utils/activityTriager'
7+
import {
8+
LISTING_UTM,
9+
ensOrAddress,
10+
getCollectionType,
11+
getTokenApiUrl,
12+
getTokenUrl,
13+
replaceVideoWithGIF,
14+
isEngineContract,
15+
isExplorationsContract,
16+
} from './utils'
17+
import { ItemListedEvent } from '@opensea/stream-js'
18+
19+
const IDENTICAL_TOLERANCE = 0.0001
20+
const LISTING_TTL_MS = 24 * 60 * 60 * 1000 // 24 hours
21+
22+
/**
23+
* Bot for handling OpenSea stream listing events
24+
* Similar to ReservoirListBot but for OpenSea stream data
25+
*/
26+
export class OpenSeaListBot {
27+
private bot: Client
28+
private recentListings: {
29+
[key: string]: { price: number; timestamp: number }
30+
} = {}
31+
private listColor = '#407FDB'
32+
33+
constructor(bot: Client) {
34+
this.bot = bot
35+
36+
// Setup cleanup interval for old listings
37+
setInterval(() => {
38+
this.cleanupOldListings()
39+
}, 60 * 60 * 1000) // Clean up every hour
40+
}
41+
42+
/**
43+
* Cleanup listings older than TTL
44+
*/
45+
private cleanupOldListings() {
46+
const now = Date.now()
47+
Object.entries(this.recentListings).forEach(([key, value]) => {
48+
if (now - value.timestamp > LISTING_TTL_MS) {
49+
delete this.recentListings[key]
50+
}
51+
})
52+
}
53+
54+
/**
55+
* Main handler for OpenSea listing events
56+
* @param event - OpenSea stream event data
57+
*/
58+
async handleListingEvent(event: ItemListedEvent) {
59+
try {
60+
await this.buildDiscordMessage(event.payload)
61+
} catch (err) {
62+
console.error('Error processing OpenSea listing event:', err)
63+
}
64+
}
65+
66+
/**
67+
* Parses OpenSea NFT ID and extracts contract address and token ID
68+
* @param nftId - Format: "ethereum/0x2308742aa28cc460522ff855d24a365f99deba7b/7111"
69+
* @returns {contractAddress, tokenId} or null if invalid format
70+
*/
71+
private parseNftId(
72+
nftId: string
73+
): { contractAddress: string; tokenId: string } | null {
74+
const parts = nftId.split('/')
75+
if (parts.length !== 3) {
76+
console.warn('Invalid NFT ID format:', nftId)
77+
return null
78+
}
79+
80+
if (parts[0] !== 'ethereum') {
81+
console.warn('Invalid NFT ID format:', nftId)
82+
return null
83+
}
84+
85+
return {
86+
contractAddress: parts[1].toLowerCase(),
87+
tokenId: parts[2],
88+
}
89+
}
90+
91+
/**
92+
* Builds and sends Discord embed message for OpenSea listing
93+
* @param payload - OpenSea stream payload
94+
*/
95+
async buildDiscordMessage(payload: ItemListedEvent['payload']) {
96+
const embed = new EmbedBuilder()
97+
98+
// Parse the NFT ID to get contract and token info
99+
const nftInfo = this.parseNftId(payload.item.nft_id)
100+
if (!nftInfo) {
101+
console.warn('Could not parse NFT ID:', payload.item.nft_id)
102+
return
103+
}
104+
105+
const { contractAddress, tokenId } = nftInfo
106+
const priceText = 'List Price'
107+
const price = parseFloat(
108+
parseFloat(payload.payment_token.eth_price).toFixed(4)
109+
)
110+
const currency = payload.payment_token.symbol
111+
const owner = payload.maker.address
112+
const platform = 'OpenSea'
113+
114+
// Check for duplicate listings (same token, similar price)
115+
if (
116+
this.recentListings[tokenId] &&
117+
Math.abs(this.recentListings[tokenId].price - price) <=
118+
IDENTICAL_TOLERANCE
119+
) {
120+
console.log(`Skipping identical OpenSea relisting for ${tokenId}`)
121+
return
122+
}
123+
this.recentListings[tokenId] = { price, timestamp: Date.now() }
124+
125+
// Apply colors and platform styling
126+
embed.setColor(this.listColor as ColorResolvable) // Default to general listing color
127+
128+
// Skip banned addresses
129+
if (BAN_ADDRESSES.has(owner)) {
130+
console.log(`Skipping OpenSea listing from banned address: ${owner}`)
131+
return
132+
}
133+
134+
try {
135+
// Get Art Blocks metadata response for the item (same as ReservoirListBot)
136+
const tokenApiUrl = getTokenApiUrl(contractAddress, tokenId)
137+
const artBlocksResponse = await axios.get(tokenApiUrl)
138+
const artBlocksData = artBlocksResponse?.data
139+
const tokenUrl = getTokenUrl(
140+
artBlocksData.external_url,
141+
contractAddress,
142+
tokenId
143+
)
144+
145+
const sellerText = await ensOrAddress(owner)
146+
const baseABProfile = 'https://www.artblocks.io/user/'
147+
const sellerProfile = baseABProfile + owner + LISTING_UTM
148+
149+
embed.addFields(
150+
{
151+
name: `Seller (${platform})`,
152+
value: `[${sellerText}](${sellerProfile})`,
153+
},
154+
{
155+
name: priceText,
156+
value: `${price} ${currency}`,
157+
inline: true,
158+
}
159+
)
160+
161+
let curationStatus = artBlocksData?.curation_status
162+
? artBlocksData.curation_status[0].toUpperCase() +
163+
artBlocksData.curation_status.slice(1).toLowerCase()
164+
: ''
165+
166+
let title = `${artBlocksData.name} - ${artBlocksData.artist}`
167+
168+
if (artBlocksData?.platform?.includes('Art Blocks x Pace')) {
169+
curationStatus = 'AB x Pace'
170+
} else if (artBlocksData?.platform === 'Art Blocks × Bright Moments') {
171+
curationStatus = 'AB x Bright Moments'
172+
} else if (isExplorationsContract(contractAddress)) {
173+
curationStatus = 'Explorations'
174+
} else if (isEngineContract(contractAddress)) {
175+
curationStatus = 'Engine'
176+
if (artBlocksData?.platform) {
177+
title = `${artBlocksData.platform} - ${title}`
178+
}
179+
}
180+
181+
// Update thumbnail image to use larger variant from Art Blocks API.
182+
let assetUrl = artBlocksData?.preview_asset_url
183+
if (assetUrl && !assetUrl.includes('undefined')) {
184+
assetUrl = await replaceVideoWithGIF(assetUrl)
185+
embed.setThumbnail(assetUrl)
186+
}
187+
188+
// Add fields
189+
const fields = []
190+
if (curationStatus && curationStatus.trim()) {
191+
fields.push({
192+
name: 'Collection',
193+
value: curationStatus,
194+
inline: true,
195+
})
196+
}
197+
198+
fields.push({
199+
name: 'Live Script',
200+
value: `[view on artblocks.io](${tokenUrl + LISTING_UTM})`,
201+
inline: true,
202+
})
203+
204+
embed.addFields(...fields)
205+
206+
const platformUrl = payload.item.permalink
207+
208+
embed.setTitle(title)
209+
if (platformUrl) {
210+
embed.setURL(platformUrl + LISTING_UTM)
211+
}
212+
if (artBlocksData.collection_name) {
213+
console.log(artBlocksData.name + ' OpenSea LIST')
214+
sendEmbedToListChannels(
215+
this.bot,
216+
embed,
217+
artBlocksData,
218+
await getCollectionType(contractAddress)
219+
)
220+
}
221+
} catch (error) {
222+
console.error('Error building OpenSea listing message:', error)
223+
}
224+
}
225+
226+
/**
227+
* Cleanup method to be called when the bot is being destroyed
228+
*/
229+
cleanup() {
230+
this.recentListings = {}
231+
}
232+
}

0 commit comments

Comments
 (0)