Merge pull request 'dev' (#4) from dev into master
Some checks failed
Build and Push Docker Image / build (push) Failing after 3s

Reviewed-on: #4
This commit is contained in:
Corban-Lee Jones 2025-02-16 20:52:10 +00:00
commit 6081324320
9 changed files with 304 additions and 106 deletions

View File

@ -8,4 +8,6 @@ __pycache__/
.vscode/ .vscode/
db.sqlite db.sqlite
db.sqlite-shm db.sqlite-shm
db.sqlite-wal db.sqlite-wal
zomboid/
data/

4
.gitignore vendored
View File

@ -5,4 +5,6 @@ __pycache__/
*.pyc *.pyc
db.sqlite db.sqlite
db.sqlite-shm db.sqlite-shm
db.sqlite-wal db.sqlite-wal
zomboid/
data/

View File

@ -5,6 +5,25 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
- Debug environment variable flag.
- Store player deaths and sessions in the db.
- Calculate player's playtime via the sum of their stored session lengths.
- Admin-only "build" command for player data - causes the bot to read through all older log files for data.
### Fixed
- Unhandled situation where zomboid rotates the log files, causing FileNotFoundError to be raised.
### Changed
- Log level dependent on the debug flag.
- Connect to and build the database earlier into start-up.
- Many smaller changes and improvements to the database tables for player data.
## [0.0.3] - 2024-12-09 ## [0.0.3] - 2024-12-09
### Added ### Added
@ -15,7 +34,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed ### Fixed
- "User has connected/disconnected" alert on player death. - "User has connected/disconnected" alert incorrectly firing on player death.
### Changed ### Changed

View File

@ -1,5 +1,10 @@
# Spiffo # Spiffo
> **IMPORTANT**
> Due to the recent release of build 42 of Project Zomboid, this project is now redundant. The updated game includes improved Discord integration, which was the primary purpose of this repository.
>
> Please don't attempt to use this repository, as the latest version includes various bugs from development, before it was overshadowed by build 42.
A multi-purpose Discord integration bot for Project Zomboid. A multi-purpose Discord integration bot for Project Zomboid.
## Features ## Features
@ -29,9 +34,12 @@ This should make the environment variables used be more clear and pronounced whe
|`SPIFFO__DISCORD_CHANNEL_ID`|Snowflake ID of the channel where join alerts and other server related messages should appear.|✔|| |`SPIFFO__DISCORD_CHANNEL_ID`|Snowflake ID of the channel where join alerts and other server related messages should appear.|✔||
|`SPIFFO__STEAM_API_KEY`|Web API key granted by steam, allows for the user's steam profile info to be shown in the join alerts.||| |`SPIFFO__STEAM_API_KEY`|Web API key granted by steam, allows for the user's steam profile info to be shown in the join alerts.|||
|`SPIFFO__ZOMBOID_FOLDER_PATH`|Absolute path to the 'Zomboid' folder that contains the `server-console.txt` file.|✔|| |`SPIFFO__ZOMBOID_FOLDER_PATH`|Absolute path to the 'Zomboid' folder that contains the `server-console.txt` file.|✔||
|`SPIFFO__DATA_FOLDER_PATH`|Custom path of where the bot will create and store data.|✔||
|`SPIFFO__SERVER_NAME`|Name of the server instance, don't change unless you know what you are doing!||servertest| |`SPIFFO__SERVER_NAME`|Name of the server instance, don't change unless you know what you are doing!||servertest|
|`SPIFFO__DEBUG`|Whether to run in debug mode, which will reveal debug logs.||False|
|`SPIFFO__ZOMBOID_LOG_TIMESTAMP_FORMAT`|The format used for timestamps in Zomboid's logs - do not change this!|✔|%d-%m-%y %H:%M:%S.%f|
## Why Did I Make This? ## Why Am I Making This?
All existing bots are poorly coded, slow and/or lack certain features. All existing bots are poorly coded, slow and/or lack certain features.

46
bot.py
View File

@ -11,7 +11,7 @@ import logging.config
from os import getenv from os import getenv
from pathlib import Path from pathlib import Path
from discord import Intents from discord import Intents, TextChannel
from discord.ext import commands from discord.ext import commands
from dotenv import load_dotenv from dotenv import load_dotenv
from tortoise import Tortoise from tortoise import Tortoise
@ -21,6 +21,15 @@ log = logging.getLogger(__name__)
BASE_DIR = Path(__file__).resolve().parent BASE_DIR = Path(__file__).resolve().parent
DATA_DIR = getenv("SPIFFO__DATA_FOLDER_PATH") DATA_DIR = getenv("SPIFFO__DATA_FOLDER_PATH")
TORTOISE_ORM = {
"connections": { "default": f"sqlite://{str(DATA_DIR)}/db.sqlite" },
"apps": {
"models": {
"models": ["utils.models"],
"default_connection": "default"
}
}
}
class DiscordBot(commands.Bot): class DiscordBot(commands.Bot):
@ -28,12 +37,9 @@ class DiscordBot(commands.Bot):
Represents a Discord bot. Represents a Discord bot.
Contains controls to interact with the bot via the Discord API. Contains controls to interact with the bot via the Discord API.
""" """
in_game_channel_id: int def __init__(self, debug_mode: bool):
steam_api_key: str
rcon_details: dict
def __init__(self):
super().__init__(command_prefix="-", intents=Intents.all()) super().__init__(command_prefix="-", intents=Intents.all())
self.debug_mode = debug_mode
self.in_game_channel_id = int(getenv("SPIFFO__DISCORD_CHANNEL_ID")) self.in_game_channel_id = int(getenv("SPIFFO__DISCORD_CHANNEL_ID"))
self.steam_api_key = getenv("SPIFFO__STEAM_API_KEY") self.steam_api_key = getenv("SPIFFO__STEAM_API_KEY")
self.rcon_details = { self.rcon_details = {
@ -51,14 +57,6 @@ class DiscordBot(commands.Bot):
# Sync app commands # Sync app commands
await self.wait_until_ready() await self.wait_until_ready()
await self.tree.sync() await self.tree.sync()
# Open database connection
await Tortoise.init(
db_url= f"sqlite://{str(DATA_DIR)}/db.sqlite",
modules={"models": ["utils.models"]}
)
await Tortoise.generate_schemas()
log.info("Discord Bot is ready") log.info("Discord Bot is ready")
async def close(self): async def close(self):
@ -75,6 +73,12 @@ class DiscordBot(commands.Bot):
if path.suffix == ".py": if path.suffix == ".py":
await self.load_extension(f"cogs.{path.stem}") await self.load_extension(f"cogs.{path.stem}")
async def get_ingame_channel(self) -> TextChannel:
"""
"""
channel = self.get_channel(self.in_game_channel_id)
return channel or await self.fetch_channel(self.in_game_channel_id)
def get_bot_token() -> str: def get_bot_token() -> str:
""" """
@ -87,7 +91,7 @@ def get_bot_token() -> str:
return bot_token return bot_token
def setup_logging_config(): def setup_logging_config(debug_mode: bool):
""" """
Loads the logging configuration and creates an asynchronous queue handler. Loads the logging configuration and creates an asynchronous queue handler.
""" """
@ -101,6 +105,9 @@ def setup_logging_config():
# Ensure the logging directory exists # Ensure the logging directory exists
(BASE_DIR / "logs").mkdir(exist_ok=True) (BASE_DIR / "logs").mkdir(exist_ok=True)
# update the log level dependent on debug mode
log_config["loggers"]["root"]["level"] = "DEBUG" if debug_mode else "INFO"
# Load the config # Load the config
logging.config.dictConfig(log_config) # This 'logging.config' path is jank. logging.config.dictConfig(log_config) # This 'logging.config' path is jank.
@ -114,10 +121,15 @@ async def main():
""" """
The entrypoint function, initialises the application. The entrypoint function, initialises the application.
""" """
setup_logging_config() debug_mode = getenv("SPIFFO__DEBUG").lower() == "true"
setup_logging_config(debug_mode)
bot_token = get_bot_token() bot_token = get_bot_token()
async with DiscordBot() as bot: # Open database connection
await Tortoise.init(config=TORTOISE_ORM)
await Tortoise.generate_schemas()
async with DiscordBot(debug_mode) as bot:
await bot.load_cogs() await bot.load_cogs()
await bot.start(bot_token, reconnect=True) await bot.start(bot_token, reconnect=True)

View File

@ -8,7 +8,7 @@ from os import getenv
from pathlib import Path from pathlib import Path
from datetime import datetime from datetime import datetime
from discord import Colour from discord import Colour, Interaction, Permissions, app_commands
from discord.ext import commands, tasks from discord.ext import commands, tasks
from utils.reader import LogFileReader from utils.reader import LogFileReader
@ -16,11 +16,7 @@ from utils.models import Player
ZOMBOID_FOLDER_PATH = Path(getenv("SPIFFO__ZOMBOID_FOLDER_PATH")) ZOMBOID_FOLDER_PATH = Path(getenv("SPIFFO__ZOMBOID_FOLDER_PATH"))
LOGS_FOLDER_PATH = ZOMBOID_FOLDER_PATH / "Logs" LOGS_FOLDER_PATH = ZOMBOID_FOLDER_PATH / "Logs"
USER_LOG_FILE_PATH = None TIMESTAMP_FORMAT = getenv("SPIFFO__ZOMBOID_LOG_TIMESTAMP_FORMAT", "%d-%m-%y %H:%M:%S.%f")
for path in LOGS_FOLDER_PATH.iterdir():
if path.name.endswith("_user.txt"):
USER_LOG_FILE_PATH = path
break
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -33,26 +29,65 @@ class PlayersCog(commands.Cog):
def __init__(self, bot: commands.Bot): def __init__(self, bot: commands.Bot):
self.bot = bot self.bot = bot
self.file_handler = LogFileReader(USER_LOG_FILE_PATH) self.create_file_handler()
self.listen_for_changes.start() self.listen_for_changes.start()
cmd_group = app_commands.Group(
name="players",
description="Commands for the players cog.",
default_permissions=Permissions.all(),
guild_only=True
)
@cmd_group.command(name="build-from-logs")
async def build_from_logs(self, inter: Interaction):
"""
Build player data from existing and older log files.
"""
await inter.response.defer()
self.listen_for_changes.stop()
log.info("Building player data from logs.")
# Delete the existing data, as we will reconstruct it.
await Player.all().delete()
for log_file in LOGS_FOLDER_PATH.glob("**/*_user.txt"):
log.debug("building from log file: %s", str(log_file))
file_handler = LogFileReader(log_file, track_from_start=True)
for line in await file_handler.read():
await self.process_log_line(line, alert=False)
self.listen_for_changes.start()
await inter.followup.send("Completed")
def create_file_handler(self):
user_log_file_path = next(LOGS_FOLDER_PATH.glob("*_user.txt"), None)
self.file_handler = LogFileReader(user_log_file_path)
@tasks.loop(seconds=3) @tasks.loop(seconds=3)
async def listen_for_changes(self): async def listen_for_changes(self):
"""
Listen for changes in the user.txt log file, and process them.
"""
log.debug("listening for changes") log.debug("listening for changes")
for line in await self.file_handler.read(): try:
await self.process_log_line(line) for line in await self.file_handler.read():
await self.process_log_line(line)
except FileNotFoundError:
log.info("User log file not found, assuming it rotated to a new file.")
self.create_file_handler()
async def process_log_line(self, line: str): async def process_log_line(self, line: str, alert: bool = True):
log.debug("processing log line") log.debug("processing log line")
if "died" in line: if "died" in line:
await self.process_player_death(line) await self.process_player_death(line, alert)
elif "fully connected" in line: elif "fully connected" in line:
await self.process_connected_player(line) await self.process_connected_player(line, alert)
elif "disconnected player" in line: elif "disconnected player" in line:
await self.process_disconnected_player(line) await self.process_disconnected_player(line, alert)
async def process_player_death(self, line: str): async def process_player_death(self, line: str, alert: bool = False):
log.debug("processing player death") log.debug("processing player death")
re_pattern = r"\[(?P<timestamp>[\d\- :\.]+)\] user (?P<username>.+?) died at \((?P<x>\d+),(?P<y>\d+),(?P<z>\d+)\) \((?P<cause>.+?)\)" 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) re_match = re.search(re_pattern, line)
@ -73,28 +108,28 @@ class PlayersCog(commands.Cog):
cause=re_match.group("cause"), cause=re_match.group("cause"),
timestamp=datetime.strptime( timestamp=datetime.strptime(
re_match.group("timestamp"), re_match.group("timestamp"),
"%m-%d-%y %H:%M:%S.%f" TIMESTAMP_FORMAT
) )
) )
await player.save() await player.save()
channel = self.bot.get_channel(self.bot.in_game_channel_id) log.debug("successfully registered player death to %s", re_match.group("username"))
channel = channel or await self.bot.fetch_channel(self.bot.in_game_channel_id)
if not alert:
return
channel = await self.bot.get_ingame_channel()
embed = await player.get_embed() embed = await player.get_embed()
embed.title = "Player Has Died" embed.title = "Player Has Died"
embed.colour = Colour.dark_orange() embed.colour = Colour.dark_orange()
await channel.send(embed=embed) await channel.send(embed=embed)
log.debug("successfully registered player death to %s", re_match.group("username")) async def process_connected_player(self, line: str, alert: bool = False):
async def process_connected_player(self, line: str):
""" """
""" """
log.debug("processing connected player") log.debug("processing connected player")
re_pattern = r'\[(?P<timestamp>.*?)\] (?P<steam_id>\d+) "(?P<username>.*?)" fully connected \((?P<coordinates>\d+,\d+,\d+)\)' re_pattern = r'\[(?P<timestamp>.*?)\] (?P<steam_id>\d+) "(?P<username>.*?)" fully connected \((?P<x>\d+),(?P<y>\d+),(?P<z>\d+)\)'
re_match = re.search(re_pattern, line) re_match = re.search(re_pattern, line)
if not re_match: if not re_match:
@ -102,11 +137,16 @@ class PlayersCog(commands.Cog):
return return
player, created = await Player.get_or_create(username=re_match.group("username")) player, created = await Player.get_or_create(username=re_match.group("username"))
player.last_connection = datetime.strptime( await player.open_session(
re_match.group("timestamp"), timestamp=datetime.strptime(re_match.group("timestamp"), TIMESTAMP_FORMAT),
"%m-%d-%y %H:%M:%S.%f" coord_x=re_match.group("x"),
coord_y=re_match.group("y"),
coord_z=re_match.group("z")
)
await player.update_outdated_steam_summary(
re_match.group("steam_id"),
self.bot.steam_api_key
) )
await player.update_steam_summary(re_match.group("steam_id"), self.bot.steam_api_key)
await player.save() await player.save()
# This connection method is called when the player respawns # This connection method is called when the player respawns
@ -115,20 +155,21 @@ class PlayersCog(commands.Cog):
await player.save() await player.save()
return return
channel = self.bot.get_channel(self.bot.in_game_channel_id) if not alert:
channel = channel or await self.bot.fetch_channel(self.bot.in_game_channel_id) return
channel = await self.bot.get_ingame_channel()
embed = await player.get_embed() embed = await player.get_embed()
embed.title = "Player Has Connected" embed.title = "Player Has Connected"
embed.colour = Colour.brand_green() embed.colour = Colour.brand_green()
await channel.send(embed=embed) await channel.send(embed=embed)
async def process_disconnected_player(self, line: str): async def process_disconnected_player(self, line: str, alert: bool = False):
""" """
""" """
log.debug("processing disconnected player") log.debug("processing disconnected player")
re_pattern = r'\[(?P<timestamp>.*?)\] (?P<steam_id>\d+) "(?P<username>.*?)" disconnected player \((?P<coordinates>\d+,\d+,\d+)\)' re_pattern = r'\[(?P<timestamp>.*?)\] (?P<steam_id>\d+) "(?P<username>.*?)" disconnected player \((?P<x>\d+),(?P<y>\d+),(?P<z>\d+)\)'
re_match = re.search(re_pattern, line) re_match = re.search(re_pattern, line)
if not re_match: if not re_match:
@ -136,19 +177,22 @@ class PlayersCog(commands.Cog):
return return
player, created = await Player.get_or_create(username=re_match.group("username")) player, created = await Player.get_or_create(username=re_match.group("username"))
player.last_disconnection = datetime.strptime( await player.close_session(
re_match.group("timestamp"), timestamp=datetime.strptime(re_match.group("timestamp"), TIMESTAMP_FORMAT),
"%m-%d-%y %H:%M:%S.%f" coord_x=re_match.group("x"),
coord_y=re_match.group("y"),
coord_z=re_match.group("z")
)
await player.update_outdated_steam_summary(
re_match.group("steam_id"),
self.bot.steam_api_key
) )
await player.update_steam_summary(re_match.group("steam_id"), self.bot.steam_api_key)
await player.save() await player.save()
if player.is_dead: if player.is_dead or not alert:
return return
channel = self.bot.get_channel(self.bot.in_game_channel_id) channel = await self.bot.get_ingame_channel()
channel = channel or await self.bot.fetch_channel(self.bot.in_game_channel_id)
embed = await player.get_embed() embed = await player.get_embed()
embed.title = "Player Has Disconnected" embed.title = "Player Has Disconnected"
embed.colour = Colour.brand_red() embed.colour = Colour.brand_red()

