From 7fa1d9321023125166a8eca8aa92eaedf000175e Mon Sep 17 00:00:00 2001 From: ZareMate <0.zaremate@gmail.com> Date: Fri, 27 Mar 2026 16:53:13 +0100 Subject: [PATCH] Implement online player detection and presence icons; update README and main.py --- README.md | 11 ++ main.py | 259 +++++++++++++++++++++++++++++++---------------- requirements.txt | 3 +- 3 files changed, 187 insertions(+), 86 deletions(-) diff --git a/README.md b/README.md index d610b6a..9197cfe 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,8 @@ The script will: - ✅ Edit the same message on subsequent updates (no spam!) - ✅ **Only update the message if stats actually change** (saves API calls) - ✅ Display stats grouped by type across all players +- ✅ Detect who is currently online using the lookup API +- ✅ Show online location state icons per player - ✅ Show: Money, Playtime, Kills, Deaths, Mobs Killed, Blocks Placed/Broken, Shards ## How It Works @@ -70,3 +72,12 @@ The script will: - If you want to reset and create a new message, delete `message_id.txt` and `stats_cache.json` - The script handles network errors gracefully and will retry every 2 minutes - Press `Ctrl+C` to stop the script + +## Online Detection Icons + +The tracker uses `https://api.donutsmp.net/v1/lookup/` to detect online state and location: + +- `:sleeping:` = `Limbo` (AFK) +- `:yellow_circle:` = `Spawn` +- `:green_circle:` = `Overworld` (also used for `Nether` and `End`) +- `:black_circle:` = Offline diff --git a/main.py b/main.py index d77ffa1..3c99cd0 100644 --- a/main.py +++ b/main.py @@ -12,6 +12,7 @@ load_dotenv() WEBHOOK_URL = os.getenv('DISCORD_WEBHOOK_URL') API_KEY = os.getenv('API_KEY') API_BASE_URL = 'https://api.donutsmp.net/v1/stats' +LOOKUP_BASE_URL = 'https://api.donutsmp.net/v1/lookup' MESSAGE_ID_FILE = 'message_id.txt' CACHE_FILE = 'stats_cache.json' REFRESH_INTERVAL = 60 # 1 minute in seconds @@ -51,6 +52,88 @@ def fetch_player_stats(username): print(f"Exception fetching stats for {username}: {e}") return None +def fetch_player_presence(username): + """Fetch online/location presence data for a single player from the lookup API""" + try: + url = f'{LOOKUP_BASE_URL}/{username}' + headers = { + 'accept': 'application/json' + } + if API_KEY: + headers['Authorization'] = API_KEY + + response = requests.get(url, headers=headers, timeout=10) + + if response.status_code == 200: + data = response.json() + result = data.get('result', {}) + return { + 'online': True, + 'location': result.get('location', 'Unknown'), + 'rank': result.get('rank', 'Unknown') + } + + if response.status_code == 500: + # API returns 500 with this specific message when user is offline. + try: + data = response.json() + if data.get('message') == 'This user is not currently online.': + return { + 'online': False, + 'location': 'Offline', + 'rank': 'Unknown' + } + except Exception: + pass + + if response.status_code == 401: + print(f"Error fetching presence for {username}: Unauthorized (check API_KEY)") + else: + print(f"Error fetching presence for {username}: {response.status_code}") + + return { + 'online': False, + 'location': 'Offline', + 'rank': 'Unknown' + } + except Exception as e: + print(f"Exception fetching presence for {username}: {e}") + return { + 'online': False, + 'location': 'Offline', + 'rank': 'Unknown' + } + +def get_presence_icon(location, online): + """Map DonutSMP location to Discord emoji state""" + if not online: + return '⚫' + + normalized = str(location).strip().lower() + if normalized in {'limbo', 'limbos'}: + return '💤' + if normalized == 'spawn': + return '🟡' + if normalized in {'overworld', 'nether', 'end'}: + return '🟢' + + return '🟢' + +def get_presence_shortcode(location, online): + """Map DonutSMP location to Discord shortcode emoji for table rendering.""" + if not online: + return ':black_circle:' + + normalized = str(location).strip().lower() + if normalized in {'limbo', 'limbos'}: + return ':zzz:' + if normalized == 'spawn': + return ':yellow_circle:' + if normalized in {'overworld', 'nether', 'end'}: + return ':green_circle:' + + return ':green_circle:' + def format_number(value): """Format a number with K/M/B suffixes for readability""" if value == 'N/A' or value == '❌': @@ -97,68 +180,57 @@ def format_playtime(milliseconds): except (ValueError, TypeError): return str(milliseconds) -def format_stats_message(players_data): - """Format player stats grouped by stat type into a Discord embed message""" +def format_stats_message(players_data, presence_data): + """Format player stats as Discord embed fields (one field per column, inline).""" + + col_players = [] + col_money = [] + col_shards = [] + + total_money = 0 + total_spawners = 0 + + for username, stats in players_data.items(): + presence = presence_data.get(username, {'online': False, 'location': 'Offline'}) + status = get_presence_shortcode(presence.get('location', 'Offline'), presence.get('online', False)) + + if stats: + raw_money = stats.get('money', 0) or 0 + raw_shards = stats.get('shards', 0) or 0 + try: + total_money += int(raw_money) + spawners = int(raw_shards) // 1500 + total_spawners += spawners + except (ValueError, TypeError): + spawners = 0 + money = format_number(raw_money) + shards_display = f"{format_number(raw_shards)} ({spawners} <:spawner:1411753406070263958> )" + else: + money = shards_display = '❌' + + col_players.append(f"{status} {username}") + col_money.append(money) + col_shards.append(shards_display) + + # Append totals after a divider line + col_players.append("─────") + col_money.append(f"**{format_number(total_money)}**") + col_shards.append(f"**{total_spawners} <:spawner:1411753406070263958>**") + + def field(name, values, inline=True): + return {"name": name, "value": "\n".join(values), "inline": inline} + embed = { "title": "📊 Server Stats Tracker", - "description": "Real-time player statistics", "color": 3447003, "timestamp": datetime.now(timezone.utc).isoformat(), - "fields": [] + "fields": [ + field(":green_circle: Player", col_players), + field(":moneybag: Money", col_money), + field(":crystal_ball: Shards", col_shards), + ] } - - # Define stat groups with icons - stat_groups = { - "💰 Money": "money", - "⏱️ Playtime": "playtime", - "⚔️ Kills": "kills", - "☠️ Deaths": "deaths", - "🧟 Mobs Killed": "mobs_killed", - "🪨 Blocks Placed": "placed_blocks", - "⛏️ Blocks Broken": "broken_blocks", - "✨ Shards": "shards", - } - - # Stats that should show a total - summable_stats = {"money"} - - # Group stats by type - for group_name, stat_key in stat_groups.items(): - field_value = "" - total = 0 - can_sum = stat_key in summable_stats - - for username, stats in players_data.items(): - if stats: - value = stats.get(stat_key, 'N/A') - # Use special formatting for playtime - if stat_key == 'playtime': - formatted_value = format_playtime(value) - else: - formatted_value = format_number(value) - else: - formatted_value = "❌" - value = 'N/A' - - field_value += f"{username}: `{formatted_value}`\n" - - # Sum numeric values if this stat is summable - if can_sum and value != 'N/A' and value != '❌': - try: - total += int(value) - except (ValueError, TypeError): - can_sum = False # Stop summing if we hit a non-numeric value - - # Add total if this stat is summable - if can_sum and total > 0: - field_value += f"\n**Total**: `{format_number(total)}`" - - embed['fields'].append({ - "name": group_name, - "value": field_value.rstrip(), - "inline": True - }) - + return {"embeds": [embed]} def get_saved_message_id(): @@ -192,26 +264,15 @@ def stats_changed(old_data, new_data): if old_data is None: print(" ℹ️ First run - no cached stats") return True # First run - - # Compare all player stats - if set(old_data.keys()) != set(new_data.keys()): - print(" ℹ️ Players list changed") - return True # Different players - - for player in new_data: - old_stats = old_data.get(player) - new_stats = new_data.get(player) - - # If either is None, check if both changed - if (old_stats is None) != (new_stats is None): - print(f" ℹ️ {player}: stats availability changed") - return True - - # If both exist, compare them - if old_stats is not None and new_stats is not None: - if old_stats != new_stats: - print(f" ℹ️ {player}: stats changed") - return True + + # Backward compatibility for old cache format. + if not isinstance(old_data, dict) or 'stats' not in old_data or 'presence' not in old_data: + print(" ℹ️ Cache format changed") + return True + + if old_data != new_data: + print(" ℹ️ Stats or presence changed") + return True return False def save_message_id(message_id): @@ -248,7 +309,13 @@ def post_message(content): return False def edit_message(message_id, content): - """Edit an existing Discord message""" + """Edit an existing Discord message. + + Returns: + True on success, + "not_found" when Discord reports Unknown Message (10008), + False on other errors. + """ try: # Extract webhook parts webhook_parts = WEBHOOK_URL.rstrip('/').split('/') @@ -259,13 +326,22 @@ def edit_message(message_id, content): webhook_id = webhook_parts[-2] webhook_token = webhook_parts[-1] - edit_url = f"https://discordapp.com/api/webhooks/{webhook_id}/{webhook_token}/messages/{message_id}" + edit_url = f"https://discord.com/api/webhooks/{webhook_id}/{webhook_token}/messages/{message_id}" response = requests.patch(edit_url, json=content, timeout=10) if response.status_code == 200: print(f"✅ Updated message: {message_id}") return True else: + if response.status_code == 404: + try: + err = response.json() + if err.get('code') == 10008: + print(f"ℹ️ Stored message {message_id} no longer exists, will post a new message") + return "not_found" + except Exception: + pass + print(f"❌ Error editing message: {response.status_code}") print(response.text) return False @@ -297,18 +373,28 @@ def main(): # Fetch stats for all players print(f"\n⏰ [{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Fetching stats...") players_data = {} + presence_data = {} for player in players: stats = fetch_player_stats(player) + presence = fetch_player_presence(player) players_data[player] = stats + presence_data[player] = presence + + status_icon = get_presence_icon(presence.get('location', 'Offline'), presence.get('online', False)) if stats: - print(f" ✅ {player}") + print(f" ✅ {player} {status_icon} {presence.get('location', 'Offline')}") else: - print(f" ⚠️ {player}") + print(f" ⚠️ {player} {status_icon} {presence.get('location', 'Offline')}") + + current_snapshot = { + 'stats': players_data, + 'presence': presence_data + } # Check if stats changed cached_stats = get_cached_stats() - has_changed = stats_changed(cached_stats, players_data) + has_changed = stats_changed(cached_stats, current_snapshot) if not has_changed: print("📍 No changes detected, skipping update") @@ -316,7 +402,7 @@ def main(): continue # Format message - message_content = format_stats_message(players_data) + message_content = format_stats_message(players_data, presence_data) # Check if we have a previous message ID message_id = get_saved_message_id() @@ -324,14 +410,17 @@ def main(): if message_id: # Edit existing message print(f"📝 Editing existing message: {message_id}") - edit_message(message_id, message_content) + edit_result = edit_message(message_id, message_content) + if edit_result == "not_found": + print("📮 Posting replacement message") + post_message(message_content) else: # Post new message print("📮 No message ID found, posting new message") post_message(message_content) # Save stats for next comparison - save_cached_stats(players_data) + save_cached_stats(current_snapshot) print("💾 Cached stats saved") # Wait for next update diff --git a/requirements.txt b/requirements.txt index dde1b32..bef76f7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ python-dotenv==1.0.0 -requests==2.31.0 \ No newline at end of file +requests==2.31.0 +tabulate==0.9.0 \ No newline at end of file