Implement online player detection and presence icons; update README and main.py

This commit is contained in:
ZareMate 2026-03-27 16:53:13 +01:00
parent c7bd0d7bb7
commit 7fa1d93210
3 changed files with 187 additions and 86 deletions

View File

@ -46,6 +46,8 @@ The script will:
- ✅ Edit the same message on subsequent updates (no spam!) - ✅ Edit the same message on subsequent updates (no spam!)
- ✅ **Only update the message if stats actually change** (saves API calls) - ✅ **Only update the message if stats actually change** (saves API calls)
- ✅ Display stats grouped by type across all players - ✅ 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 - ✅ Show: Money, Playtime, Kills, Deaths, Mobs Killed, Blocks Placed/Broken, Shards
## How It Works ## 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` - 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 - The script handles network errors gracefully and will retry every 2 minutes
- Press `Ctrl+C` to stop the script - Press `Ctrl+C` to stop the script
## Online Detection Icons
The tracker uses `https://api.donutsmp.net/v1/lookup/<username>` to detect online state and location:
- `:sleeping:` = `Limbo` (AFK)
- `:yellow_circle:` = `Spawn`
- `:green_circle:` = `Overworld` (also used for `Nether` and `End`)
- `:black_circle:` = Offline

241
main.py
View File

@ -12,6 +12,7 @@ load_dotenv()
WEBHOOK_URL = os.getenv('DISCORD_WEBHOOK_URL') WEBHOOK_URL = os.getenv('DISCORD_WEBHOOK_URL')
API_KEY = os.getenv('API_KEY') API_KEY = os.getenv('API_KEY')
API_BASE_URL = 'https://api.donutsmp.net/v1/stats' API_BASE_URL = 'https://api.donutsmp.net/v1/stats'
LOOKUP_BASE_URL = 'https://api.donutsmp.net/v1/lookup'
MESSAGE_ID_FILE = 'message_id.txt' MESSAGE_ID_FILE = 'message_id.txt'
CACHE_FILE = 'stats_cache.json' CACHE_FILE = 'stats_cache.json'
REFRESH_INTERVAL = 60 # 1 minute in seconds REFRESH_INTERVAL = 60 # 1 minute in seconds
@ -51,6 +52,88 @@ def fetch_player_stats(username):
print(f"Exception fetching stats for {username}: {e}") print(f"Exception fetching stats for {username}: {e}")
return None 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): def format_number(value):
"""Format a number with K/M/B suffixes for readability""" """Format a number with K/M/B suffixes for readability"""
if value == 'N/A' or value == '': if value == 'N/A' or value == '':
@ -97,67 +180,56 @@ def format_playtime(milliseconds):
except (ValueError, TypeError): except (ValueError, TypeError):
return str(milliseconds) return str(milliseconds)
def format_stats_message(players_data): def format_stats_message(players_data, presence_data):
"""Format player stats grouped by stat type into a Discord embed message""" """Format player stats as Discord embed fields (one field per column, inline)."""
embed = {
"title": "📊 Server Stats Tracker",
"description": "Real-time player statistics",
"color": 3447003,
"timestamp": datetime.now(timezone.utc).isoformat(),
"fields": []
}
# Define stat groups with icons col_players = []
stat_groups = { col_money = []
"💰 Money": "money", col_shards = []
"⏱️ 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 total_money = 0
summable_stats = {"money"} total_spawners = 0
# 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(): 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: if stats:
value = stats.get(stat_key, 'N/A') raw_money = stats.get('money', 0) or 0
# Use special formatting for playtime raw_shards = stats.get('shards', 0) or 0
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: try:
total += int(value) total_money += int(raw_money)
spawners = int(raw_shards) // 1500
total_spawners += spawners
except (ValueError, TypeError): except (ValueError, TypeError):
can_sum = False # Stop summing if we hit a non-numeric value spawners = 0
money = format_number(raw_money)
shards_display = f"{format_number(raw_shards)} ({spawners} <:spawner:1411753406070263958> )"
else:
money = shards_display = ''
# Add total if this stat is summable col_players.append(f"{status} {username}")
if can_sum and total > 0: col_money.append(money)
field_value += f"\n**Total**: `{format_number(total)}`" col_shards.append(shards_display)
embed['fields'].append({ # Append totals after a divider line
"name": group_name, col_players.append("─────")
"value": field_value.rstrip(), col_money.append(f"**{format_number(total_money)}**")
"inline": True 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",
"color": 3447003,
"timestamp": datetime.now(timezone.utc).isoformat(),
"fields": [
field(":green_circle: Player", col_players),
field(":moneybag: Money", col_money),
field(":crystal_ball: Shards", col_shards),
]
}
return {"embeds": [embed]} return {"embeds": [embed]}
@ -193,24 +265,13 @@ def stats_changed(old_data, new_data):
print(" First run - no cached stats") print(" First run - no cached stats")
return True # First run return True # First run
# Compare all player stats # Backward compatibility for old cache format.
if set(old_data.keys()) != set(new_data.keys()): if not isinstance(old_data, dict) or 'stats' not in old_data or 'presence' not in old_data:
print(" Players list changed") print(" Cache format 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 return True
# If both exist, compare them if old_data != new_data:
if old_stats is not None and new_stats is not None: print(" Stats or presence changed")
if old_stats != new_stats:
print(f" {player}: stats changed")
return True return True
return False return False
@ -248,7 +309,13 @@ def post_message(content):
return False return False
def edit_message(message_id, content): 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: try:
# Extract webhook parts # Extract webhook parts
webhook_parts = WEBHOOK_URL.rstrip('/').split('/') webhook_parts = WEBHOOK_URL.rstrip('/').split('/')
@ -259,13 +326,22 @@ def edit_message(message_id, content):
webhook_id = webhook_parts[-2] webhook_id = webhook_parts[-2]
webhook_token = webhook_parts[-1] 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) response = requests.patch(edit_url, json=content, timeout=10)
if response.status_code == 200: if response.status_code == 200:
print(f"✅ Updated message: {message_id}") print(f"✅ Updated message: {message_id}")
return True return True
else: 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(f"❌ Error editing message: {response.status_code}")
print(response.text) print(response.text)
return False return False
@ -297,18 +373,28 @@ def main():
# Fetch stats for all players # Fetch stats for all players
print(f"\n⏰ [{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Fetching stats...") print(f"\n⏰ [{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Fetching stats...")
players_data = {} players_data = {}
presence_data = {}
for player in players: for player in players:
stats = fetch_player_stats(player) stats = fetch_player_stats(player)
presence = fetch_player_presence(player)
players_data[player] = stats players_data[player] = stats
presence_data[player] = presence
status_icon = get_presence_icon(presence.get('location', 'Offline'), presence.get('online', False))
if stats: if stats:
print(f"{player}") print(f"{player} {status_icon} {presence.get('location', 'Offline')}")
else: 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 # Check if stats changed
cached_stats = get_cached_stats() 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: if not has_changed:
print("📍 No changes detected, skipping update") print("📍 No changes detected, skipping update")
@ -316,7 +402,7 @@ def main():
continue continue
# Format message # 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 # Check if we have a previous message ID
message_id = get_saved_message_id() message_id = get_saved_message_id()
@ -324,14 +410,17 @@ def main():
if message_id: if message_id:
# Edit existing message # Edit existing message
print(f"📝 Editing existing message: {message_id}") 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: else:
# Post new message # Post new message
print("📮 No message ID found, posting new message") print("📮 No message ID found, posting new message")
post_message(message_content) post_message(message_content)
# Save stats for next comparison # Save stats for next comparison
save_cached_stats(players_data) save_cached_stats(current_snapshot)
print("💾 Cached stats saved") print("💾 Cached stats saved")
# Wait for next update # Wait for next update

View File

@ -1,2 +1,3 @@
python-dotenv==1.0.0 python-dotenv==1.0.0
requests==2.31.0 requests==2.31.0
tabulate==0.9.0