View File

@ -5,9 +5,11 @@ aiosignal==1.3.1
aiosqlite==0.20.0 aiosqlite==0.20.0
annotated-types==0.7.0 annotated-types==0.7.0
anyio==4.7.0 anyio==4.7.0
asyncclick==8.1.7.2
attrs==24.2.0 attrs==24.2.0
bump2version==1.0.1 bump2version==1.0.1
certifi==2024.8.30 certifi==2024.8.30
dictdiffer==0.9.0
discord.py==2.4.0 discord.py==2.4.0
frozenlist==1.5.0 frozenlist==1.5.0
h11==0.14.0 h11==0.14.0
@ -24,6 +26,7 @@ python-dotenv==1.0.1
pytz==2024.2 pytz==2024.2
rcon==2.4.9 rcon==2.4.9
sniffio==1.3.1 sniffio==1.3.1
tomlkit==0.13.2
tortoise-orm==0.22.1 tortoise-orm==0.22.1
typing_extensions==4.12.2 typing_extensions==4.12.2
yarl==1.18.3 yarl==1.18.3

View File

@ -3,15 +3,16 @@ Database schemas.
""" """
import logging import logging
from datetime import datetime from datetime import datetime, timedelta
from pytz import timezone
import httpx import httpx
from tortoise import fields from tortoise import fields
from tortoise.queryset import QuerySet
from tortoise.models import Model from tortoise.models import Model
from discord import Embed from discord import Embed
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
utc = timezone("UTC")
class SteamProfileSummary(Model): class SteamProfileSummary(Model):
@ -29,78 +30,167 @@ class SteamProfileSummary(Model):
table = "steam_profile_summary" table = "steam_profile_summary"
class Coordinates(Model):
x = fields.IntField()
y = fields.IntField()
z = fields.IntField()
class Meta:
table = "ingame_coordinates"
class PlayerDeath(Model): class PlayerDeath(Model):
player = fields.ForeignKeyField( player = fields.ForeignKeyField(
model_name="models.Player", model_name="models.Player",
on_delete=fields.CASCADE on_delete=fields.CASCADE
) )
coordinate_x = fields.IntField()
coordinate_y = fields.IntField()
coordinate_z = fields.IntField()
cause = fields.CharField(max_length=32) cause = fields.CharField(max_length=32)
timestamp = fields.DatetimeField(auto_now_add=True) timestamp = fields.DatetimeField(auto_now_add=True)
coordinates = fields.ForeignKeyField(
model_name="models.Coordinates",
on_delete=fields.CASCADE
)
class Meta: class Meta:
table = "player_deaths" table = "player_deaths"
class PlayerSession(Model):
player = fields.ForeignKeyField(
model_name="models.Player",
on_delete=fields.CASCADE
)
connected_at = fields.DatetimeField()
disconnected_at = fields.DatetimeField(null=True)
connected_coords = fields.ForeignKeyField(
model_name="models.Coordinates",
related_name="connected_coords",
on_delete=fields.CASCADE
)
disconnected_coords = fields.ForeignKeyField(
model_name="models.Coordinates",
related_name="disconnected_coords",
on_delete=fields.CASCADE,
null=True
)
class Meta:
table = "player_session"
@property
def playtime(self) -> timedelta:
if not self.disconnected_at:
return datetime.now(tz=utc) - self.connected_at
return self.disconnected_at - self.connected_at
class Player(Model): class Player(Model):
""" """
""" """
username = fields.CharField(max_length=20, unique=True) username = fields.CharField(max_length=20, unique=True)
last_connection = fields.DatetimeField(null=True)
last_disconnection = fields.DatetimeField(null=True)
play_time_seconds = fields.IntField(default=0)
is_dead = fields.BooleanField(default=False) is_dead = fields.BooleanField(default=False)
class Meta: class Meta:
table = "players" table = "players"
@property async def get_playtime(self) -> timedelta:
def is_online(self) -> bool: log.info("Getting total playtime for player: %s", self.username)
""" sessions = await PlayerSession.filter(player=self)
""" total_playtime = timedelta()
if not self.last_connection:
return False
return (self.last_connection and not self.last_disconnection) \ # I know this is terrible efficiency-wise, but the tortoise docs
or self.last_connection > self.last_disconnection # are so bad and the annotations don't work like Django's models. Deal with it!
for session in sessions:
log.debug(
"playtime info:\nsession start: %s\nsession end: %s\nsession playtime: %s",
session.connected_at,
session.disconnected_at,
session.playtime
)
total_playtime += session.playtime
async def get_deaths(self) -> QuerySet[PlayerDeath]: return total_playtime
async def get_deaths(self) -> list[PlayerDeath]:
return await PlayerDeath.filter(player=self) return await PlayerDeath.filter(player=self)
async def add_death( async def add_death(
self, self,
coord_x: str | int, coord_x: str | int,
coord_y: str | int, coord_y: str | int,
coord_z: str | int, coord_z: str | int,
cause: str, cause: str,
timestamp: datetime timestamp: datetime
) -> PlayerDeath: ) -> PlayerDeath:
""" """
""" """
log.debug("Assigning death to player: %s", self.username) log.info("Assigning death to player: %s", self.username)
self.is_dead = True self.is_dead = True
await self.save() await self.save()
coordinates = await Coordinates.create(x=coord_x, y=coord_y, z=coord_z)
return await PlayerDeath.create( return await PlayerDeath.create(
player=self, player=self,
coordinate_x=coord_x,
coordinate_y=coord_y,
coordinate_z=coord_z,
cause=cause, cause=cause,
timestamp=timestamp timestamp=timestamp,
coordinates=coordinates
) )
async def get_latest_session(self, ignore_closed_sessions: bool = False) -> PlayerSession:
queryset = PlayerSession.filter(player=self)
if ignore_closed_sessions:
queryset = queryset.filter(disconnected_at=None)
return await queryset.last()
async def open_session(
self,
timestamp: datetime,
coord_x: str | int,
coord_y: str | int,
coord_z: str | int,
) -> PlayerSession:
log.info("creating session for player: %s", self.username)
existing_session = await self.get_latest_session(ignore_closed_sessions=True)
if existing_session:
log.debug("deleting an unfinished session to open a new one")
await existing_session.delete()
coordinates = await Coordinates.create(x=coord_x, y=coord_y, z=coord_z)
return await PlayerSession.create(
player=self,
connected_at=timestamp,
connected_coords=coordinates
)
async def close_session(
self,
timestamp: datetime,
coord_x: str | int,
coord_y: str | int,
coord_z: str | int,
) -> PlayerSession:
log.info("closing session for player: %s", self.username)
current_session = await self.get_latest_session(ignore_closed_sessions=True)
if not current_session:
raise ValueError("Tried to close session that doesn't exist.")
coordinates = await Coordinates.create(x=coord_x, y=coord_y, z=coord_z)
current_session.disconnected_coords = coordinates
current_session.disconnected_at = timestamp
await current_session.save()
async def get_steam_summary(self) -> SteamProfileSummary | None: async def get_steam_summary(self) -> SteamProfileSummary | None:
""" """
Returns the linked steam profile summary or `NoneType` if it doesn't exist.
""" """
return await SteamProfileSummary.get_or_none(player=self) return await SteamProfileSummary.get_or_none(player=self)
async def update_steam_summary(self, steam_id: str | int, steam_api_key: str) -> SteamProfileSummary: @staticmethod
async def fetch_steam_summary_data(steam_id: str | int, steam_api_key: str | int) -> dict:
""" """
Fetches and returns the raw data of a steam profile summary for the given steam user ID.
""" """
log.debug("Updating Steam summary for player: %s", self.username)
if not steam_api_key: if not steam_api_key:
raise ValueError("No Steam API key provided, can't get profile summary.") raise ValueError("No Steam API key provided, can't get profile summary.")
@ -113,39 +203,55 @@ class Player(Model):
if not profiles: if not profiles:
raise ValueError("No profiles found in response") raise ValueError("No profiles found in response")
profile = profiles[0] return profiles[0]
summary, created = await SteamProfileSummary.get_or_create( async def update_outdated_steam_summary(
self,
steam_id: str | int,
steam_api_key: str
) -> SteamProfileSummary:
"""
Updates the linked steam profile summary if missing or outdated.
Returns the resulting summary, regardless of whether it's updated or not.
"""
log.debug("Checking steam summary for player: %s", self.username)
summary = await self.get_steam_summary()
# If the summary exists and isn't outdated then return it, no work to be done!
if summary and summary.last_update + timedelta(days=1) > datetime.now(tz=utc):
return summary
# Update if summary is NoneType or older than 1 day
log.info("Steam summary missing or outdated, updating: %s", self.username)
data = await self.fetch_steam_summary_data(steam_id, steam_api_key)
summary, created = await SteamProfileSummary.update_or_create(
steam_id=steam_id, steam_id=steam_id,
defaults={ defaults={
"player": self, "player": self,
"profile_name": profile.get("personaname"), "profile_name": data.get("personaname"),
"url": profile.get("profileurl"), "url": data.get("profileurl"),
"avatar_url": profile.get("avatarfull") "avatar_url": data.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 return summary
async def get_embed(self) -> Embed: async def get_embed(self) -> Embed:
log.info("Creating an embed for player: %s", self.username)
summary = await self.get_steam_summary() summary = await self.get_steam_summary()
if not summary: if not summary:
raise ValueError("You must fetch the steam_profile_summary before creating an embed.") raise ValueError("You must fetch the steam_profile_summary before creating an embed.")
death_count = len(await self.get_deaths()) death_count = len(await self.get_deaths())
playtime = str(await self.get_playtime()).split(".")[0] # remove the miliseconds
log.debug("death count is: %s and playtime is: %s", death_count, playtime)
embed = Embed( embed = Embed(
title="Player", title="Player",
description=( description=(
f"{self.username} ([{summary.profile_name}]({summary.url}))\n" f"{self.username} ([{summary.profile_name}]({summary.url}))\n"
f"Deaths: {death_count}\n" f"Deaths: {death_count}\n"
f"Playtime ???" f"Playtime: {playtime}"
) )
) )
embed.set_thumbnail(url=summary.avatar_url) embed.set_thumbnail(url=summary.avatar_url)

View File

@ -13,11 +13,12 @@ log = logging.getLogger(__name__)
class LogFileReader: class LogFileReader:
""" """
""" """
def __init__(self, file_path: Path): def __init__(self, file_path: Path, track_from_start: bool=False):
if not isinstance(file_path, Path): if not isinstance(file_path, Path):
raise TypeError(f"file_path must be type Path, not {type(file_path)}") raise TypeError(f"file_path must be type Path, not {type(file_path)}")
self.file_path = file_path self.file_path = file_path
self.track_from_start = track_from_start
self._last_line_number = 0 self._last_line_number = 0
log.debug("%s created with path %s", self.__class__.__name__, str(file_path)) log.debug("%s created with path %s", self.__class__.__name__, str(file_path))
@ -27,7 +28,8 @@ class LogFileReader:
raise FileNotFoundError(self.file_path) raise FileNotFoundError(self.file_path)
async with aiofiles.open(self.file_path, "r", encoding="utf-8") as file: async with aiofiles.open(self.file_path, "r", encoding="utf-8") as file:
if self._last_line_number == 0: log.debug("file open, and jumping to line: %s", self._last_line_number)
if self._last_line_number == 0 and not self.track_from_start:
await file.seek(0, 2) await file.seek(0, 2)
self._last_line_number = await file.tell() self._last_line_number = await file.tell()
return [] return []