Skip to content

Commit a8b49f0

Browse files
Merge pull request #714 from ArtBlocks/twitter-sales-bot
getting basic twitter sales bot hooked up
2 parents 0017787 + 515242f commit a8b49f0

File tree

4 files changed

+220
-33
lines changed

4 files changed

+220
-33
lines changed

src/Classes/APIBots/ReservoirSaleBot.ts

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
} from '../../Utils/activityTriager'
77
import { CollectionType } from '../MintBot'
88
import { APIPollBot } from './ApiPollBot'
9+
import { TwitterBot } from '../TwitterBot'
910
import {
1011
getTokenApiUrl,
1112
isExplorationsContract,
@@ -48,24 +49,25 @@ type ReservoirSaleResponse = {
4849

4950
/** API Poller for Reservoir Sale events */
5051
export class ReservoirSaleBot extends APIPollBot {
51-
contract: string
5252
saleIds: Set<string>
53+
twitterBot?: TwitterBot
5354
/** Constructor just calls super
5455
* @param {string} apiEndpoint - Endpoint to be hitting
5556
* @param {number} refreshRateMs - How often to poll the endpoint (in ms)
5657
* @param {*} bot - Discord bot that will be sending messages
58+
* @param {TwitterBot} twitterBot - Optional TwitterBot instance for posting sale tweets
5759
*/
5860
constructor(
5961
apiEndpoint: string,
6062
refreshRateMs: number,
6163
bot: Client,
6264
headers: any,
63-
contract = ''
65+
twitterBot?: TwitterBot
6466
) {
6567
apiEndpoint =
6668
apiEndpoint + '&startTimestamp=' + (Date.now() / 1000).toFixed()
6769
super(apiEndpoint, refreshRateMs, bot, headers)
68-
this.contract = contract
70+
this.twitterBot = twitterBot
6971
this.lastUpdatedTime = Math.floor(this.lastUpdatedTime / 1000)
7072
this.saleIds = new Set()
7173
}
@@ -269,6 +271,55 @@ export class ReservoirSaleBot extends APIPollBot {
269271
await getCollectionType(sale.token.contract)
270272
)
271273
}
274+
275+
// Post to Twitter if TwitterBot is available and sale meets criteria
276+
if (this.twitterBot && this.shouldTweetSale(sale, artBlocksData)) {
277+
try {
278+
await this.twitterBot.tweetSale({
279+
tokenName: artBlocksData.name,
280+
projectName: artBlocksData.collection_name,
281+
artist: artBlocksData.artist,
282+
salePrice: price,
283+
currency: currency,
284+
buyer: sale.to,
285+
seller: sale.from,
286+
assetUrl: assetUrl || artBlocksData.preview_asset_url,
287+
tokenUrl: tokenUrl,
288+
platform: platform
289+
.replace('<:lilsquig:1028047420636020786>', '')
290+
.trim(),
291+
})
292+
} catch (error) {
293+
console.error('Error posting sale to Twitter:', error)
294+
}
295+
}
296+
}
297+
298+
/**
299+
* Determines whether a sale should be posted to Twitter
300+
* Currently filters out low-value sales and certain platforms
301+
* Can be customized to add more filtering criteria
302+
*/
303+
private shouldTweetSale(sale: ReservoirSale, _artBlocksData: any): boolean {
304+
const price = sale.price.amount.decimal
305+
const currency = sale.price.currency.symbol
306+
307+
// Filter out very low-value sales (less than 0.1 ETH)
308+
if (currency === 'ETH' && price < 0.1) {
309+
return false
310+
}
311+
312+
// Filter out mint events (though these should already be filtered out)
313+
if (sale.orderKind === 'mint') {
314+
return false
315+
}
316+
317+
// Could add more filters here, for example:
318+
// - Only certain collections
319+
// - Only sales above certain thresholds
320+
// - Only certain platforms
321+
322+
return true
272323
}
273324

274325
async buildSweepDiscordMessage(sales: ReservoirSale[]) {

src/Classes/MintBot.ts

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -42,22 +42,11 @@ export class MintBot {
4242
mintsToPost: { [id: string]: Mint } = {}
4343
contractToChannel: { [id: string]: string[] } = {}
4444
contractToTwitterBot: { [id: string]: TwitterBot } = {}
45-
constructor(bot: Client) {
45+
constructor(bot: Client, abTwitterBot?: TwitterBot) {
4646
this.bot = bot
47+
this.abTwitterBot = abTwitterBot
4748
this.buildContractToChannel()
4849
this.startRoutine()
49-
50-
if (process.env.PRODUCTION_MODE) {
51-
if (process.env.AB_TWITTER_API_KEY) {
52-
this.abTwitterBot = new TwitterBot({
53-
appKey: process.env.AB_TWITTER_API_KEY ?? '',
54-
appSecret: process.env.AB_TWITTER_API_SECRET ?? '',
55-
accessToken: process.env.AB_TWITTER_OAUTH_TOKEN ?? '',
56-
accessSecret: process.env.AB_TWITTER_OAUTH_SECRET ?? '',
57-
listener: true,
58-
})
59-
}
60-
}
6150
}
6251

6352
async buildContractToChannel() {
@@ -230,7 +219,7 @@ export class MintBot {
230219
// this.abTwitterBot?.sendToTwitter(mint)
231220
}
232221
if (this.contractToTwitterBot[mint.contractAddress]) {
233-
this.contractToTwitterBot[mint.contractAddress].sendToTwitter(mint)
222+
this.contractToTwitterBot[mint.contractAddress].tweetMint(mint)
234223
}
235224
}
236225

src/Classes/TwitterBot.ts

Lines changed: 152 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,11 @@ import {
2424
updateStatusRefreshToken,
2525
} from '../Data/supabase'
2626

27+
// Twitter Bot Feature Toggles - Modify these to enable/disable functionality
28+
const TWITTER_LISTENING_ENABLED = false // Enables listening and responding to tweet mentions
29+
const TWITTER_SALE_POSTING_ENABLED = false // Enables posting sale tweets
30+
const TWITTER_MINT_POSTING_ENABLED = false // Enables posting mint tweets
31+
2732
const TWITTER_MEDIA_BYTE_LIMIT = 5242880
2833
// Search rate limit is 60 queries per 15 minutes - shortest interval is 15 secs (though we should keep it a bit longer)
2934
const SEARCH_INTERVAL_MS = 20000
@@ -36,36 +41,59 @@ const prod = process.env.ARTBOT_IS_PROD
3641
const ARTBOT_TWITTER_HANDLE = 'artbotartbot'
3742
const STATUS_TWITTER_HANDLE = 'ArtbotStatus'
3843

44+
/**
45+
* TwitterBot handles Twitter API interactions for posting tweets and listening/responding to mentions
46+
*
47+
* Feature Toggles (constants at top of file):
48+
* - TWITTER_LISTENING_ENABLED: Controls whether the bot listens for and responds to tweets (default: false)
49+
* - TWITTER_SALE_POSTING_ENABLED: Controls whether the bot posts sale tweets (default: false)
50+
* - TWITTER_MINT_POSTING_ENABLED: Controls whether the bot posts mint tweets (default: false)
51+
*
52+
* The bot can be used for posting functionality (sales, mints, etc.) even when listener is disabled
53+
*/
3954
export class TwitterBot {
40-
twitterClient: TwitterApi
55+
twitterClient?: TwitterApi
4156
twitterStatusAccount?: TwitterApi
42-
lastTweetId: string
57+
lastTweetId?: string
4358
intervalId?: NodeJS.Timeout
4459

4560
constructor({
4661
appKey,
4762
appSecret,
4863
accessToken,
4964
accessSecret,
50-
listener,
5165
}: {
5266
appKey: string
5367
appSecret: string
5468
accessToken: string
5569
accessSecret: string
56-
listener?: boolean
5770
}) {
5871
this.lastTweetId = ''
59-
if (listener && process.env.TWITTER_ENABLED === 'true') {
60-
console.log('Starting Twitter listener')
61-
this.startSearchAndReplyRoutine()
72+
73+
if (!appKey || !appSecret || !accessToken || !accessSecret) {
74+
console.warn(
75+
'Twitter credentials are missing - not initializing TwitterBot'
76+
)
77+
return
6278
}
79+
80+
// Initialize Twitter client for posting capabilities
6381
this.twitterClient = new TwitterApi({
6482
appKey,
6583
appSecret,
6684
accessToken,
6785
accessSecret,
6886
})
87+
88+
// Only start listener if enabled via constant
89+
if (TWITTER_LISTENING_ENABLED) {
90+
console.log('Starting Twitter listener')
91+
this.startSearchAndReplyRoutine()
92+
} else {
93+
console.log(
94+
'Twitter listener disabled via TWITTER_LISTENING_ENABLED constant'
95+
)
96+
}
6997
}
7098

7199
async startSearchAndReplyRoutine() {
@@ -101,7 +129,7 @@ export class TwitterBot {
101129

102130
const query = `(to:${ARTBOT_TWITTER_HANDLE} OR @${ARTBOT_TWITTER_HANDLE}) -is:retweet -has:links has:mentions -from:${STATUS_TWITTER_HANDLE} -from:${ARTBOT_TWITTER_HANDLE}`
103131
const devQuery = `to:ArtbotTesting from:ArtbotTesting`
104-
artbotTweets = await this.twitterClient.v2.search({
132+
artbotTweets = await this.twitterClient?.v2.search({
105133
query: prod ? query : devQuery,
106134
since_id: this.lastTweetId,
107135
})
@@ -150,6 +178,7 @@ export class TwitterBot {
150178
}
151179
this.updateLastTweetId(artbotTweets.meta.newest_id)
152180
}
181+
153182
async updateLastTweetId(tweetId: string) {
154183
if (tweetId === this.lastTweetId) {
155184
return
@@ -221,7 +250,7 @@ export class TwitterBot {
221250
console.log(`Replying to ${tweet.id} with ${artBlocksData.name}`)
222251
for (let i = 0; i < NUM_RETRIES; i++) {
223252
try {
224-
await this.twitterClient.v2.reply(tweetMessage, tweet.id, {
253+
await this.twitterClient?.v2.reply(tweetMessage, tweet.id, {
225254
media: {
226255
media_ids: [media_id],
227256
},
@@ -280,9 +309,12 @@ export class TwitterBot {
280309
console.log('Uploading media to twitter...', assetUrl)
281310
for (let i = 0; i < NUM_RETRIES; i++) {
282311
try {
283-
const mediaId = await this.twitterClient.v1.uploadMedia(buff, {
312+
const mediaId = await this.twitterClient?.v1.uploadMedia(buff, {
284313
mimeType: this.getMimeType(assetUrl),
285314
})
315+
if (!mediaId) {
316+
throw new Error('No media id returned')
317+
}
286318
return mediaId
287319
} catch (err) {
288320
console.log(`Error uploading ${assetUrl}:`, err)
@@ -303,7 +335,15 @@ export class TwitterBot {
303335
}
304336
}
305337

306-
async tweetArtblock(artBlock: Mint) {
338+
async _tweetMint(artBlock: Mint) {
339+
// Check if Twitter mint posting is enabled
340+
if (!TWITTER_MINT_POSTING_ENABLED) {
341+
console.log(
342+
'Twitter mint posting disabled via TWITTER_MINT_POSTING_ENABLED constant'
343+
)
344+
return
345+
}
346+
307347
const assetUrl = artBlock.image
308348
if (!artBlock.image) {
309349
console.error('No artblock image defined', JSON.stringify(artBlock))
@@ -324,7 +364,7 @@ export class TwitterBot {
324364
}. \n\n${artBlock.artblocksUrl + TWITTER_PROJECTBOT_UTM}`
325365
console.log(`Tweeting ${tweetText}`)
326366

327-
const tweetRes = await this.twitterClient.v2.tweet(tweetText, {
367+
const tweetRes = await this.twitterClient?.v2.tweet(tweetText, {
328368
text: tweetText,
329369
media: { media_ids: [mediaId] },
330370
})
@@ -334,9 +374,9 @@ export class TwitterBot {
334374
}
335375
}
336376

337-
async sendToTwitter(mint: Mint) {
377+
async tweetMint(mint: Mint) {
338378
try {
339-
await this.tweetArtblock(mint)
379+
await this._tweetMint(mint)
340380
} catch (e) {
341381
console.error('Error posting to Twitter: ', e)
342382
}
@@ -361,6 +401,14 @@ export class TwitterBot {
361401
}
362402

363403
async sendStatusMessage(message: string, replyId?: string) {
404+
// Check if Twitter listening is enabled (status messages are mainly for user interactions)
405+
if (!TWITTER_LISTENING_ENABLED) {
406+
console.log(
407+
'Twitter status messaging disabled via TWITTER_LISTENING_ENABLED constant'
408+
)
409+
return
410+
}
411+
364412
if (!this.twitterStatusAccount) {
365413
await this.signIntoStatusAccount()
366414
}
@@ -373,4 +421,94 @@ export class TwitterBot {
373421
}
374422
await this.twitterStatusAccount?.v2.tweet(message)
375423
}
424+
425+
async tweetSale(saleData: {
426+
tokenName: string
427+
projectName: string
428+
artist: string
429+
salePrice: number
430+
currency: string
431+
buyer: string
432+
seller: string
433+
assetUrl: string
434+
tokenUrl: string
435+
platform?: string
436+
}) {
437+
// Check if Twitter sale posting is enabled
438+
if (!TWITTER_SALE_POSTING_ENABLED) {
439+
console.log(
440+
'Twitter sale posting disabled via TWITTER_SALE_POSTING_ENABLED constant'
441+
)
442+
return
443+
}
444+
445+
try {
446+
console.log(`Tweeting sale for ${saleData.tokenName}`)
447+
448+
// Upload the token image to Twitter
449+
let media_id: string
450+
try {
451+
media_id = await this.uploadMedia(saleData.assetUrl)
452+
} catch (error) {
453+
console.error(`Error uploading media for sale tweet:`, error)
454+
return
455+
}
456+
457+
// Get ENS names for buyer and seller
458+
const buyerText = await ensOrAddress(saleData.buyer)
459+
const sellerText = await ensOrAddress(saleData.seller)
460+
461+
// Format the platform text if provided
462+
const platformText = saleData.platform ? ` on ${saleData.platform}` : ''
463+
464+
// Construct the tweet message
465+
const tweetMessage = `🔥 SALE: ${saleData.tokenName} by ${saleData.artist}
466+
467+
💰 ${saleData.salePrice} ${saleData.currency}${platformText}
468+
👤 Sold by ${sellerText}
469+
🛒 Bought by ${buyerText}
470+
471+
${saleData.tokenUrl + TWITTER_PROJECTBOT_UTM}`
472+
473+
console.log(`Posting sale tweet: ${saleData.tokenName}`)
474+
475+
// Post the tweet with retry logic
476+
for (let i = 0; i < NUM_RETRIES; i++) {
477+
try {
478+
await this.twitterClient?.v2.tweet(tweetMessage, {
479+
media: {
480+
media_ids: [media_id],
481+
},
482+
})
483+
console.log(`Successfully tweeted sale for ${saleData.tokenName}`)
484+
return
485+
} catch (error) {
486+
if (error instanceof ApiResponseError && error.rateLimit) {
487+
console.log(
488+
`Rate limit hit on sale tweet: \nLimit: ${error.rateLimit.limit}\nRemaining: ${error.rateLimit.remaining}\nReset: ${error.rateLimit.reset}`
489+
)
490+
const reset = new Date(error.rateLimit.reset * 1000)
491+
const now = new Date()
492+
const diff = reset.getTime() - now.getTime()
493+
const diffMinutes = Math.ceil(diff / 60000)
494+
try {
495+
await this.sendStatusMessage(
496+
`Sale tweet rate limited :( Will retry in ${diffMinutes} minutes`
497+
)
498+
} catch (e) {
499+
console.error('Error sending rate limit status message:', e)
500+
}
501+
return
502+
} else {
503+
console.log(`Error posting sale tweet:`, error)
504+
console.log(`Retrying (attempt ${i + 1} of ${NUM_RETRIES})...`)
505+
await delay(RETRY_DELAY_MS)
506+
}
507+
}
508+
}
509+
console.error('Failed to post sale tweet after all retry attempts')
510+
} catch (error) {
511+
console.error('Error in tweetSale:', error)
512+
}
513+
}
376514
}

0 commit comments

Comments
 (0)