-
Notifications
You must be signed in to change notification settings - Fork 0
/
main.ts
162 lines (147 loc) · 7.22 KB
/
main.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
//@ts-nocheck
const { Telegraf } = require('telegraf');
const Markup = require("telegraf/markup");
const axios = require('axios');
// Telegram bot token
const BOT_TOKEN = process.env.BOT_TOKEN || "TELEGRAM_BOT_TOKEN";
// Tensor API key
const API_KEY = process.env.API_KEY || "TENSOR_API_KEY";
// Marketplace base URL with "/" at the end, so concatenated with mint address it will redirect to the single NFT buy page
const BASE_URL = process.env.BASE_URL || "https://www.tensor.trade/item/";
// Amount of listings to fetch
const NUM_OF_LISTINGS = process.env.NUM_OF_LISTINGS || 10;
// Slug of collection to fetch FP for (default: Tensorians)
const SLUG = process.env.SLUG || "05c52d84-2e49-4ed9-a473-b43cab41e777";
// initialize bot with bot token
const bot = new Telegraf(BOT_TOKEN);
// The following vars are currently global, might make sense to not requery each time a user starts the bot/refreshes
// so a local DB with periodical refetches or (even better) websocket subscriptions to keep listings up-to-date are
// wayyyy better options, but that's up to you to play around with and optimize!!
var mintsWithIndices = [];
var royaltiesEnabledPerUser = {};
// on /start or /Start => set royaltiesEnabled for the current user if not given, fetch tensor API and send reply
bot.command("start", ctx => {
const userId = ctx.from.id;
royaltiesEnabledPerUser[userId] = royaltiesEnabledPerUser[userId] || false;
fetchAndReply(ctx)
});
bot.command("Start", ctx => {
const userId = ctx.from.id;
royaltiesEnabledPerUser[userId] = royaltiesEnabledPerUser[userId] || false;
fetchAndReply(ctx)
});
// when a user clicks on "Return to Main Menu" Button on single listing views, refetch and reply with Menu
bot.action('back_to_start', ctx => fetchAndReply(ctx));
// when a user selects a listing via index (parsed via RegEx), fetch current mint by index and send single NFT view as reply
bot.action(/select_(\d+)/, async (ctx) => {
try {
const index = ctx.match[1];
const currentMint = mintsWithIndices.find(mint => mint.index === parseInt(index) + 1);
await sendSingleNFTReply(ctx, currentMint);
} catch (error) {
console.error('Error in command handler:', error);
ctx.reply('Try to restart the bot with /start')
}
})
// toggles royalties for the user on/off
bot.action("toggleRoyalties", ctx => {
royaltiesEnabledPerUser[ctx.from.id] = !royaltiesEnabledPerUser[ctx.from.id];
fetchAndReply(ctx);
});
// start the bot
bot.launch({
"dropPendingUpdates": true
}).then(() => {
console.log('Bot started');
});
// fetch floor listings via tensor API
async function fetchFloorListings(ctx) {
try {
// slug: tensorians slug || sortBy: ListingPriceAsc || limit: NUM_OF_LISTINGS ( default 10 ) || onlyListings: True
const URL = `https://api.mainnet.tensordev.io/api/v1/mint/collection?slug=${SLUG}&sortBy=ListingPriceAsc&limit=${NUM_OF_LISTINGS}&onlyListings=true`;
const response = await axios.get(
URL,
{
"headers": {
"x-tensor-api-key": API_KEY
}
}
);
return response.data;
} catch (error) {
console.error('Error when trying to fetch tensor API: ', error);
ctx.reply('An error occurred. Please try again later.');
}
}
// reply with the info of the currentMint object that got returned from the API
async function sendSingleNFTReply(ctx, currentMint) {
// if royalties are enabled, adjust price and define end of caption accordingly
const royaltiesEnabled = royaltiesEnabledPerUser[ctx.from.id];
const price = getAdjustedPrice(currentMint, royaltiesEnabled);
const royaltyInfoMessage = royaltiesEnabled ? "incl. royalties/fees" : "excl. royalties/fees";
// send reply with image, caption and buttons
ctx.replyWithPhoto({
// reply with image with the imageUri from API return
url: currentMint.imageUri
}, {
caption: `<b><u>${currentMint.index}. lowest listing: </u></b> \n${currentMint.name} listed at ${price} S◎L ${royaltyInfoMessage}.`,
parse_mode: "HTML",
reply_markup: {
inline_keyboard: [
[
// indices are off by one, so "select_0" would select the mint with index 1 (1st lowest listing)
{ text: "←", callback_data: `select_${currentMint.index - 2 < 0 ? NUM_OF_LISTINGS - 1 : (currentMint.index - 2)}` },
// clicking on BUY will directly redirect to the marketplace given by the BASE_URL concatenated with the mint address
{ text: "BUY", url: `${BASE_URL}${currentMint.mint}` },
// next mint is mod NUM_OF_LISTINGS, so if current mint index is 10, go to "select_0" (1st lowest listing - off by one)
{ text: "→", callback_data: `select_${(currentMint.index % NUM_OF_LISTINGS)}` },
],
[
{ text: "Back to Main Menu", callback_data: "back_to_start" }
]
]
}
}
)
}
// fetches floor listings from tensor API and constructs reply (overview) for the bot to send
async function fetchAndReply(ctx) {
try {
// check if user enabled royalties and define royalty button thusly
const royaltiesEnabled = royaltiesEnabledPerUser[ctx.from.id];
var royaltyToggle;
if (royaltiesEnabled) {
royaltyToggle = Markup.button.callback("Royalties/Marketplace Fees: ON ✅", "toggleRoyalties");
} else {
royaltyToggle = Markup.button.callback("Royalties/Marketplace Fees: OFF ❌", "toggleRoyalties");
}
// fetch floor listings from API
const data = await fetchFloorListings(ctx);
const mints = data.mints;
// maps mints to corresponding index and stores it globally ( improvable ;) )
mintsWithIndices = mints.map((mint, index) => ({ ...mint, index: index + 1 }));
// constructs a button for each mint
const buttons = mintsWithIndices.map(mint => Markup.button.callback(mint.index, `select_${mint.index - 1}`));
// defines "back to menu" button
const backToMenuButton = Markup.button.callback("Refresh", "back_to_start");
// initialize keyboard (all buttons) with the mint buttons first...
const keyboard = Markup.inlineKeyboard(buttons, { columns: 5 });
// ... and add "back to menu" button and royalty button in the next rows
keyboard.reply_markup.inline_keyboard = [...keyboard.reply_markup.inline_keyboard, [backToMenuButton], [royaltyToggle]];
// potentially adjusts prices if royalties enabled and maps from lamports => SOL with 3 fixed digits
const prices = mintsWithIndices.map(mint => getAdjustedPrice(mint, royaltiesEnabled));
// construct message
const message = mintsWithIndices.map((mint, i) => `${mint.index}. ${mint.name} is listed for ${prices[i]} S◎L.`).join('\n');
// reply with message and keyboard (buttons)
ctx.reply(message, keyboard);
} catch (error) {
ctx.reply('An error occurred. Please try again later.');
}
}
// helper function that potentially adjusts prices if royalties enabled and maps from lamports => SOL with 3 fixed digits
// if royalties are enabled: multiply price by fixed platform fee (1.5%) and royalties for the specific NFT via royaltyBps
// 1 BPS == 0.01%
function getAdjustedPrice(currentMint, royaltiesEnabled) {
const potentiallyAdjustedPrice = royaltiesEnabled ? Number(currentMint.listing.price) * (1 + 0.015 + (currentMint.royaltyBps / 10_000)) : Number(currentMint.listing.price);
return (potentiallyAdjustedPrice / 1_000_000_000).toFixed(3);
}