├── LastSaleDatetime
├── Procfile
├── requirements.txt
├── .env
├── README.md
├── config.py
├── address.py
└── main.py
/LastSaleDatetime:
--------------------------------------------------------------------------------
1 | 0
2 |
--------------------------------------------------------------------------------
/Procfile:
--------------------------------------------------------------------------------
1 | web: python main.py
2 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | python-dotenv==0.20.0
2 | pytz==2021.3
3 | requests==2.27.1
4 | schedule==1.1.0
5 | telepot==12.7
6 |
--------------------------------------------------------------------------------
/.env:
--------------------------------------------------------------------------------
1 | COLLECTION_ADDRESS="EQAo92DYMokxghKcq-CkCGSk_MgXY5Fo1SPW20gkvZl75iCN"
2 | ROYALTY_ADDRESS="EQABh4JBalyRKN42tZB1jevT3BheWqHYjkhSv3zoHldqqRJs"
3 | BOT_TOKEN=""
4 | TONCENTER_API=""
5 | CHATS="-1001536482073;-1001627286419"
6 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # TON NFT Resales Notificator
2 | :gem: Bot notifying about resales of any TON NFT collection
3 |
4 | ## How to run bot
5 |
6 | ### .env variables
7 |
8 | You need to specify these env variables to run this bot. If you run it locally, you can also write them in `.env` text file.
9 |
10 | ``` bash
11 | CHATS= # Chats to send resell notifications to (separated by semicolons) // Example: "-1001536482073;-1001627286419"
12 | BOT_TOKEN= # Telegram bot API token from @BotFather // Example: "1234567890:ABCDEFGHIJKLMNOPQRSTUVWXYZ"
13 | ROYALTY_ADDRESS= # TON address to which royalties from resales are accrued // Example: "EQABh4JBalyRKN42tZB1jevT3BheWqHYjkhSv3zoHldqqRJs"
14 | COLLECTION_ADDRESS= # TON address of NFT collection // Example: "EQAo92DYMokxghKcq-CkCGSk_MgXY5Fo1SPW20gkvZl75iCN"
15 | TONCENTER_API_KEY= # toncenter.com API key from @tonapibot
16 |
17 | ```
18 |
19 | ### Run bot locally
20 |
21 | First, you need to install all dependencies:
22 |
23 | ```bash
24 | pip install -r requirements.txt
25 | ```
26 |
27 | Then you can run the bot. Don't forget to fill in the `.env` file in the root folder with all required params (read above).
28 |
29 | ``` bash
30 | python main.py
31 | ```
32 |
--------------------------------------------------------------------------------
/config.py:
--------------------------------------------------------------------------------
1 | import os
2 | import pathlib
3 |
4 | import telepot
5 | from dotenv import load_dotenv
6 | from address import *
7 |
8 | load_dotenv()
9 |
10 | TONCENTER_API = os.getenv('TONCENTER_API')
11 | BOT_TOKEN = os.getenv('BOT_TOKEN')
12 | ROYALTY_ADDRESS = detect_address(os.getenv('ROYALTY_ADDRESS'))['bounceable']['b64url']
13 | COLLECTION_ADDRESS = os.getenv('COLLECTION_ADDRESS')
14 | chats = list(map(int, os.getenv('CHATS').split(';')))
15 |
16 | bot = telepot.Bot(BOT_TOKEN)
17 |
18 | current_path = pathlib.Path(__file__).parent.resolve()
19 |
20 | disintar = {
21 | 'headers': {
22 | 'cookie': 'csrftoken=vO44viQbEDlbcKiLz56aaE8tLmjzc5XtVqUTZo6zgItxRNehF7VRGlCVdQR3dul9',
23 | 'x-csrftoken': 'vO44viQbEDlbcKiLz56aaE8tLmjzc5XtVqUTZo6zgItxRNehF7VRGlCVdQR3dul9',
24 | 'referer': 'https://beta.disintar.io/object/',
25 | 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; rv:91.0) Gecko/20100101 Firefox/91.0'},
26 | 'get_floor': {'entity_name': 'Collection',
27 | 'order_by': '["creation_date"]',
28 | 'filter_by': '[{"name":"address","value":' +
29 | detect_address(COLLECTION_ADDRESS)['bounceable']['b64url'] + '}]',
30 | 'limit': 'null',
31 | 'group_by': 'null',
32 | 'page': '0', 'request_time': 'undefined'}
33 | }
34 |
35 | getgems_query = "query nftSearch($count: Int!, $cursor: String, $query: String, $sort: String) {\n alphaNftItemSearch(first: $count, after: $cursor, query: $query, sort: $sort) {\n edges {\n node {\n ...nftPreview\n __typename\n }\n cursor\n __typename\n }\n info {\n hasNextPage\n __typename\n }\n __typename\n }\n}\n\nfragment nftPreview on NftItem {\n name\n previewImage: content {\n ... on NftContentImage {\n image {\n sized(width: 500, height: 500)\n __typename\n }\n __typename\n }\n ... on NftContentLottie {\n lottie\n fallbackImage: image {\n sized(width: 500, height: 500)\n __typename\n }\n __typename\n }\n __typename\n }\n address\n collection {\n name\n address\n __typename\n }\n sale {\n ... on NftSaleFixPrice {\n fullPrice\n __typename\n }\n __typename\n }\n __typename\n}"
36 |
37 | getgems_data = {'operationName': 'nftSearch',
38 | 'query': getgems_query,
39 | 'variables': {
40 | 'count': 30,
41 | 'query': '{"$and":[{"collectionAddress":"' + detect_address(COLLECTION_ADDRESS)['bounceable']['b64url'] + '"}]}',
42 | 'sort': '[{"isOnSale":{"order":"desc"}},{"price":{"order":"asc"}},{"index":{"order":"asc"}}]'
43 | }}
44 |
45 | main_params = {'address': ROYALTY_ADDRESS, 'limit': '100', 'to_lt': '0',
46 | 'archival': 'false', 'api_key': TONCENTER_API}
47 |
48 | second_params = {'limit': '15', 'to_lt': '0', 'archival': 'false',
49 | 'api_key': TONCENTER_API}
50 |
51 | marketplaces = {'EQDrLq-X6jKZNHAScgghh0h1iog3StK71zn8dcmrOj8jPWRA': 'Disintar',
52 | 'EQCjk1hh952vWaE9bRguFkAhDAL5jj3xj9p0uPWrFBq_GEMS': 'GetGems'}
53 |
--------------------------------------------------------------------------------
/address.py:
--------------------------------------------------------------------------------
1 | import base64
2 |
3 | """
4 | https://github.com/toncenter/pytonlib/blob/e2093be2ccf03a0dc9dd424df999c5b9f77beac8/pytonlib/utils/address.py
5 | """
6 |
7 | bounceable_tag, non_bounceable_tag = b'\x11', b'\x51'
8 | b64_abc = set('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890+/')
9 | b64_abc_urlsafe = set('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890_-')
10 |
11 |
12 | def is_int(x):
13 | try:
14 | int(x)
15 | return True
16 | except:
17 | return False
18 |
19 |
20 | def is_hex(x):
21 | try:
22 | int(x, 16)
23 | return True
24 | except:
25 | return False
26 |
27 |
28 | def calcCRC(message):
29 | poly = 0x1021
30 | reg = 0
31 | message += b'\x00\x00'
32 | for byte in message:
33 | mask = 0x80
34 | while(mask > 0):
35 | reg <<= 1
36 | if byte & mask:
37 | reg += 1
38 | mask >>= 1
39 | if reg > 0xffff:
40 | reg &= 0xffff
41 | reg ^= poly
42 | return reg.to_bytes(2, "big")
43 |
44 |
45 | def account_forms(raw_form, test_only=False):
46 | workchain, address = raw_form.split(":")
47 | workchain, address = int(workchain), int(address, 16)
48 | address = address.to_bytes(32, "big")
49 | workchain_tag = b'\xff' if workchain == -1 else workchain.to_bytes(1, "big")
50 | btag = bounceable_tag
51 | nbtag = non_bounceable_tag
52 | # if test_only:
53 | # btag = (btag[0] | 0x80).to_bytes(1,'big')
54 | # nbtag = (nbtag[0] | 0x80).to_bytes(1,'big')
55 | preaddr_b = btag + workchain_tag + address
56 | preaddr_u = nbtag + workchain_tag + address
57 | b64_b = base64.b64encode(preaddr_b+calcCRC(preaddr_b)).decode('utf8')
58 | b64_u = base64.b64encode(preaddr_u+calcCRC(preaddr_u)).decode('utf8')
59 | b64_b_us = base64.urlsafe_b64encode(preaddr_b+calcCRC(preaddr_b)).decode('utf8')
60 | b64_u_us = base64.urlsafe_b64encode(preaddr_u+calcCRC(preaddr_u)).decode('utf8')
61 | return {'raw_form': raw_form,
62 | 'bounceable': {'b64': b64_b, 'b64url': b64_b_us},
63 | 'non_bounceable': {'b64': b64_u, 'b64url': b64_u_us},
64 | 'given_type': 'raw_form',
65 | 'test_only': test_only}
66 |
67 |
68 | def read_friendly_address(address):
69 | urlsafe = False
70 | if set(address).issubset(b64_abc):
71 | address_bytes = base64.b64decode(address.encode('utf8'))
72 | elif set(address).issubset(b64_abc_urlsafe):
73 | urlsafe = True
74 | address_bytes = base64.urlsafe_b64decode(address.encode('utf8'))
75 | else:
76 | raise Exception("Not an address")
77 | if not calcCRC(address_bytes[:-2]) == address_bytes[-2:]:
78 | raise Exception("Wrong checksum")
79 | tag = address_bytes[0]
80 | if tag & 0x80:
81 | test_only = True
82 | tag = tag ^ 0x80
83 | else:
84 | test_only = False
85 | tag = tag.to_bytes(1, 'big')
86 | if tag == bounceable_tag:
87 | bounceable = True
88 | elif tag == non_bounceable_tag:
89 | bounceable = False
90 | else:
91 | raise Exception("Unknown tag")
92 | if address_bytes[1:2] == b'\xff':
93 | workchain = -1
94 | else:
95 | workchain = address_bytes[1]
96 | hx = hex(int.from_bytes(address_bytes[2:-2], "big"))[2:]
97 | hx = (64-len(hx))*"0"+hx
98 | raw_form = str(workchain)+":"+hx
99 | account = account_forms(raw_form, test_only)
100 | account['given_type'] = "friendly_"+("bounceable" if bounceable else "non_bounceable")
101 | return account
102 |
103 |
104 | def detect_address(unknown_form):
105 | if is_hex(unknown_form):
106 | return account_forms("-1:"+unknown_form)
107 | elif (":" in unknown_form) and is_int(unknown_form.split(":")[0]) and is_hex(unknown_form.split(":")[1]):
108 | return account_forms(unknown_form)
109 | else:
110 | return read_friendly_address(unknown_form)
111 |
112 |
113 | def prepare_address(unknown_form):
114 | address = detect_address(unknown_form)
115 | if 'non_bounceable' in address['given_type']:
116 | return address["non_bounceable"]["b64"]
117 | return address["bounceable"]["b64"]
118 |
--------------------------------------------------------------------------------
/main.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | import requests
3 | import time
4 | import json
5 | from datetime import datetime
6 | import pytz
7 | from config import *
8 | import schedule
9 |
10 |
11 | def main():
12 | old_utime = open(f'{current_path}/LastSaleDatetime', 'r').read()
13 | try:
14 | last_utime = int(open(f'{current_path}/LastSaleDatetime', 'r').read())
15 | success = False
16 | while not success:
17 | try:
18 | response = json.loads(requests.get(
19 | "https://toncenter.com/api/v2/getTransactions", params=main_params).text)
20 | response = response['result']
21 | success = True
22 | except:
23 | print('Get Request Failed')
24 | for transaction in response[::-1]:
25 | if transaction['utime'] <= last_utime:
26 | continue
27 | transaction = transaction['in_msg']
28 | if transaction['@type'] == 'raw.message' and transaction['message'] == '':
29 | address = transaction['source']
30 | second_params['address'] = address
31 | transactions = json.loads(requests.get(
32 | "https://toncenter.com/api/v2/getTransactions", params=second_params).text)
33 | prices, timings = [], []
34 | isnft = False
35 | market = 'Unknown Marketplace'
36 | for i in transactions['result'][::-1]:
37 | try:
38 | for out in i['out_msgs']:
39 | try:
40 | market = marketplaces[out['destination']]
41 | break
42 | except:
43 | pass
44 | except:
45 | pass
46 | msg = i['in_msg']
47 | time.sleep(1)
48 | try:
49 | nft = json.loads(
50 | requests.get(f'https://tonapi.io/v1/nft/getItem?account={msg["source"]}').text)
51 | if nft['collection_address'] == detect_address(COLLECTION_ADDRESS)['raw_form']:
52 | data = nft['metadata']
53 | name = data['name']
54 | image = data['image']
55 | nft_address = msg['source']
56 | isnft = True
57 | except:
58 | if i['utime'] <= last_utime:
59 | continue
60 | else:
61 | open(f'{current_path}/LastSaleDatetime', 'w').write(str(i['utime']))
62 | price = int(msg['value'])
63 | if price >= 1000000000:
64 | prices += [price / (10 ** 9)]
65 | timings += [str(datetime.fromtimestamp(i['utime'], tz=pytz.utc).strftime('%d.%m.%Y %H:%M:%S'))]
66 | if isnft:
67 | print(f'New deal on {market}: {name}')
68 | price = round(float(max(prices)), 3)
69 | emoji = '💎'
70 | floor = {'disintar': [10**20, ''],
71 | 'getgems': [10**20, '']}
72 | try:
73 | getgems_floor = json.loads(requests.post('https://api.getgems.io/graphql', json=getgems_data).text)['data']['alphaNftItemSearch']['edges']
74 | number = 0
75 | error = True
76 | while error:
77 | try:
78 | gg_floor_num = getgems_floor[number]['node']
79 | floor['getgems'] = [round(float(gg_floor_num['sale']['fullPrice']) / (10 ** 9), 3),
80 | f"https://getgems.io/collection/{detect_address(COLLECTION_ADDRESS)['bounceable']['b64url']}/{gg_floor_num['address']}"]
81 | error = False
82 | except:
83 | pass
84 | number += 1
85 | except:
86 | print('Get GetGems Floor Failed')
87 | try:
88 | disintar_floor = json.loads(requests.post('https://beta.disintar.io/api/get_entities/',
89 | headers=disintar['headers'],
90 | data=disintar['get_floor']).text)['data'][0]
91 | floor['disintar'] = [round(float(disintar_floor['price']), 3),
92 | f"https://beta.disintar.io/object/{disintar_floor['address']}"]
93 | except:
94 | print('Get Disintar Floor Failed')
95 | try:
96 | floor_market = 'disintar' if floor['disintar'][0] < floor['getgems'][0] else 'getgems'
97 | floor_price, floor_link = floor[floor_market]
98 | floor_text = f'🔽 Current floor: {floor_price} TON'
99 | if price <= floor_price * 1.1:
100 | emoji = '🍣'
101 | except:
102 | floor_text = ''
103 | print('Get Floor Failed')
104 | buy_time = timings[prices.index(max(prices))]
105 | try:
106 | dollars = json.loads(requests.get('https://ru.ton.org/getpriceg/').text)['the-open-network']['usd']
107 | dollars = str(round(float(price) * float(dollars), 2))
108 | dollars_text = f'({dollars}$)'
109 | except:
110 | dollars_text = ''
111 | print('Get Dollars Failed')
112 | message_text = f'👾 {name}\n\n' \
113 | f'' \
114 | f'{emoji} #Purchased for {price} TON {dollars_text} on {market}\n\n' \
115 | '' \
116 | f'⌚ Date and time of buy: {buy_time} UTC\n' \
117 | f'{floor_text}'
118 | for chat in chats:
119 | try:
120 | bot.sendPhoto(chat, photo=image, caption=message_text, parse_mode='HTML')
121 | except Exception as e:
122 | print(f'Photo Send ({chat}) Failed: {e}')
123 | try:
124 | bot.sendMessage(chat, message_text, parse_mode='HTML', disable_web_page_preview=True)
125 | except Exception as e:
126 | print(f'Message Send ({chat}) Failed: {e}')
127 | except Exception as e:
128 | print(e)
129 |
130 |
131 | schedule.every(15).seconds.do(main)
132 |
133 | while True:
134 | schedule.run_pending()
135 | time.sleep(1)
136 |
--------------------------------------------------------------------------------