From 67bb52c767c83d86223b77bb370dc4b8450f3ccf Mon Sep 17 00:00:00 2001 From: Corban-Lee Jones Date: Mon, 9 Dec 2024 01:04:34 +0000 Subject: [PATCH] steam summary and player death tracker --- cogs/console.py | 12 +-- cogs/players.py | 268 ++++++++++++++++++++++++++++++++---------------- utils/models.py | 135 ++++++++++++++++++++++-- 3 files changed, 310 insertions(+), 105 deletions(-) diff --git a/cogs/console.py b/cogs/console.py index 1dcaef1..373e009 100644 --- a/cogs/console.py +++ b/cogs/console.py @@ -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: diff --git a/cogs/players.py b/cogs/players.py index 54f059f..d492d8f 100644 --- a/cogs/players.py +++ b/cogs/players.py @@ -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[\d\-:\.]+)\] user (?P.+?) died at \((?P\d+),(?P\d+),(?P\d+)\) \((?P.+?)\)" 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.*?)\] (?P/*?) "(?P.*?)" fully connected \((?P.*?)\)' + 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) diff --git a/utils/models.py b/utils/models.py index 19697a5..f376f15 100644 --- a/utils/models.py +++ b/utils/models.py @@ -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()