From 123e0599db8a630a58b70e4fed88e17f5090b4e4 Mon Sep 17 00:00:00 2001 From: Uncle Stretch Date: Mon, 6 Apr 2026 17:48:36 +0300 Subject: [PATCH] initial bot notification with <3 Signed-off-by: Uncle Stretch --- .env.template | 3 + .gitignore | 6 ++ README.md | 41 ++++++++++ main.py | 201 +++++++++++++++++++++++++++++++++++++++++++++++ messages.json | 50 ++++++++++++ networks.json | 110 ++++++++++++++++++++++++++ requirements.txt | 44 +++++++++++ rpcs/hoodi.txt | 4 + rpcs/mordor.txt | 3 + rpcs/sepolia.txt | 5 ++ 10 files changed, 467 insertions(+) create mode 100644 .env.template create mode 100644 .gitignore create mode 100644 README.md create mode 100644 main.py create mode 100644 messages.json create mode 100644 networks.json create mode 100644 requirements.txt create mode 100644 rpcs/hoodi.txt create mode 100644 rpcs/mordor.txt create mode 100644 rpcs/sepolia.txt diff --git a/.env.template b/.env.template new file mode 100644 index 0000000..bcb77ef --- /dev/null +++ b/.env.template @@ -0,0 +1,3 @@ +TELEGRAM_TOKEN= +NOTIFY_CHAT_ID= +THREAD_ID= \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bba94e6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.venv/ +.env + +*.py[cod] +*$py.class +__pycache__/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..bc15fdb --- /dev/null +++ b/README.md @@ -0,0 +1,41 @@ +# Ghost Notifier Bot + +A Telegram bot that monitors onchain events on +[ghostDAO](https://app.dao.ghostchain.io/) and sends real-time +notifications to a Telegram group using `aiogram`. + +## Features + +- Listens to specified onchain events (e.g., swaps, bond purchases, proposals creation) +- Sends instant notifications to a configured Telegram group +- Built with `aiogram` for async Telegram bot functionality +- Usage of public RPC endpoints from [chainlist](https://chainlist.org/) + +## Networks + +Each network has unique name, explorer link and event details to +listen on: + +- Event name that will be used for messages only +- Address is address of smart contract to listen on +- Event topic is `keccak256` hash of the event aka `topic[0]` + +For more info go to `networks.json`. + +## Messages + +All possible messages mapped to the event name. It is using predefined +structure of message: + +- Header is bold text on top of the message +- Body is main text of the message +- Link is text of the link (in out case is the link to transaction hash) +- Footer is text in the end of the message +- Button text is text of the inline button +- Button URL is the template link for the inline button + +## RPCs + +Becase we are aiming to use public RPC endpoints we need to have list +of enpoints in `rpcs/NETWORK_NAME.txt` where network name is described +inside `networks.json`. \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..dd26aa8 --- /dev/null +++ b/main.py @@ -0,0 +1,201 @@ +import os +import json +import asyncio +import logging + +from dotenv import load_dotenv +from web3 import Web3 +from aiogram import types, Bot +from aiogram.utils.keyboard import InlineKeyboardBuilder + +load_dotenv() +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger("GhostNotifierBot") + +SLEEP_TIME=10 # seconds +CONNECTION_TIMEOUT=10 # seconds +MAX_BLOCK_DELAY=5 # blocks + +class RPCManager: + def __init__(self, network_name): + self.network_name = network_name + self.current_index = 0 + self.rpcs = [] + + async def start(self): + asyncio.create_task(self._auto_update_loop()) + + def next_rpc(self): + if len(self.rpcs) > 0: + self.current_index = (self.current_index + 1) % len(self.rpcs) + + def get_current_rpc(self): + if len(self.rpcs) == 0: + return "" + return self.rpcs[self.current_index] + + def update_rpc_from_file(self): + path = os.path.join("rpcs", f"{self.network_name.lower()}.txt") + if os.path.exists(path): + with open(path, "r") as f: + rpcs = [line.strip() for line in f if line.strip()] + self.rpcs = rpcs + self.current_index %= len(self.rpcs) + logger.info(f"[{self.network_name}] list of RPC updated.") + else: + logger.warning(f"[{self.network_name}] file with RPCs not found at {path}.") + + async def _auto_update_loop(self): + while True: + self.update_rpc_from_file() + await asyncio.sleep(1800) # 30 mins + +class EventBot: + def __init__(self, token, chat_id, thread_id, messages): + self.bot = Bot(token) + self.chat_id = chat_id + self.thread_id = thread_id + self.messages = messages + + def prepare_message(self, network_name, explorer_tx_link, event_name, log): + msgs = self.messages.get(event_name) + if msgs is None: + return f"{event_name} has no explanation text yet. =(" + + tx_link = f"{explorer_tx_link}{log['transactionHash'].to_0x_hex()}" + message = f"*{msgs['header']} #{network_name}!*\n\n" + message += f"{msgs['body']}\n[{msgs['link']}]({tx_link})\n\n" + message += f"_{msgs['footer']}_" + return message + + def prepare_button(self, network_name, event_name, ftso_address, log): + msgs = self.messages.get(event_name) + if msgs is None: + return None + + button_url = msgs["button_url"] + button_url = button_url.replace("/#/", f"/#/{network_name}/") + button = InlineKeyboardBuilder() + + if event_name == "Swap": + button_url += ftso_address + elif event_name == "Bond" or event_name == "MarketCreated": + bond_id = int(log["topics"][1].hex(), 16) + button_url += str(bond_id) + elif event_name == "ProposalCreated": + proposal_id = int(log["topics"][1].hex(), 16) + button_url += str(proposal_id) + + button.add(types.InlineKeyboardButton( + text=msgs["button_text"], + url=button_url + )) + + return button.as_markup() + + async def monitor_network(self, network_name, network_data): + rpc_manager = RPCManager(network_name) + await rpc_manager.start() + last_block = None + + while True: + checked_index = 0 + current_rpc = rpc_manager.get_current_rpc() + + try: + w3 = Web3(Web3.HTTPProvider(current_rpc, request_kwargs={'timeout': CONNECTION_TIMEOUT})) + if not w3.is_connected(): + raise Exception(f"[{network_name}] Could not connect to RPC: {current_rpc}.") + + logger.info(f"[{network_name}] Connecting to {current_rpc}.") + if last_block is None: + last_block = w3.eth.block_number + logger.info(f"[{network_name}] Starting from {last_block} based on {current_rpc}.") + + while True: + latest_block = w3.eth.block_number + + if last_block >= latest_block: + await asyncio.sleep(SLEEP_TIME) + continue + + current_block = min(last_block + MAX_BLOCK_DELAY, latest_block) + logger.info(f"[{network_name}] Looking for range from {last_block + 1} to {current_block}") + + for index, event in enumerate(network_data["events"]): + if checked_index > index: + continue + + event_name = event["name"] + logs = w3.eth.get_logs({ + "fromBlock": last_block + 1, + "toBlock": current_block, + "address": Web3.to_checksum_address(event["address"]), + "topics": [event["topic"]] + }) + checked_index = index + 1 + + logger.info(f"""[{network_name}] Found {len(logs)} {event_name} events from {current_rpc}.""") + await asyncio.sleep(2) + + for log in logs: + message = self.prepare_message( + network_name, + network_data["explorer_tx_link"], + event_name, + log + ) + button = self.prepare_button( + network_name, + event_name, + network_data["ftso"], + log + ) + await self.send_alert(message, button) + + checked_index = 0 + last_block = current_block + await asyncio.sleep(SLEEP_TIME) + + except Exception as e: + logger.warning(f"[{network_name}] Connection failed {current_rpc}: {e}.") + await asyncio.sleep(2) + + finally: + rpc_manager.next_rpc() + + async def send_alert(self, message, button_reply_markup): + try: + await self.bot.send_message( + chat_id=self.chat_id, + message_thread_id=self.thread_id, + text=message, + parse_mode="Markdown", + reply_markup=button_reply_markup + ) + await asyncio.sleep(0.5) + except Exception as e: + logger.error(f"[{network}] TG error during sending: {e}.") + + async def start(self, networks): + tasks = [ + self.monitor_network(network_name, network_data) + for network_name, network_data in networks.items() + ] + + logger.info(f"Ghost Event Bot started. Monitoring {len(tasks)} networks.") + await asyncio.gather(*tasks) + +if __name__ == "__main__": + TG_TOKEN = os.getenv("TELEGRAM_TOKEN") + CHAT_ID = os.getenv("NOTIFY_CHAT_ID") + THREAD_ID = os.getenv("THREAD_ID") or None + + with open("networks.json", "r") as f: + networks = json.load(f) + + with open("messages.json", "r") as f: + messages = json.load(f) + + bot = EventBot(token=TG_TOKEN, chat_id=CHAT_ID, thread_id=THREAD_ID, messages=messages) + asyncio.run(bot.start(networks)) diff --git a/messages.json b/messages.json new file mode 100644 index 0000000..cde21d2 --- /dev/null +++ b/messages.json @@ -0,0 +1,50 @@ +{ + "Swap": { + "header": "🔄 eCSPR Swap Detected on", + "body": "$eCSPR just changed hands on the DEX!", + "link": "🔗 Check the trade", + "footer": "Time to peek at your ghostDAO profits - spooky gains ahead! 👻📈", + "button_text": "View Current Price", + "button_url": "https://app.dao.ghostchain.io/#/dex/uniswap?from=0x0000000000000000000000000000000000000000&to=" + }, + "Bond": { + "header": "🎉 Bond Purchased on", + "body": "Someone just locked in a bond at a DISCOUNT!", + "link": "🔗 View transaction", + "footer": "Pro tip: Discounts vanish faster than ghosts at sunrise! 👻", + "button_text": "Check Discount", + "button_url": "https://app.dao.ghostchain.io/#/bonds/" + }, + "Stake": { + "header": "🔥 New Staking Action Detected on", + "body": "eCSPR tokens just got staked in the latest block!", + "link": "🔗 View transaction", + "footer": "'To stake or not to stake?' The answer is always STAKE! 👻", + "button_text": "Check Staking APY", + "button_url": "https://app.dao.ghostchain.io/#/stake" + }, + "MarketCreated": { + "header": "✨ Fresh Bond Alert on", + "body": "A new bond is now live with rising discount!", + "link": "🔗 See details here", + "footer": "Claim yours before the Ghosties snatch it up! 👻💰", + "button_text": "View new Bond", + "button_url": "https://app.dao.ghostchain.io/#/bonds/" + }, + "Ghosted": { + "header": "🔀 eCSPR has been GHOSTed on", + "body": "eCSPR has just been bridged in ⛓️➡️👻!", + "link": "🔗 Track the transfer", + "footer": "📢 To all #CASPER Validators: Time to earn bridging fees! 👻💸", + "button_text": "Check Bridge Status", + "button_url": "https://app.dao.ghostchain.io/#/bridge" + }, + "ProposalCreated": { + "header": "🗳 A new proposal just hit the ghostDAO voting floor on", + "body": "Time to make your CSPR count!", + "link": "🔗 Check the proposal", + "footer": "Go cast your vote before the proposal haunts you forever! 👻⚖️", + "button_text": "View Proposal", + "button_url": "https://app.dao.ghostchain.io/#/governance/" + } +} diff --git a/networks.json b/networks.json new file mode 100644 index 0000000..a719fc8 --- /dev/null +++ b/networks.json @@ -0,0 +1,110 @@ +{ + "sepolia": { + "ftso": "0x7ebd1224D36d64eA09312073e60f352d1383801A", + "explorer_tx_link": "https://sepolia.etherscan.io/tx/", + "events": [ + { + "name": "Swap", + "address": "0xCd1505E5d169525e0241c177aF5929A92E02276D", + "topic": "0xd78ad95fa46c994b6551d0da85fc275fe613ce37657fb8d5e3d130840159d822" + }, + { + "name": "Bond", + "address": "0x46BF6F7c3e96351eab7542f2B14c9f2ac6d08dF0", + "topic": "0x7880508a48fd3aee88f7e15917d85e39c3ad059e51ad4aca9bb46e7b4938b961" + }, + { + "name": "Stake", + "address": "0xC2C579631Bf6daA93252154080fecfd68c6aa506", + "topic": "0xfb1c60409e7f5b507f77c07b9d283bbd9569bcdc0b505ada7fad911130420ed8" + }, + { + "name": "MarketCreated", + "address": "0x46BF6F7c3e96351eab7542f2B14c9f2ac6d08dF0", + "topic": "0xf5110a61238afa49549d323d552cac783a8c8104bd1bbf42c5b479d60927f033" + }, + { + "name": "Ghosted", + "address": "0xd735cA07984a16911222c08411A80e24EB38869B", + "topic": "0x7ab52ec05c331e6257a3d705d6bea6e4c27277351764ad139209e06b203811a6" + }, + { + "name": "ProposalCreated", + "address": "0xaf7Ad1b83C47405BB9aa96868bCFbb6D65e4C2a1", + "topic": "0x7d84a6263ae0d98d3329bd7b46bb4e8d6f98cd35a7adb45c274c8b7fd5ebd5e0" + } + ] + }, + "mordor": { + "ftso": "0x7ebd1224D36d64eA09312073e60f352d1383801A", + "explorer_tx_link": "https://etc-mordor.blockscout.com/tx/", + "events": [ + { + "name": "Swap", + "address": "0x53B13C4722081c405ce25c7A7629fC326A49a469", + "topic": "0xd78ad95fa46c994b6551d0da85fc275fe613ce37657fb8d5e3d130840159d822" + }, + { + "name": "Bond", + "address": "0x46BF6F7c3e96351eab7542f2B14c9f2ac6d08dF0", + "topic": "0x7880508a48fd3aee88f7e15917d85e39c3ad059e51ad4aca9bb46e7b4938b961" + }, + { + "name": "Stake", + "address": "0xC2C579631Bf6daA93252154080fecfd68c6aa506", + "topic": "0xfb1c60409e7f5b507f77c07b9d283bbd9569bcdc0b505ada7fad911130420ed8" + }, + { + "name": "MarketCreated", + "address": "0x46BF6F7c3e96351eab7542f2B14c9f2ac6d08dF0", + "topic": "0xf5110a61238afa49549d323d552cac783a8c8104bd1bbf42c5b479d60927f033" + }, + { + "name": "Ghosted", + "address": "0x4823F1DC785D721eAdD2bD218E1eeD63aF67fBF4", + "topic": "0x7ab52ec05c331e6257a3d705d6bea6e4c27277351764ad139209e06b203811a6" + }, + { + "name": "ProposalCreated", + "address": "0xF950101af53733Ccf9309Ef4CC374B300dd43010", + "topic": "0x7d84a6263ae0d98d3329bd7b46bb4e8d6f98cd35a7adb45c274c8b7fd5ebd5e0" + } + ] + }, + "hoodi": { + "ftso": "0x7ebd1224D36d64eA09312073e60f352d1383801A", + "explorer_tx_link": "https://hoodi.etherscan.io/tx/", + "events": [ + { + "name": "Swap", + "address": "0x53B13C4722081c405ce25c7A7629fC326A49a469", + "topic": "0xd78ad95fa46c994b6551d0da85fc275fe613ce37657fb8d5e3d130840159d822" + }, + { + "name": "Bond", + "address": "0x46BF6F7c3e96351eab7542f2B14c9f2ac6d08dF0", + "topic": "0x7880508a48fd3aee88f7e15917d85e39c3ad059e51ad4aca9bb46e7b4938b961" + }, + { + "name": "Stake", + "address": "0xC2C579631Bf6daA93252154080fecfd68c6aa506", + "topic": "0xfb1c60409e7f5b507f77c07b9d283bbd9569bcdc0b505ada7fad911130420ed8" + }, + { + "name": "MarketCreated", + "address": "0x46BF6F7c3e96351eab7542f2B14c9f2ac6d08dF0", + "topic": "0xf5110a61238afa49549d323d552cac783a8c8104bd1bbf42c5b479d60927f033" + }, + { + "name": "Ghosted", + "address": "0x4823F1DC785D721eAdD2bD218E1eeD63aF67fBF4", + "topic": "0x7ab52ec05c331e6257a3d705d6bea6e4c27277351764ad139209e06b203811a6" + }, + { + "name": "ProposalCreated", + "address": "0xF950101af53733Ccf9309Ef4CC374B300dd43010", + "topic": "0x7d84a6263ae0d98d3329bd7b46bb4e8d6f98cd35a7adb45c274c8b7fd5ebd5e0" + } + ] + } +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a24ecb3 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,44 @@ +aiofiles==25.1.0 +aiogram==3.26.0 +aiohappyeyeballs==2.6.1 +aiohttp==3.13.5 +aiosignal==1.4.0 +annotated-types==0.7.0 +async-timeout==5.0.1 +attrs==26.1.0 +bitarray==3.8.1 +certifi==2026.2.25 +charset-normalizer==3.4.7 +ckzg==2.1.7 +cytoolz==1.1.0 +eth-account==0.13.7 +eth-hash==0.8.0 +eth-keyfile==0.8.1 +eth-keys==0.7.0 +eth-rlp==2.2.0 +eth-typing==6.0.0 +eth-utils==6.0.0 +eth_abi==5.2.0 +frozenlist==1.8.0 +hexbytes==1.3.1 +idna==3.11 +magic-filter==1.0.12 +multidict==6.7.1 +parsimonious==0.10.0 +propcache==0.4.1 +pycryptodome==3.23.0 +pydantic==2.12.5 +pydantic_core==2.41.5 +python-dotenv==1.2.2 +pyunormalize==17.0.0 +regex==2026.3.32 +requests==2.33.1 +rlp==4.1.0 +toolz==1.1.0 +types-requests==2.33.0.20260402 +typing-inspection==0.4.2 +typing_extensions==4.15.0 +urllib3==2.6.3 +web3==7.15.0 +websockets==15.0.1 +yarl==1.23.0 diff --git a/rpcs/hoodi.txt b/rpcs/hoodi.txt new file mode 100644 index 0000000..85f1fb0 --- /dev/null +++ b/rpcs/hoodi.txt @@ -0,0 +1,4 @@ +https://rpc.hoodi.ethpandaops.io +https://ethereum-hoodi.gateway.tatum.io +https://0xrpc.io/hoodi +https://rpc.sentio.xyz/hoodi diff --git a/rpcs/mordor.txt b/rpcs/mordor.txt new file mode 100644 index 0000000..2ad22a9 --- /dev/null +++ b/rpcs/mordor.txt @@ -0,0 +1,3 @@ +https://rpc.mordor.etccooperative.org +https://0xrpc.io/mordor +https://geth-mordor.etc-network.info diff --git a/rpcs/sepolia.txt b/rpcs/sepolia.txt new file mode 100644 index 0000000..9f730cf --- /dev/null +++ b/rpcs/sepolia.txt @@ -0,0 +1,5 @@ +https://rpc.sepolia.ethpandaops.io +https://ethereum-sepolia.gateway.tatum.io +https://sepolia.gateway.tenderly.co +https://0xrpc.io/sep +https://rpc.sepolia.ethpandaops.io