steam summary and player death tracker
All checks were successful
Build and Push Docker Image / build (push) Successful in 16s

This commit is contained in:
Corban-Lee Jones 2024-12-09 01:04:34 +00:00
parent 1cce089cc3
commit 67bb52c767
3 changed files with 310 additions and 105 deletions

View File

@ -46,13 +46,13 @@ class ConsoleCog(commands.Cog):
if "CheckModsNeedUpdate: Mods need update" in line:
await self.handle_mod_needs_update(line)
elif "ConnectionManager: [fully-connected]" in line:
cog = self.bot.get_cog("PlayersCog")
await cog.handle_player_connected(line)
# elif "ConnectionManager: [fully-connected]" in line:
# cog = self.bot.get_cog("PlayersCog")
# await cog.handle_player_connected(line)
elif "ConnectionManager: [disconnect]" in line:
cog = self.bot.get_cog("PlayersCog")
await cog.handle_player_disconnected(line)
# elif "ConnectionManager: [disconnect]" in line:
# cog = self.bot.get_cog("PlayersCog")
# await cog.handle_player_disconnected(line)
async def alert_and_wait_for_restart(self, intervals_ms: list[int], reason: str):
for interval_ms in intervals_ms:

View File

@ -4,142 +4,228 @@ Handles tasks related to in-game players, such as connect/disconnect alerts.
import re
import logging
from os import getenv
from pathlib import Path
from dataclasses import dataclass
from datetime import datetime
import httpx
from discord import Embed, Colour
from discord.ext import commands, tasks
from rcon.source import rcon
from utils.reader import LogFileReader
from utils.models import Player, PlayerDeath, create_or_update_player
ZOMBOID_FOLDER_PATH = Path(getenv("SPIFFO__ZOMBOID_FOLDER_PATH"))
LOGS_FOLDER_PATH = ZOMBOID_FOLDER_PATH / "Logs"
USER_LOG_FILE_PATH = None
for path in LOGS_FOLDER_PATH.iterdir():
if path.stem.endswith("_user.txt"):
USER_LOG_FILE_PATH = path
break
log = logging.getLogger(__name__)
@dataclass(slots=True)
class SteamProfileSummary:
steam_id: int
profile_name: str
url: str
avatar_url: str
# @dataclass(slots=True)
# class SteamProfileSummary:
# steam_id: int
# profile_name: str
# url: str
# avatar_url: str
@dataclass(slots=True)
class ZomboidUser:
guid: str
ip: str
steam_id: str
access: str
username: str
connection_type: str
steam_profile_summary: SteamProfileSummary | None = None
# @dataclass(slots=True)
# class ZomboidUser:
# guid: str
# ip: str
# steam_id: str
# access: str
# username: str
# connection_type: str
# steam_profile_summary: SteamProfileSummary | None = None
async def fetch_steam_profile(self, steam_api_key: str):
if not steam_api_key:
log.warning("No steam API key, can't get profile summary.")
return
# async def fetch_steam_profile(self, steam_api_key: str):
# if not steam_api_key:
# log.warning("No steam API key, can't get profile summary.")
# return
async with httpx.AsyncClient() as client:
response = await client.get(url=f"https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v2/?key={steam_api_key}&steamids={self.steam_id}")
response.raise_for_status()
# async with httpx.AsyncClient() as client:
# response = await client.get(url=f"https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v2/?key={steam_api_key}&steamids={self.steam_id}")
# response.raise_for_status()
all_data = response.json()
user_data = all_data["response"]["players"][0]
# all_data = response.json()
# user_data = all_data["response"]["players"][0]
log.debug("fetched user data for: %s", self.steam_id)
# log.debug("fetched user data for: %s", self.steam_id)
self.steam_profile_summary = SteamProfileSummary(
steam_id=user_data["steamid"],
profile_name=user_data["personaname"],
url=user_data["profileurl"],
avatar_url=user_data["avatarfull"]
)
# self.steam_profile_summary = SteamProfileSummary(
# steam_id=user_data["steamid"],
# profile_name=user_data["personaname"],
# url=user_data["profileurl"],
# avatar_url=user_data["avatarfull"]
# )
log.debug("successfully parsed steam profile summary for: %s", self.steam_id)
# log.debug("successfully parsed steam profile summary for: %s", self.steam_id)
@property
def embed(self) -> Embed:
if not self.steam_profile_summary:
raise ValueError("You must fetch the steam_profile_summary before creating an embed.")
# @property
# def embed(self) -> Embed:
# if not self.steam_profile_summary:
# raise ValueError("You must fetch the steam_profile_summary before creating an embed.")
embed = Embed(
title="Player",
description=(
f"{self.username} ([{self.steam_profile_summary.profile_name}]({self.steam_profile_summary.url}))\n"
"Deaths: ???\n"
"Playtime: ???"
)
)
embed.set_thumbnail(url=self.steam_profile_summary.avatar_url)
return embed
# embed = Embed(
# title="Player",
# description=(
# f"{self.username} ([{self.steam_profile_summary.profile_name}]({self.steam_profile_summary.url}))\n"
# "Deaths: ???\n"
# "Playtime: ???"
# )
# )
# embed.set_thumbnail(url=self.steam_profile_summary.avatar_url)
# return embed
class PlayersCog(commands.Cog):
"""
Handles tasks related to in-game players.
"""
file_handler: LogFileReader
def __init__(self, bot: commands.Bot):
self.bot = bot
self.file_handler = LogFileReader(USER_LOG_FILE_PATH)
self.listen_for_changes.start()
async def handle_player_connected(self, line: str):
"""
Report when a user has joined the server into a specified Discord channel.
Example of line:
ConnectionManager: [fully-connected] "" connection: guid=*** ip=*** steam-id=*** access=admin username="corbz" connection-type="UDPRakNet"
"""
re_pattern = r"guid=(\d+)\s+ip=([\d\.]+)\s+steam-id=(\d+)\s+access=(\w*)\s+username=\"([^\"]+)\"\s+connection-type=\"([^\"]+)\""
@tasks.loop(seconds=3)
async def listen_for_changes(self):
for line in await self.file_handler.read():
await self.process_log_line(line)
async def process_log_line(self, line: str):
log.debug("processing log line")
if "died" in line:
await self.process_player_death(line)
elif "fully connected" in line:
await self.process_connected_player(line)
elif "disconnected player" in line:
await self.process_disconnected_player(line)
async def process_player_death(line: str):
re_pattern = r"\[(?P<timestamp>[\d\-:\.]+)\] user (?P<username>.+?) died at \((?P<x>\d+),(?P<y>\d+),(?P<z>\d+)\) \((?P<cause>.+?)\)"
re_match = re.search(re_pattern, line)
if not re_match:
log.warning("failed to parse player data: %s", line)
log.warning("failed to parse player death log: %s", line)
return
user = ZomboidUser(
guid=re_match.group(1),
ip=re_match.group(2),
steam_id=re_match.group(3),
access=re_match.group(4),
username=re_match.group(5),
connection_type=re_match.group(6)
username = re_match.group("username")
player = await Player.get_or_none(username=username)
if not player:
log.warning("Player returned none, cannot add death: %s", username)
return
await player.add_death(
coord_x=re_match.group("x"),
coord_y=re_match.group("y"),
coord_z=re_match.group("z"),
cause=re_match.group("cause"),
timestamp=datetime.strptime(
re_match.group("timestamp"),
"%m-%d-%y %H:%M:%S.%f"
)
)
await user.fetch_steam_profile(self.bot.steam_api_key)
await player.save()
log.debug("successfully registered player death to %s", re_match.group("username"))
async def process_connected_player(self, line: str):
"""
"""
re_pattern = r'\[(?P<timestamp>.*?)\] (?P<steam_id>/*?) "(?P<username>.*?)" fully connected \((?P<coordinates>.*?)\)'
re_match = re.search(re_pattern, line)
if not re_match:
log.warning("Failed to parse player data: %s", line)
return
player = await Player.get_or_create(username=re_match.group("username"))
await player.update_steam_summary(self.bot.steam_api_key, re_match.group("steam_id"))
channel = self.bot.get_channel(self.bot.in_game_channel_id)
channel = await self.bot.fetch_channel(self.bot.in_game_channel_id) if not channel else channel
channel = channel or await self.bot.fetch_channel(self.bot.in_game_channel_id)
embed = user.embed
embed = await player.get_embed()
embed.title = "Player Has Connected"
embed.colour = Colour.brand_green()
await channel.send(embed=embed)
async def handle_player_disconnected(self, line: str):
"""
Report when a user has left the server into a specified Discord channel.
Example of line:
ConnectionManager: [disconnect] "receive-disconnect" connection: guid=*** ip=*** steam-id=*** access=admin username="corbz" connection-type="Disconnected"
"""
re_pattern = r"guid=(\d+)\s+ip=([\d\.]+)\s+steam-id=(\d+)\s+access=(\w*)\s+username=\"([^\"]+)\"\s+connection-type=\"([^\"]+)\""
re_match = re.search(re_pattern, line)
if not re_match:
log.warning("failed to parse player data: %s", line)
return
user = ZomboidUser(
guid=re_match.group(1),
ip=re_match.group(2),
steam_id=re_match.group(3),
access=re_match.group(4),
username=re_match.group(5),
connection_type=re_match.group(6)
)
await user.fetch_steam_profile(self.bot.steam_api_key)
async def process_disconnected_player(self, line: str):
pass
channel = self.bot.get_channel(self.bot.in_game_channel_id)
channel = await self.bot.fetch_channel(self.bot.in_game_channel_id) if not channel else channel
embed = user.embed
embed.title = "Player Has Disconnected"
embed.colour = Colour.brand_red()
# async def handle_player_connected(self, line: str):
# """
# Report when a user has joined the server into a specified Discord channel.
# Example of line:
# ConnectionManager: [fully-connected] "" connection: guid=*** ip=*** steam-id=*** access=admin username="corbz" connection-type="UDPRakNet"
# """
# re_pattern = r"guid=(\d+)\s+ip=([\d\.]+)\s+steam-id=(\d+)\s+access=(\w*)\s+username=\"([^\"]+)\"\s+connection-type=\"([^\"]+)\""
# re_match = re.search(re_pattern, line)
# if not re_match:
# log.warning("failed to parse player data: %s", line)
# return
await channel.send(embed=embed)
# user = ZomboidUser(
# guid=re_match.group(1),
# ip=re_match.group(2),
# steam_id=re_match.group(3),
# access=re_match.group(4),
# username=re_match.group(5),
# connection_type=re_match.group(6)
# )
# await user.fetch_steam_profile(self.bot.steam_api_key)
# channel = self.bot.get_channel(self.bot.in_game_channel_id)
# channel = await self.bot.fetch_channel(self.bot.in_game_channel_id) if not channel else channel
# embed = user.embed
# embed.title = "Player Has Connected"
# embed.colour = Colour.brand_green()
# await channel.send(embed=embed)
# async def handle_player_disconnected(self, line: str):
# """
# Report when a user has left the server into a specified Discord channel.
# Example of line:
# ConnectionManager: [disconnect] "receive-disconnect" connection: guid=*** ip=*** steam-id=*** access=admin username="corbz" connection-type="Disconnected"
# """
# re_pattern = r"guid=(\d+)\s+ip=([\d\.]+)\s+steam-id=(\d+)\s+access=(\w*)\s+username=\"([^\"]+)\"\s+connection-type=\"([^\"]+)\""
# re_match = re.search(re_pattern, line)
# if not re_match:
# log.warning("failed to parse player data: %s", line)
# return
# user = ZomboidUser(
# guid=re_match.group(1),
# ip=re_match.group(2),
# steam_id=re_match.group(3),
# access=re_match.group(4),
# username=re_match.group(5),
# connection_type=re_match.group(6)
# )
# await user.fetch_steam_profile(self.bot.steam_api_key)
# channel = self.bot.get_channel(self.bot.in_game_channel_id)
# channel = await self.bot.fetch_channel(self.bot.in_game_channel_id) if not channel else channel
# embed = user.embed
# embed.title = "Player Has Disconnected"
# embed.colour = Colour.brand_red()
# await channel.send(embed=embed)
async def setup(bot: commands.Bot):
cog = PlayersCog(bot)

View File

@ -2,30 +2,149 @@
Database schemas.
"""
from tortoise import Tortoise, fields
import logging
from datetime import datetime
from __future__ import annotations
import httpx
from tortoise import fields
from tortoise.queryset import QuerySet
from tortoise.models import Model
from discord import Embed
log = logging.getLogger(__name__)
class Player(Model):
username = fields.CharField(max_length=20)
steam_id = fields.CharField(max_length=20)
"""
"""
username = fields.CharField(max_length=20, unique=True)
last_connection = fields.DatetimeField()
last_disconnection = fields.DatetimeField()
deaths = fields.ManyToManyField(
model_name="models.PlayerDeath",
on_delete=fields.CASCADE
)
play_time_seconds = fields.DecimalField(default=0.0)
class Meta:
table = "players"
@property
def is_online(self):
def is_online(self) -> bool:
"""
"""
if not self.last_connection:
return False
return (self.last_connection and not self.last_disconnection) \
or self.last_connection > self.last_disconnection
async def get_deaths(self) -> QuerySet[PlayerDeath]:
return await PlayerDeath.filter(player=self)
async def add_death(
self,
coord_x: str | int,
coord_y: str | int,
coord_z: str | int,
cause: str,
timestamp: datetime
) -> PlayerDeath:
"""
"""
log.debug("Assigning death to player: %s", self.username)
return await PlayerDeath.create(
player=self,
coordinate_x=coord_x,
coordinate_y=coord_y,
coordinate_z=coord_z,
cause=cause,
timestamp=timestamp
)
async def get_steam_summary(self) -> SteamProfileSummary | None:
"""
"""
return SteamProfileSummary.get_or_none(player=self)
async def update_steam_summary(self, steam_id: str | int, steam_api_key: str) -> SteamProfileSummary:
"""
"""
log.debug("Updating Steam summary for player: %s", self.username)
if not steam_api_key:
raise ValueError("No Steam API key provided, can't get profile summary.")
async with httpx.AsyncClient() as client:
response = await client.get(url=f"https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v2/?key={steam_api_key}&steamids={steam_id}")
response.raise_for_status()
data = response.json()
profiles = data.get("response", {}).get("players", [])
if not profiles:
raise ValueError("No profiles found in response")
profile = profiles[0]
summary, created = SteamProfileSummary.get_or_create(
steam_id=steam_id,
defaults={
"player": self,
"profile_name": profile.get("personaname"),
"url": profile.get("profileurl"),
"avatar_url": profile.get("avatarfull")
}
)
if not created:
summary.profile_name = profile.get("personaname")
summary.url = profile.get("profileurl")
summary.avatar_url = profile.get("avatarfull")
await summary.save()
return summary
async def get_embed(self) -> Embed:
summary = await self.get_steam_summary()
if not summary:
raise ValueError("You must fetch the steam_profile_summary before creating an embed.")
death_count = (await self.get_deaths()).count()
embed = Embed(
title="Player",
description=(
f"{self.username} ([{summary.profile_name}]({summary.url}))\n"
f"Deaths: {death_count}"
f"Playtime ???"
)
)
embed.set_thumbnail(summary.avatar_url)
return embed
class SteamProfileSummary(Model):
player = fields.ForeignKeyField(
model_name="models.Player",
on_delete=fields.CASCADE
)
steam_id = fields.CharField(max_length=20, unique=True)
profile_name = fields.CharField(max_length=32)
url = fields.CharField(max_length=128)
avatar_url = fields.CharField(max_length=128)
last_update = fields.DatetimeField(auto_now=True)
class Meta:
table = "steam_profile_summary"
class PlayerDeath(Model):
player = fields.ForeignKeyField(
model_name="models.Player",
on_delete=fields.CASCADE
)
coordinate_x = fields.IntField()
coordinate_y = fields.IntField()
coordinate_z = fields.IntField()