Implement online player detection and presence icons; update README and main.py
This commit is contained in:
parent
c7bd0d7bb7
commit
7fa1d93210
11
README.md
11
README.md
@ -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
|
||||||
|
|||||||
259
main.py
259
main.py
@ -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,68 +180,57 @@ 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)."""
|
||||||
|
|
||||||
|
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 = {
|
embed = {
|
||||||
"title": "📊 Server Stats Tracker",
|
"title": "📊 Server Stats Tracker",
|
||||||
"description": "Real-time player statistics",
|
|
||||||
"color": 3447003,
|
"color": 3447003,
|
||||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
"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]}
|
return {"embeds": [embed]}
|
||||||
|
|
||||||
def get_saved_message_id():
|
def get_saved_message_id():
|
||||||
@ -192,26 +264,15 @@ def stats_changed(old_data, new_data):
|
|||||||
if old_data is None:
|
if old_data is None:
|
||||||
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
|
return True
|
||||||
|
|
||||||
for player in new_data:
|
if old_data != new_data:
|
||||||
old_stats = old_data.get(player)
|
print(" ℹ️ Stats or presence changed")
|
||||||
new_stats = new_data.get(player)
|
return True
|
||||||
|
|
||||||
# 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
|
|
||||||
|
|
||||||
return False
|
return False
|
||||||
def save_message_id(message_id):
|
def save_message_id(message_id):
|
||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
Loading…
x
Reference in New Issue
Block a user