├── cogs ├── __init__.py └── satellites.py ├── models ├── __init__.py └── opensea.py ├── utils ├── __init__.py └── logging.py ├── requirements.txt ├── assets └── nft-satellite.png ├── satellite.py ├── README.md └── .gitignore /cogs/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /models/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chillerno1/discord-nft-satellite/HEAD/requirements.txt -------------------------------------------------------------------------------- /assets/nft-satellite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chillerno1/discord-nft-satellite/HEAD/assets/nft-satellite.png -------------------------------------------------------------------------------- /models/opensea.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | 4 | @dataclass 5 | class FloorPrice: 6 | source: str 7 | project: str 8 | price: str 9 | -------------------------------------------------------------------------------- /satellite.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | import logging 3 | import argparse 4 | 5 | from discord.ext import commands 6 | from utils.logging import setup_logging 7 | 8 | from cogs.satellites import NFT 9 | 10 | log = logging.getLogger(__name__) 11 | 12 | 13 | if __name__ == '__main__': 14 | 15 | parser = argparse.ArgumentParser(description="Bot to display the NFT floor price of a collection on OpenSea.") 16 | parser.add_argument('--discord-token', 17 | '-t', 18 | type=str, 19 | required=True, 20 | help="The token for this Discord bot.") 21 | parser.add_argument('--alias', 22 | '-a', 23 | type=str, 24 | required=True, 25 | help="Alias for collection to display in the Discord activity (i.e. BAYC).") 26 | parser.add_argument('--url', 27 | '-u', 28 | type=str, 29 | required=True, 30 | help="OpenSea API URL of any collection.") 31 | 32 | args = parser.parse_args() 33 | 34 | satellite = commands.Bot(command_prefix=str(uuid.uuid4())) 35 | satellite.add_cog(NFT(bot=satellite, alias=args.alias, url=args.url)) 36 | 37 | with setup_logging(): 38 | satellite.run(args.discord_token) 39 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Discord NFT Satellite 2 | 3 | Simple script to run a Discord bot that displays the floor price of an NFT collection on OpenSea. 4 | 5 | ![](assets/nft-satellite.png) 6 | 7 | ## Requirements 8 | - [Discord Bot & Token](https://www.writebots.com/discord-bot-token/) 9 | - Python >= 3.8 10 | 11 | ## Permissions 12 | Invite the bot into your Discord server and ensure it has a role with the following permissions: 13 | - View Channels 14 | - Change Nickname 15 | 16 | ## Setup 17 | 18 | 1. Clone the repository: `git clone https://github.com/chillerno1/discord-nft-satellite` 19 | 2. cd into directory: `cd discord-nft-satellite` 20 | 3. Setup a virtual environment: `python -m venv venv` 21 | 4. Activate the virtual environment: `venv\Scripts\activate` 22 | 5. Install dependencies: `pip install -r requirements.txt` 23 | 24 | ## Usage 25 | 26 | 1. Get the slug of a collection you wish to display (**slug** is a human readable identifier that is used to identify a collection. It can be extracted from the URL: https://opensea.io/collection/{slug}) 27 | 2. Append the slug to this URL to get the collection API endpoint: https://api.opensea.io/collection/{slug} (example: https://api.opensea.io/collection/boredapeyachtclub) 28 | 3. Run the following command: 29 | 30 | ```shell 31 | usage: satellite.py [-h] --discord-token DISCORD_TOKEN --alias ALIAS --url URL 32 | 33 | Bot to display the NFT floor price of a collection on OpenSea. 34 | -------------------------------------------------------------- 35 | 36 | required arguments: 37 | -t, --discord-token token for this Discord bot. 38 | -a, --alias alias for the NFT collection to display (shown in Discord activity). 39 | -u, --url opensea api endpoint of the collection to display. 40 | 41 | optional arguments: 42 | -h, --help show this help message and exit 43 | ``` 44 | 45 | ### Example 46 | ``` 47 | python .\satellite.py -t AAAAAAAA.aaa.AAAA -a BAYC -u https://api.opensea.io/collection/boredapeyachtclub 48 | ``` 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 98 | __pypackages__/ 99 | 100 | # Celery stuff 101 | celerybeat-schedule 102 | celerybeat.pid 103 | 104 | # SageMath parsed files 105 | *.sage.py 106 | 107 | # Environments 108 | .env 109 | .venv 110 | env/ 111 | venv/ 112 | ENV/ 113 | env.bak/ 114 | venv.bak/ 115 | 116 | # Spyder project settings 117 | .spyderproject 118 | .spyproject 119 | 120 | # Rope project settings 121 | .ropeproject 122 | 123 | # mkdocs documentation 124 | /site 125 | 126 | # mypy 127 | .mypy_cache/ 128 | .dmypy.json 129 | dmypy.json 130 | 131 | # Pyre type checker 132 | .pyre/ 133 | 134 | # pytype static type analyzer 135 | .pytype/ 136 | 137 | # Cython debug symbols 138 | cython_debug/ -------------------------------------------------------------------------------- /utils/logging.py: -------------------------------------------------------------------------------- 1 | import os 2 | import logging 3 | import contextlib 4 | 5 | from rich.logging import RichHandler 6 | from logging.handlers import RotatingFileHandler 7 | 8 | ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 9 | LOGS_PATH = 'logs/' 10 | 11 | 12 | class RemoveNoise(logging.Filter): 13 | 14 | def __init__(self): 15 | super().__init__(name='discord.state') 16 | 17 | def filter(self, record): 18 | if record.levelname == 'WARNING' and 'referencing an unknown' in record.msg: 19 | return False 20 | return True 21 | 22 | 23 | class RemoveRateLimit(logging.Filter): 24 | 25 | def __init__(self): 26 | super().__init__(name='discord.http') 27 | 28 | def filter(self, record): 29 | if record.levelname == 'WARNING' and 'We are being rate limited.' in record.msg: 30 | return False 31 | return True 32 | 33 | 34 | def ensure_logs_path_exists(): 35 | 36 | """Create a log file directory if it doesn't already exist.""" 37 | 38 | if not os.path.exists(LOGS_PATH): 39 | os.makedirs(os.path.join(ROOT_DIR, LOGS_PATH)) 40 | 41 | 42 | @contextlib.contextmanager 43 | def setup_logging(): 44 | 45 | """Logging handler for monitoring errors and warnings.""" 46 | 47 | ensure_logs_path_exists() 48 | 49 | try: 50 | # __enter__ 51 | logging.getLogger('discord').setLevel(logging.INFO) 52 | logging.getLogger('discord.http').setLevel(logging.WARNING) 53 | logging.getLogger('discord.state').addFilter(RemoveNoise()) 54 | logging.getLogger('discord.http').addFilter(RemoveRateLimit()) 55 | 56 | message_format = "[%(asctime)s] [%(levelname)s] %(message)s" 57 | max_bytes = 32 * 1024 * 1024 # 32mb 58 | format_handler = RichHandler(rich_tracebacks=True, 59 | show_time=True, 60 | show_level=True) 61 | file_handler = RotatingFileHandler(filename='logs/session.log', 62 | encoding='utf-8', 63 | mode='w', 64 | maxBytes=max_bytes, 65 | backupCount=5) 66 | 67 | logging.basicConfig( 68 | level="INFO", 69 | format=message_format, 70 | datefmt="%Y-%m-%d %H:%M:%S", 71 | handlers=[ 72 | format_handler, 73 | file_handler 74 | ], 75 | ) 76 | log = logging.getLogger() 77 | 78 | yield 79 | 80 | finally: 81 | # __exit__ 82 | handlers = log.handlers[:] 83 | for hdlr in handlers: 84 | hdlr.close() 85 | log.removeHandler(hdlr) -------------------------------------------------------------------------------- /cogs/satellites.py: -------------------------------------------------------------------------------- 1 | import aiohttp 2 | import asyncio 3 | import logging 4 | 5 | from typing import Union 6 | from models.opensea import FloorPrice 7 | from discord.ext import commands, tasks 8 | from discord import Activity, ActivityType 9 | 10 | 11 | log = logging.getLogger(__name__) 12 | 13 | 14 | async def get_opensea_floor_price(url: str, alias: str) -> Union[FloorPrice, bool]: 15 | 16 | """Queries the API endpoint of an NFT collection on OpenSea and parses the response to retrieve the floor price. 17 | 18 | .. note: 19 | API endpoint of a collection, e.g. https://api.opensea.io/collection/boredapeyachtclub 20 | .. todo: 21 | Check the base currency of the floor price returned is always in ETH. 22 | 23 | :param url: str 24 | URL of the collection to call. 25 | :param alias: str 26 | Name of the collection, i.e. BAYC. 27 | :returns: 28 | NFTPrice object if successful, otherwise False. 29 | """ 30 | 31 | source = "OpenSea" 32 | 33 | try: 34 | async with aiohttp.ClientSession() as session: 35 | async with session.get(url) as response: 36 | content = await response.json(content_type=None) 37 | if content: 38 | price = content['collection']['stats']['floor_price'] 39 | return FloorPrice(source=source, price=f"{price} ETH", project=alias) 40 | except Exception as error: 41 | log.error(f"[ENDPOINT] [{source}] API response was invalid {error}.") 42 | return False 43 | log.error(f"[ENDPOINT] [{source}] API request did not return the expected response: {content}.") 44 | return False 45 | 46 | 47 | class NFT(commands.Cog): 48 | 49 | """Discord satellite that displays OpenSea NFT floor prices as a user in Discord servers. 50 | ___________________________________ 51 | | | 52 | | 35.5 ETH | 53 | | Watching BAYC floor price on .. | 54 | |__________________________________| 55 | """ 56 | 57 | def __init__(self, 58 | bot: commands.Bot, 59 | alias: str, 60 | url: str): 61 | 62 | self.bot = bot 63 | self.bot_type = self.__class__.__name__ 64 | 65 | self.alias = alias 66 | self.url = url 67 | 68 | self.price = "" 69 | self.status = "" 70 | self.member_of_guilds = None 71 | 72 | self.updater.start() 73 | 74 | async def update_interface_elements(self, guild_id: int) -> None: 75 | 76 | """Updates the nickname for this bot within a given discord server (guild_id). 77 | 78 | :param guild_id: 79 | `guild_id` to perform update. 80 | """ 81 | 82 | guild = self.bot.get_guild(guild_id) 83 | bot_instance = guild.me 84 | 85 | await bot_instance.edit(nick=self.price) 86 | 87 | log.info(f"[UPDATE] [{self.bot_type}] - [{guild}] - [{self.alias}] Nickname: {self.price} Activity: {self.status}") 88 | 89 | async def update_nft_floor_price(self) -> bool: 90 | 91 | """Fetches latest NFT price from sources to update the price and status attributes. 92 | 93 | :returns: bool 94 | True if successful else False 95 | """ 96 | 97 | nft_floor_price = await get_opensea_floor_price(url=self.url, alias=self.alias) 98 | 99 | if nft_floor_price: 100 | self.price = f"{nft_floor_price.price}" 101 | self.status = f"{nft_floor_price.project} floor price on {nft_floor_price.source}" 102 | 103 | return True 104 | return False 105 | 106 | @tasks.loop(minutes=1) 107 | async def updater(self): 108 | 109 | """Task to fetch new data and update the bots nickname and activity in Discord. 110 | 111 | .. note: 112 | 113 | Discord nicknames and roles are local to the server, whereas an activity is universal across all servers. 114 | 115 | `nickname` is updated by creating an self.update_interface_elements(guild_id) task for all servers this bot 116 | is a member of. 117 | UI: '35.5 ETH' 118 | `activity` is updated with self.bot.change_presence() 119 | UI: 'Watching BAYC floor price on OpenSea' 120 | """ 121 | 122 | self.member_of_guilds = [guild.id for guild in self.bot.guilds] 123 | valid_response = await self.update_nft_floor_price() 124 | 125 | if valid_response: 126 | await self.bot.change_presence(activity=Activity(type=ActivityType.watching, name=self.status)) 127 | update_jobs = [self.update_interface_elements(guild_id) for guild_id in self.member_of_guilds] 128 | await asyncio.gather(*update_jobs) 129 | 130 | @updater.before_loop 131 | async def before_update(self): 132 | 133 | """Ensure this bot is initialized before updating it's nickname or status.""" 134 | 135 | await self.bot.wait_until_ready() 136 | self.member_of_guilds = [guild.id for guild in self.bot.guilds] 137 | 138 | log.info(f"[INIT] [{self.bot_type}] - [{self.alias}] Bot started successfully, active on {len(self.member_of_guilds)} servers.") 139 | 140 | def __str__(self): 141 | return f"Discord NFT satellite for {self.alias}" 142 | 143 | def __repr__(self): 144 | return f"" 145 | --------------------------------------------------------------------------------