From 77cb214276f2d924a0d89029309ed273da99c653 Mon Sep 17 00:00:00 2001 From: Corban-Lee Jones Date: Wed, 11 Dec 2024 15:37:19 +0000 Subject: [PATCH 01/29] debug mode var and ingame_channel shorthand getter --- bot.py | 25 ++++++++++++++++--------- cogs/players.py | 35 ++++++++++++++++------------------- 2 files changed, 32 insertions(+), 28 deletions(-) diff --git a/bot.py b/bot.py index 56668d7..2db1a88 100644 --- a/bot.py +++ b/bot.py @@ -11,7 +11,7 @@ import logging.config from os import getenv from pathlib import Path -from discord import Intents +from discord import Intents, TextChannel from discord.ext import commands from dotenv import load_dotenv from tortoise import Tortoise @@ -28,12 +28,9 @@ class DiscordBot(commands.Bot): Represents a Discord bot. Contains controls to interact with the bot via the Discord API. """ - in_game_channel_id: int - steam_api_key: str - rcon_details: dict - - def __init__(self): + def __init__(self, debug_mode: bool): super().__init__(command_prefix="-", intents=Intents.all()) + self.debug_mode = debug_mode self.in_game_channel_id = int(getenv("SPIFFO__DISCORD_CHANNEL_ID")) self.steam_api_key = getenv("SPIFFO__STEAM_API_KEY") self.rcon_details = { @@ -75,6 +72,12 @@ class DiscordBot(commands.Bot): if path.suffix == ".py": await self.load_extension(f"cogs.{path.stem}") + async def get_ingame_channel(self) -> TextChannel: + """ + """ + channel = self.bot.get_channel(self.bot.in_game_channel_id) + return channel or await self.bot.fetch_channel(self.bot.in_game_channel_id) + def get_bot_token() -> str: """ @@ -87,7 +90,7 @@ def get_bot_token() -> str: return bot_token -def setup_logging_config(): +def setup_logging_config(debug_mode: bool): """ Loads the logging configuration and creates an asynchronous queue handler. """ @@ -101,6 +104,9 @@ def setup_logging_config(): # Ensure the logging directory exists (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 logging.config.dictConfig(log_config) # This 'logging.config' path is jank. @@ -114,10 +120,11 @@ async def main(): """ 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() - async with DiscordBot() as bot: + async with DiscordBot(debug_mode) as bot: await bot.load_cogs() await bot.start(bot_token, reconnect=True) diff --git a/cogs/players.py b/cogs/players.py index 11fa4aa..c0ca59c 100644 --- a/cogs/players.py +++ b/cogs/players.py @@ -16,11 +16,7 @@ from utils.models import 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.name.endswith("_user.txt"): - USER_LOG_FILE_PATH = path - break +USER_LOG_FILE_PATH = next(LOGS_FOLDER_PATH.glob("*_user.txt"), None) log = logging.getLogger(__name__) @@ -38,6 +34,8 @@ class PlayersCog(commands.Cog): @tasks.loop(seconds=3) async def listen_for_changes(self): + """ + """ log.debug("listening for changes") for line in await self.file_handler.read(): await self.process_log_line(line) @@ -52,7 +50,7 @@ class PlayersCog(commands.Cog): elif "disconnected player" in line: await self.process_disconnected_player(line) - async def process_player_death(self, line: str): + async def process_player_death(self, line: str, alert: bool = False): log.debug("processing player death") re_pattern = r"\[(?P[\d\- :\.]+)\] user (?P.+?) died at \((?P\d+),(?P\d+),(?P\d+)\) \((?P.+?)\)" re_match = re.search(re_pattern, line) @@ -78,19 +76,19 @@ class PlayersCog(commands.Cog): ) await player.save() - channel = self.bot.get_channel(self.bot.in_game_channel_id) - channel = channel or await self.bot.fetch_channel(self.bot.in_game_channel_id) + log.debug("successfully registered player death to %s", re_match.group("username")) + if not alert: + return + + channel = await self.bot.get_ingame_channel() embed = await player.get_embed() embed.title = "Player Has Died" embed.colour = Colour.dark_orange() 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): + async def process_connected_player(self, line: str, alert: bool = False): """ """ log.debug("processing connected player") @@ -115,16 +113,17 @@ class PlayersCog(commands.Cog): await player.save() return - channel = self.bot.get_channel(self.bot.in_game_channel_id) - 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.title = "Player Has Connected" embed.colour = Colour.brand_green() 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") @@ -143,12 +142,10 @@ class PlayersCog(commands.Cog): await player.update_steam_summary(re_match.group("steam_id"), self.bot.steam_api_key) await player.save() - if player.is_dead: + if player.is_dead or not alert: return - channel = self.bot.get_channel(self.bot.in_game_channel_id) - channel = channel or await self.bot.fetch_channel(self.bot.in_game_channel_id) - + channel = await self.bot.get_ingame_channel() embed = await player.get_embed() embed.title = "Player Has Disconnected" embed.colour = Colour.brand_red() From 6e780f5b04474c9a99f8ec1549b4064afd368ded Mon Sep 17 00:00:00 2001 From: Corban-Lee Jones Date: Wed, 11 Dec 2024 17:37:20 +0000 Subject: [PATCH 02/29] init and build db earlier --- bot.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/bot.py b/bot.py index 2db1a88..76140df 100644 --- a/bot.py +++ b/bot.py @@ -48,14 +48,6 @@ class DiscordBot(commands.Bot): # Sync app commands await self.wait_until_ready() 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") async def close(self): @@ -124,6 +116,13 @@ async def main(): setup_logging_config(debug_mode) bot_token = get_bot_token() + # Open database connection + await Tortoise.init( + db_url= f"sqlite://{str(DATA_DIR)}/db.sqlite", + modules={"models": ["utils.models"]} + ) + await Tortoise.generate_schemas() + async with DiscordBot(debug_mode) as bot: await bot.load_cogs() await bot.start(bot_token, reconnect=True) From fc752d6a84e73801fc7618f23a64e73cb05159f4 Mon Sep 17 00:00:00 2001 From: Corban-Lee Jones Date: Wed, 11 Dec 2024 17:37:35 +0000 Subject: [PATCH 03/29] exclude dev data directories --- .dockerignore | 4 +++- .gitignore | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.dockerignore b/.dockerignore index 1c7a300..d6313bc 100644 --- a/.dockerignore +++ b/.dockerignore @@ -8,4 +8,6 @@ __pycache__/ .vscode/ db.sqlite db.sqlite-shm -db.sqlite-wal \ No newline at end of file +db.sqlite-wal +zomboid/ +data/ \ No newline at end of file diff --git a/.gitignore b/.gitignore index 1cdca00..e97fde2 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,6 @@ __pycache__/ *.pyc db.sqlite db.sqlite-shm -db.sqlite-wal \ No newline at end of file +db.sqlite-wal +zomboid/ +data/ \ No newline at end of file From 6816eb68e8fdef96f2d320771a395b7767aca669 Mon Sep 17 00:00:00 2001 From: Corban-Lee Jones Date: Wed, 11 Dec 2024 17:39:00 +0000 Subject: [PATCH 04/29] build from logs command --- cogs/players.py | 32 +++++++++++++++++++++++++++----- utils/reader.py | 5 +++-- 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/cogs/players.py b/cogs/players.py index c0ca59c..dbca276 100644 --- a/cogs/players.py +++ b/cogs/players.py @@ -8,7 +8,7 @@ from os import getenv from pathlib import Path from datetime import datetime -from discord import Colour +from discord import Colour, Interaction, Permissions, app_commands from discord.ext import commands, tasks from utils.reader import LogFileReader @@ -32,6 +32,28 @@ class PlayersCog(commands.Cog): self.file_handler = LogFileReader(USER_LOG_FILE_PATH) self.listen_for_changes.start() + @app_commands.command(name="build-from-logs") + @app_commands.default_permissions(Permissions.all()) + async def build_from_logs(self, inter: Interaction): + """ + Build player data from existing and older log files. + """ + await inter.response.defer() + 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("**/*.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) + + await inter.followup.send("Completed") + + + @tasks.loop(seconds=3) async def listen_for_changes(self): """ @@ -40,15 +62,15 @@ class PlayersCog(commands.Cog): for line in await self.file_handler.read(): await self.process_log_line(line) - async def process_log_line(self, line: str): + async def process_log_line(self, line: str, alert: bool = True): log.debug("processing log line") if "died" in line: - await self.process_player_death(line) + await self.process_player_death(line, alert) elif "fully connected" in line: - await self.process_connected_player(line) + await self.process_connected_player(line, alert) 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, alert: bool = False): log.debug("processing player death") diff --git a/utils/reader.py b/utils/reader.py index ee55509..f9348d4 100644 --- a/utils/reader.py +++ b/utils/reader.py @@ -13,11 +13,12 @@ log = logging.getLogger(__name__) 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): raise TypeError(f"file_path must be type Path, not {type(file_path)}") self.file_path = file_path + self.track_from_start = track_from_start self._last_line_number = 0 log.debug("%s created with path %s", self.__class__.__name__, str(file_path)) @@ -27,7 +28,7 @@ class LogFileReader: raise FileNotFoundError(self.file_path) async with aiofiles.open(self.file_path, "r", encoding="utf-8") as file: - if self._last_line_number == 0: + if self._last_line_number == 0 and not self.track_from_start: await file.seek(0, 2) self._last_line_number = await file.tell() return [] From 1329ef8bd67aabd0de3f086c062e61ad4592c3e6 Mon Sep 17 00:00:00 2001 From: Corban-Lee Jones Date: Wed, 11 Dec 2024 17:39:02 +0000 Subject: [PATCH 05/29] Update CHANGELOG.md --- CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 21c2a15..f8ee876 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ 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/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- Debug environment variable flag + +### Changed + +- Log level dependent on the debug flag + ## [0.0.3] - 2024-12-09 ### Added From 6faa3cdd01b6f20c8566f4d7a61ec3b7dcdea207 Mon Sep 17 00:00:00 2001 From: Corban-Lee Jones Date: Wed, 11 Dec 2024 17:41:18 +0000 Subject: [PATCH 06/29] command group to fix argument error --- cogs/players.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/cogs/players.py b/cogs/players.py index dbca276..03e047a 100644 --- a/cogs/players.py +++ b/cogs/players.py @@ -32,8 +32,14 @@ class PlayersCog(commands.Cog): self.file_handler = LogFileReader(USER_LOG_FILE_PATH) self.listen_for_changes.start() - @app_commands.command(name="build-from-logs") - @app_commands.default_permissions(Permissions.all()) + 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. From 5d551b3dd8e506d92a59c16c9e6dfb9f32ee8064 Mon Sep 17 00:00:00 2001 From: Corban-Lee Jones Date: Wed, 11 Dec 2024 17:51:43 +0000 Subject: [PATCH 07/29] fix attribute error in 'get_ingame_channel' shorthand method --- bot.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot.py b/bot.py index 76140df..3e2e9a0 100644 --- a/bot.py +++ b/bot.py @@ -67,8 +67,8 @@ class DiscordBot(commands.Bot): async def get_ingame_channel(self) -> TextChannel: """ """ - channel = self.bot.get_channel(self.bot.in_game_channel_id) - return channel or await self.bot.fetch_channel(self.bot.in_game_channel_id) + 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: From 91d96d1bcbb69aa4ef13af3dc5406d08ee1f6d08 Mon Sep 17 00:00:00 2001 From: Corban-Lee Jones Date: Wed, 11 Dec 2024 20:04:30 +0000 Subject: [PATCH 08/29] add aerich --- Dockerfile | 2 +- bot.py | 14 ++++++++++---- pyproject.toml | 4 ++++ requirements.txt | 4 ++++ 4 files changed, 19 insertions(+), 5 deletions(-) create mode 100644 pyproject.toml diff --git a/Dockerfile b/Dockerfile index f0e2f8b..9397202 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,4 +13,4 @@ RUN pip install --no-cache-dir -r requirements.txt COPY . /app/ -CMD ["python", "bot.py"] \ No newline at end of file +CMD ["sh", "-c", "aerich upgrade && python bot.py"] \ No newline at end of file diff --git a/bot.py b/bot.py index 3e2e9a0..60bb677 100644 --- a/bot.py +++ b/bot.py @@ -21,6 +21,15 @@ log = logging.getLogger(__name__) BASE_DIR = Path(__file__).resolve().parent DATA_DIR = getenv("SPIFFO__DATA_FOLDER_PATH") +TORTOISE_ORM = { + "connections": { "default": f"sqlite://{str(DATA_DIR)}/db.sqlite" }, + "apps": { + "models": { + "models": ["utils.models", "aerich.models"], + "default_connection": "default" + } + } +} class DiscordBot(commands.Bot): @@ -117,10 +126,7 @@ async def main(): bot_token = get_bot_token() # Open database connection - await Tortoise.init( - db_url= f"sqlite://{str(DATA_DIR)}/db.sqlite", - modules={"models": ["utils.models"]} - ) + await Tortoise.init(config=TORTOISE_ORM) await Tortoise.generate_schemas() async with DiscordBot(debug_mode) as bot: diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..7f669ab --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,4 @@ +[tool.aerich] +tortoise_orm = "bot.TORTOISE_ORM" +location = "./migrations" +src_folder = "./." diff --git a/requirements.txt b/requirements.txt index 8f37e38..805a5c5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ +aerich==0.8.0 aiofiles==24.1.0 aiohappyeyeballs==2.4.4 aiohttp==3.11.9 @@ -5,9 +6,11 @@ aiosignal==1.3.1 aiosqlite==0.20.0 annotated-types==0.7.0 anyio==4.7.0 +asyncclick==8.1.7.2 attrs==24.2.0 bump2version==1.0.1 certifi==2024.8.30 +dictdiffer==0.9.0 discord.py==2.4.0 frozenlist==1.5.0 h11==0.14.0 @@ -24,6 +27,7 @@ python-dotenv==1.0.1 pytz==2024.2 rcon==2.4.9 sniffio==1.3.1 +tomlkit==0.13.2 tortoise-orm==0.22.1 typing_extensions==4.12.2 yarl==1.18.3 From 07cf930328b4b08e2285521610289dcc820b1b4c Mon Sep 17 00:00:00 2001 From: Corban-Lee Jones Date: Wed, 11 Dec 2024 20:04:37 +0000 Subject: [PATCH 09/29] initial migration --- migrations/models/0_20241211195324_init.py | 42 ++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 migrations/models/0_20241211195324_init.py diff --git a/migrations/models/0_20241211195324_init.py b/migrations/models/0_20241211195324_init.py new file mode 100644 index 0000000..4ee23ba --- /dev/null +++ b/migrations/models/0_20241211195324_init.py @@ -0,0 +1,42 @@ +from tortoise import BaseDBAsyncClient + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + CREATE TABLE IF NOT EXISTS "players" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "username" VARCHAR(20) NOT NULL UNIQUE, + "last_connection" TIMESTAMP, + "last_disconnection" TIMESTAMP, + "play_time_seconds" INT NOT NULL DEFAULT 0, + "is_dead" INT NOT NULL DEFAULT 0 +) /* */; +CREATE TABLE IF NOT EXISTS "player_deaths" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "coordinate_x" INT NOT NULL, + "coordinate_y" INT NOT NULL, + "coordinate_z" INT NOT NULL, + "cause" VARCHAR(32) NOT NULL, + "timestamp" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + "player_id" INT NOT NULL REFERENCES "players" ("id") ON DELETE CASCADE +); +CREATE TABLE IF NOT EXISTS "steam_profile_summary" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "steam_id" VARCHAR(20) NOT NULL UNIQUE, + "profile_name" VARCHAR(32) NOT NULL, + "url" VARCHAR(128) NOT NULL, + "avatar_url" VARCHAR(128) NOT NULL, + "last_update" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + "player_id" INT NOT NULL REFERENCES "players" ("id") ON DELETE CASCADE +); +CREATE TABLE IF NOT EXISTS "aerich" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "version" VARCHAR(255) NOT NULL, + "app" VARCHAR(100) NOT NULL, + "content" JSON NOT NULL +);""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + """ From e3da8ec805878c0fc337c37fdad07e6137ad8d8f Mon Sep 17 00:00:00 2001 From: Corban-Lee Jones Date: Wed, 11 Dec 2024 20:07:12 +0000 Subject: [PATCH 10/29] update changelog --- CHANGELOG.md | 7 +++++-- cogs/players.py | 2 -- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f8ee876..34896ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,11 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Debug environment variable flag +- Debug environment variable flag. +- Command to build player data from existing log files. +- Database migration management with the aerich package. ### Changed -- Log level dependent on the debug flag +- Log level dependent on the debug flag. +- Connect to and build the database earlier into start-up. ## [0.0.3] - 2024-12-09 diff --git a/cogs/players.py b/cogs/players.py index 03e047a..2a961c0 100644 --- a/cogs/players.py +++ b/cogs/players.py @@ -58,8 +58,6 @@ class PlayersCog(commands.Cog): await inter.followup.send("Completed") - - @tasks.loop(seconds=3) async def listen_for_changes(self): """ From 55304170b1fcd2e7dc0b253abba08dbe0c4d45f8 Mon Sep 17 00:00:00 2001 From: Corban-Lee Jones Date: Wed, 11 Dec 2024 21:41:21 +0000 Subject: [PATCH 11/29] player sessions and coordinate models --- cogs/players.py | 20 ++++--- utils/models.py | 137 +++++++++++++++++++++++++++++++++++++----------- 2 files changed, 118 insertions(+), 39 deletions(-) diff --git a/cogs/players.py b/cogs/players.py index 2a961c0..7afe9a2 100644 --- a/cogs/players.py +++ b/cogs/players.py @@ -118,7 +118,7 @@ class PlayersCog(commands.Cog): """ """ log.debug("processing connected player") - re_pattern = r'\[(?P.*?)\] (?P\d+) "(?P.*?)" fully connected \((?P\d+,\d+,\d+)\)' + re_pattern = r'\[(?P.*?)\] (?P\d+) "(?P.*?)" fully connected \((?P\d+),(?P\d+),(?P\d+)\)' re_match = re.search(re_pattern, line) if not re_match: @@ -126,9 +126,11 @@ class PlayersCog(commands.Cog): return player, created = await Player.get_or_create(username=re_match.group("username")) - player.last_connection = datetime.strptime( - re_match.group("timestamp"), - "%m-%d-%y %H:%M:%S.%f" + await player.open_session( + timestamp=datetime.strptime(re_match.group("timestamp"), "%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_steam_summary(re_match.group("steam_id"), self.bot.steam_api_key) await player.save() @@ -153,7 +155,7 @@ class PlayersCog(commands.Cog): """ """ log.debug("processing disconnected player") - re_pattern = r'\[(?P.*?)\] (?P\d+) "(?P.*?)" disconnected player \((?P\d+,\d+,\d+)\)' + re_pattern = r'\[(?P.*?)\] (?P\d+) "(?P.*?)" disconnected player \((?P\d+),(?P\d+),(?P\d+)\)' re_match = re.search(re_pattern, line) if not re_match: @@ -161,9 +163,11 @@ class PlayersCog(commands.Cog): return player, created = await Player.get_or_create(username=re_match.group("username")) - player.last_disconnection = datetime.strptime( - re_match.group("timestamp"), - "%m-%d-%y %H:%M:%S.%f" + await player.close_session( + timestamp=datetime.strptime(re_match.group("timestamp"), "%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_steam_summary(re_match.group("steam_id"), self.bot.steam_api_key) await player.save() diff --git a/utils/models.py b/utils/models.py index 1704fcb..abd10f6 100644 --- a/utils/models.py +++ b/utils/models.py @@ -3,12 +3,13 @@ Database schemas. """ import logging -from datetime import datetime +from datetime import datetime, timedelta import httpx from tortoise import fields -from tortoise.queryset import QuerySet -from tortoise.models import Model +from tortoise.functions import Sum +from tortoise.expressions import F +from tortoise.models import Model, Q from discord import Embed log = logging.getLogger(__name__) @@ -29,68 +30,141 @@ class SteamProfileSummary(Model): table = "steam_profile_summary" +class Coordinates(Model): + x = fields.IntField() + y = fields.IntField() + z = fields.IntField() + + class Meta: + table = "ingame_coordinates" + + 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() cause = fields.CharField(max_length=32) timestamp = fields.DatetimeField(auto_now_add=True) + coordinates = fields.ForeignKeyField( + model_name="models.Coordinates", + on_delete=fields.CASCADE + ) class Meta: 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=False) + connected_coords = fields.ForeignKeyField( + model_name="models.Coordinates", + on_delete=fields.CASCADE + ) + disconnected_coords = fields.ForeignKeyField( + model_name="models.Coordinates", + on_delete=fields.CASCADE, + null=True + ) + + class Meta: + table = "player_session" + + @property + def playtime(self) -> timedelta: + if not self.disconnected_at: + return datetime.now() - self.connected_at + + return self.disconnected_at - self.connected_at + + class Player(Model): """ """ 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) class Meta: table = "players" - @property - def is_online(self) -> bool: - """ - """ - if not self.last_connection: - return False + async def get_playtime(self) -> timedelta: + playtime = await PlayerSession.filter(player=self).exclude(disconnected_at=None).annotate( + total_playtime=Sum(F("disconnected_at") - F("connected_at")) + ).values() + return timedelta(seconds=playtime[0]["total_playtime"].total_seconds() if playtime else timedelta()) - return (self.last_connection and not self.last_disconnection) \ - or self.last_connection > self.last_disconnection - - async def get_deaths(self) -> QuerySet[PlayerDeath]: + async def get_deaths(self) -> list[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: + 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) self.is_dead = True await self.save() + coordinates = await Coordinates.create(x=coord_x, y=coord_y, z=coord_z) return await PlayerDeath.create( player=self, - coordinate_x=coord_x, - coordinate_y=coord_y, - coordinate_z=coord_z, 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.debug("creating session for player: %s", self.username) + existing_session = await self.get_latest_session(ignore_closed_sessions=True) + if existing_session: + raise ValueError("Tried to open session while an open one exists.") + + 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.debug("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: """ """ @@ -139,13 +213,14 @@ class Player(Model): raise ValueError("You must fetch the steam_profile_summary before creating an embed.") death_count = len(await self.get_deaths()) + playtime = str(await self.get_playtime()) embed = Embed( title="Player", description=( f"{self.username} ([{summary.profile_name}]({summary.url}))\n" f"Deaths: {death_count}\n" - f"Playtime ???" + f"Playtime: {playtime}" ) ) embed.set_thumbnail(url=summary.avatar_url) From 825843ed86aca21e35e475ecfdf1d31b2a6be4b2 Mon Sep 17 00:00:00 2001 From: Corban-Lee Jones Date: Wed, 11 Dec 2024 22:55:08 +0000 Subject: [PATCH 12/29] remove that stupid package aerich, it's terrible! --- CHANGELOG.md | 1 - Dockerfile | 2 +- bot.py | 2 +- ...95324_init.py => 0_20241211214800_init.py} | 23 +++++++++++++------ requirements.txt | 1 - 5 files changed, 18 insertions(+), 11 deletions(-) rename migrations/models/{0_20241211195324_init.py => 0_20241211214800_init.py} (61%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 34896ac..6999fe3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Debug environment variable flag. - Command to build player data from existing log files. -- Database migration management with the aerich package. ### Changed diff --git a/Dockerfile b/Dockerfile index 9397202..f0e2f8b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,4 +13,4 @@ RUN pip install --no-cache-dir -r requirements.txt COPY . /app/ -CMD ["sh", "-c", "aerich upgrade && python bot.py"] \ No newline at end of file +CMD ["python", "bot.py"] \ No newline at end of file diff --git a/bot.py b/bot.py index 60bb677..59a7f4e 100644 --- a/bot.py +++ b/bot.py @@ -25,7 +25,7 @@ TORTOISE_ORM = { "connections": { "default": f"sqlite://{str(DATA_DIR)}/db.sqlite" }, "apps": { "models": { - "models": ["utils.models", "aerich.models"], + "models": ["utils.models"], "default_connection": "default" } } diff --git a/migrations/models/0_20241211195324_init.py b/migrations/models/0_20241211214800_init.py similarity index 61% rename from migrations/models/0_20241211195324_init.py rename to migrations/models/0_20241211214800_init.py index 4ee23ba..d35ecb2 100644 --- a/migrations/models/0_20241211195324_init.py +++ b/migrations/models/0_20241211214800_init.py @@ -3,21 +3,30 @@ from tortoise import BaseDBAsyncClient async def upgrade(db: BaseDBAsyncClient) -> str: return """ - CREATE TABLE IF NOT EXISTS "players" ( + CREATE TABLE IF NOT EXISTS "ingame_coordinates" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "x" INT NOT NULL, + "y" INT NOT NULL, + "z" INT NOT NULL +); +CREATE TABLE IF NOT EXISTS "players" ( "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "username" VARCHAR(20) NOT NULL UNIQUE, - "last_connection" TIMESTAMP, - "last_disconnection" TIMESTAMP, - "play_time_seconds" INT NOT NULL DEFAULT 0, "is_dead" INT NOT NULL DEFAULT 0 ) /* */; CREATE TABLE IF NOT EXISTS "player_deaths" ( "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, - "coordinate_x" INT NOT NULL, - "coordinate_y" INT NOT NULL, - "coordinate_z" INT NOT NULL, "cause" VARCHAR(32) NOT NULL, "timestamp" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + "coordinates_id" INT NOT NULL REFERENCES "ingame_coordinates" ("id") ON DELETE CASCADE, + "player_id" INT NOT NULL REFERENCES "players" ("id") ON DELETE CASCADE +); +CREATE TABLE IF NOT EXISTS "player_session" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "connected_at" TIMESTAMP NOT NULL, + "disconnected_at" TIMESTAMP NOT NULL, + "connected_coords_id" INT NOT NULL REFERENCES "ingame_coordinates" ("id") ON DELETE CASCADE, + "disconnected_coords_id" INT REFERENCES "ingame_coordinates" ("id") ON DELETE CASCADE, "player_id" INT NOT NULL REFERENCES "players" ("id") ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS "steam_profile_summary" ( diff --git a/requirements.txt b/requirements.txt index 805a5c5..a60c0be 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,3 @@ -aerich==0.8.0 aiofiles==24.1.0 aiohappyeyeballs==2.4.4 aiohttp==3.11.9 From b2847ebe956ccbc8883e043f3a868f412dac5865 Mon Sep 17 00:00:00 2001 From: Corban-Lee Jones Date: Wed, 11 Dec 2024 22:55:24 +0000 Subject: [PATCH 13/29] user log files only and handle task around build --- cogs/players.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cogs/players.py b/cogs/players.py index 7afe9a2..1967738 100644 --- a/cogs/players.py +++ b/cogs/players.py @@ -45,17 +45,19 @@ class PlayersCog(commands.Cog): 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("**/*.txt"): + 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") @tasks.loop(seconds=3) From 1ff4f4e831082c70f76cbb3aa5962d8d444d0861 Mon Sep 17 00:00:00 2001 From: Corban-Lee Jones Date: Wed, 11 Dec 2024 22:55:39 +0000 Subject: [PATCH 14/29] get total playtime from player --- utils/models.py | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/utils/models.py b/utils/models.py index abd10f6..30d29ee 100644 --- a/utils/models.py +++ b/utils/models.py @@ -4,6 +4,7 @@ Database schemas. import logging from datetime import datetime, timedelta +from pytz import timezone import httpx from tortoise import fields @@ -61,13 +62,15 @@ class PlayerSession(Model): on_delete=fields.CASCADE ) connected_at = fields.DatetimeField() - disconnected_at = fields.DatetimeField(null=False) + 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 ) @@ -93,10 +96,18 @@ class Player(Model): table = "players" async def get_playtime(self) -> timedelta: - playtime = await PlayerSession.filter(player=self).exclude(disconnected_at=None).annotate( - total_playtime=Sum(F("disconnected_at") - F("connected_at")) - ).values() - return timedelta(seconds=playtime[0]["total_playtime"].total_seconds() if playtime else timedelta()) + sessions = await PlayerSession.filter(player=self) + total_playtime = timedelta() + now = datetime.now() + utc = timezone("UTC") + + # I know this is terrible efficiency-wise, but the tortoise docs + # are so bad and the annotations don't work like Django's models. Deal with it! + for session in sessions: + disconnected_at = session.disconnected_at or now + total_playtime += disconnected_at.astimezone(utc) - session.connected_at.astimezone(utc) + + return total_playtime async def get_deaths(self) -> list[PlayerDeath]: return await PlayerDeath.filter(player=self) @@ -139,7 +150,8 @@ class Player(Model): log.debug("creating session for player: %s", self.username) existing_session = await self.get_latest_session(ignore_closed_sessions=True) if existing_session: - raise ValueError("Tried to open session while an open one exists.") + 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( @@ -213,7 +225,8 @@ class Player(Model): raise ValueError("You must fetch the steam_profile_summary before creating an embed.") death_count = len(await self.get_deaths()) - playtime = str(await self.get_playtime()) + 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( title="Player", From 41b50e352f008eb085f67217395d3232735ce860 Mon Sep 17 00:00:00 2001 From: Corban-Lee Jones Date: Wed, 11 Dec 2024 22:55:55 +0000 Subject: [PATCH 15/29] detailed debug llog for file reader --- utils/reader.py | 1 + 1 file changed, 1 insertion(+) diff --git a/utils/reader.py b/utils/reader.py index f9348d4..a49ebad 100644 --- a/utils/reader.py +++ b/utils/reader.py @@ -28,6 +28,7 @@ class LogFileReader: raise FileNotFoundError(self.file_path) async with aiofiles.open(self.file_path, "r", encoding="utf-8") as file: + 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) self._last_line_number = await file.tell() From 2ed97dba5f356ca256bfaba5513e3d01f9c072e0 Mon Sep 17 00:00:00 2001 From: Corban-Lee Jones Date: Wed, 11 Dec 2024 23:13:20 +0000 Subject: [PATCH 16/29] promote debug logs to info & change playtime calc --- utils/models.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/utils/models.py b/utils/models.py index 30d29ee..72d029c 100644 --- a/utils/models.py +++ b/utils/models.py @@ -96,6 +96,7 @@ class Player(Model): table = "players" async def get_playtime(self) -> timedelta: + log.info("Getting total playtime for player: %s", self.username) sessions = await PlayerSession.filter(player=self) total_playtime = timedelta() now = datetime.now() @@ -104,8 +105,7 @@ class Player(Model): # I know this is terrible efficiency-wise, but the tortoise docs # are so bad and the annotations don't work like Django's models. Deal with it! for session in sessions: - disconnected_at = session.disconnected_at or now - total_playtime += disconnected_at.astimezone(utc) - session.connected_at.astimezone(utc) + total_playtime += session.playtime return total_playtime @@ -122,7 +122,7 @@ class Player(Model): ) -> PlayerDeath: """ """ - log.debug("Assigning death to player: %s", self.username) + log.info("Assigning death to player: %s", self.username) self.is_dead = True await self.save() coordinates = await Coordinates.create(x=coord_x, y=coord_y, z=coord_z) @@ -147,7 +147,7 @@ class Player(Model): coord_y: str | int, coord_z: str | int, ) -> PlayerSession: - log.debug("creating session for player: %s", self.username) + 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") @@ -167,7 +167,7 @@ class Player(Model): coord_y: str | int, coord_z: str | int, ) -> PlayerSession: - log.debug("closing session for player: %s", self.username) + 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.") @@ -185,7 +185,7 @@ class Player(Model): 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) + log.info("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.") @@ -220,6 +220,7 @@ class Player(Model): return summary async def get_embed(self) -> Embed: + log.info("Creating an embed for player: %s", self.username) summary = await self.get_steam_summary() if not summary: raise ValueError("You must fetch the steam_profile_summary before creating an embed.") From a8d4b8259a5ef0c49e26113abeda2a20a2399138 Mon Sep 17 00:00:00 2001 From: Corban-Lee Jones Date: Wed, 11 Dec 2024 23:17:12 +0000 Subject: [PATCH 17/29] fix timezone aware issue in playtime calc --- utils/models.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/utils/models.py b/utils/models.py index 72d029c..d96518f 100644 --- a/utils/models.py +++ b/utils/models.py @@ -8,12 +8,11 @@ from pytz import timezone import httpx from tortoise import fields -from tortoise.functions import Sum -from tortoise.expressions import F -from tortoise.models import Model, Q +from tortoise.models import Model from discord import Embed log = logging.getLogger(__name__) +utc = timezone("UTC") class SteamProfileSummary(Model): @@ -81,7 +80,7 @@ class PlayerSession(Model): @property def playtime(self) -> timedelta: if not self.disconnected_at: - return datetime.now() - self.connected_at + return datetime.now(tz=utc) - self.connected_at return self.disconnected_at - self.connected_at @@ -99,8 +98,6 @@ class Player(Model): log.info("Getting total playtime for player: %s", self.username) sessions = await PlayerSession.filter(player=self) total_playtime = timedelta() - now = datetime.now() - utc = timezone("UTC") # I know this is terrible efficiency-wise, but the tortoise docs # are so bad and the annotations don't work like Django's models. Deal with it! From 52e42d42ca743338c9013dd5bc2049e327e3edd1 Mon Sep 17 00:00:00 2001 From: Corban-Lee Jones Date: Wed, 11 Dec 2024 23:23:07 +0000 Subject: [PATCH 18/29] Update models.py --- utils/models.py | 1 + 1 file changed, 1 insertion(+) diff --git a/utils/models.py b/utils/models.py index d96518f..b79e7ea 100644 --- a/utils/models.py +++ b/utils/models.py @@ -102,6 +102,7 @@ class Player(Model): # I know this is terrible efficiency-wise, but the tortoise docs # are so bad and the annotations don't work like Django's models. Deal with it! for session in sessions: + log.info("session playtime: %s", session.playtime) total_playtime += session.playtime return total_playtime From da217c12425000cbc7836cda378616014814046e Mon Sep 17 00:00:00 2001 From: Corban-Lee Jones Date: Wed, 11 Dec 2024 23:30:29 +0000 Subject: [PATCH 19/29] better logging --- cogs/players.py | 4 ++-- utils/models.py | 7 ++++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/cogs/players.py b/cogs/players.py index 1967738..d2d8698 100644 --- a/cogs/players.py +++ b/cogs/players.py @@ -151,7 +151,7 @@ class PlayersCog(commands.Cog): embed.title = "Player Has Connected" embed.colour = Colour.brand_green() - await channel.send(embed=embed) + # await channel.send(embed=embed) async def process_disconnected_player(self, line: str, alert: bool = False): """ @@ -182,7 +182,7 @@ class PlayersCog(commands.Cog): embed.title = "Player Has Disconnected" embed.colour = Colour.brand_red() - await channel.send(embed=embed) + # await channel.send(embed=embed) async def setup(bot: commands.Bot): diff --git a/utils/models.py b/utils/models.py index b79e7ea..e29c5b1 100644 --- a/utils/models.py +++ b/utils/models.py @@ -102,7 +102,12 @@ class Player(Model): # I know this is terrible efficiency-wise, but the tortoise docs # are so bad and the annotations don't work like Django's models. Deal with it! for session in sessions: - log.info("session playtime: %s", session.playtime) + log.info( + "session start: %s\nsession end: %s\nsession playtime: %s", + session.connected_at, + session.disconnected_at, + session.playtime + ) total_playtime += session.playtime return total_playtime From edf901b6479bf80e66c37c513ee1b8736342b1f6 Mon Sep 17 00:00:00 2001 From: Corban-Lee Jones Date: Thu, 12 Dec 2024 16:08:21 +0000 Subject: [PATCH 20/29] stop unnecessary request to steam API --- cogs/players.py | 10 ++++++++-- utils/models.py | 46 ++++++++++++++++++++++++++++++---------------- 2 files changed, 38 insertions(+), 18 deletions(-) diff --git a/cogs/players.py b/cogs/players.py index d2d8698..106412e 100644 --- a/cogs/players.py +++ b/cogs/players.py @@ -134,7 +134,10 @@ class PlayersCog(commands.Cog): coord_y=re_match.group("y"), coord_z=re_match.group("z") ) - await player.update_steam_summary(re_match.group("steam_id"), self.bot.steam_api_key) + await player.update_outdated_steam_summary( + re_match.group("steam_id"), + self.bot.steam_api_key + ) await player.save() # This connection method is called when the player respawns @@ -171,7 +174,10 @@ class PlayersCog(commands.Cog): coord_y=re_match.group("y"), coord_z=re_match.group("z") ) - await player.update_steam_summary(re_match.group("steam_id"), self.bot.steam_api_key) + await player.update_outdated_steam_summary( + re_match.group("steam_id"), + self.bot.steam_api_key + ) await player.save() if player.is_dead or not alert: diff --git a/utils/models.py b/utils/models.py index e29c5b1..8fa7908 100644 --- a/utils/models.py +++ b/utils/models.py @@ -103,7 +103,7 @@ class Player(Model): # are so bad and the annotations don't work like Django's models. Deal with it! for session in sessions: log.info( - "session start: %s\nsession end: %s\nsession playtime: %s", + "playtime info:\nsession start: %s\nsession end: %s\nsession playtime: %s", session.connected_at, session.disconnected_at, session.playtime @@ -182,14 +182,15 @@ class Player(Model): 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) - 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.info("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.") @@ -202,24 +203,37 @@ class Player(Model): if not profiles: 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.today(): + 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, defaults={ "player": self, - "profile_name": profile.get("personaname"), - "url": profile.get("profileurl"), - "avatar_url": profile.get("avatarfull") + "profile_name": data.get("personaname"), + "url": data.get("profileurl"), + "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 async def get_embed(self) -> Embed: From d929dc875dbb20b3f186a52b6fe6bb7fd5f5edc8 Mon Sep 17 00:00:00 2001 From: Corban-Lee Jones Date: Thu, 12 Dec 2024 16:23:08 +0000 Subject: [PATCH 21/29] delete unused aerich migrations --- migrations/models/0_20241211214800_init.py | 51 ---------------------- 1 file changed, 51 deletions(-) delete mode 100644 migrations/models/0_20241211214800_init.py diff --git a/migrations/models/0_20241211214800_init.py b/migrations/models/0_20241211214800_init.py deleted file mode 100644 index d35ecb2..0000000 --- a/migrations/models/0_20241211214800_init.py +++ /dev/null @@ -1,51 +0,0 @@ -from tortoise import BaseDBAsyncClient - - -async def upgrade(db: BaseDBAsyncClient) -> str: - return """ - CREATE TABLE IF NOT EXISTS "ingame_coordinates" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, - "x" INT NOT NULL, - "y" INT NOT NULL, - "z" INT NOT NULL -); -CREATE TABLE IF NOT EXISTS "players" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, - "username" VARCHAR(20) NOT NULL UNIQUE, - "is_dead" INT NOT NULL DEFAULT 0 -) /* */; -CREATE TABLE IF NOT EXISTS "player_deaths" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, - "cause" VARCHAR(32) NOT NULL, - "timestamp" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - "coordinates_id" INT NOT NULL REFERENCES "ingame_coordinates" ("id") ON DELETE CASCADE, - "player_id" INT NOT NULL REFERENCES "players" ("id") ON DELETE CASCADE -); -CREATE TABLE IF NOT EXISTS "player_session" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, - "connected_at" TIMESTAMP NOT NULL, - "disconnected_at" TIMESTAMP NOT NULL, - "connected_coords_id" INT NOT NULL REFERENCES "ingame_coordinates" ("id") ON DELETE CASCADE, - "disconnected_coords_id" INT REFERENCES "ingame_coordinates" ("id") ON DELETE CASCADE, - "player_id" INT NOT NULL REFERENCES "players" ("id") ON DELETE CASCADE -); -CREATE TABLE IF NOT EXISTS "steam_profile_summary" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, - "steam_id" VARCHAR(20) NOT NULL UNIQUE, - "profile_name" VARCHAR(32) NOT NULL, - "url" VARCHAR(128) NOT NULL, - "avatar_url" VARCHAR(128) NOT NULL, - "last_update" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - "player_id" INT NOT NULL REFERENCES "players" ("id") ON DELETE CASCADE -); -CREATE TABLE IF NOT EXISTS "aerich" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, - "version" VARCHAR(255) NOT NULL, - "app" VARCHAR(100) NOT NULL, - "content" JSON NOT NULL -);""" - - -async def downgrade(db: BaseDBAsyncClient) -> str: - return """ - """ From d02794709cbde1a799b774c6f85dab0f2c986369 Mon Sep 17 00:00:00 2001 From: Corban-Lee Jones Date: Thu, 12 Dec 2024 16:44:29 +0000 Subject: [PATCH 22/29] fix incorrect timestamp parse string --- cogs/players.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/cogs/players.py b/cogs/players.py index 106412e..8ed40c8 100644 --- a/cogs/players.py +++ b/cogs/players.py @@ -129,7 +129,7 @@ class PlayersCog(commands.Cog): player, created = await Player.get_or_create(username=re_match.group("username")) await player.open_session( - timestamp=datetime.strptime(re_match.group("timestamp"), "%m-%d-%y %H:%M:%S.%f"), + timestamp=datetime.strptime(re_match.group("timestamp"), "%d-%m-%y %H:%M:%S.%f"), coord_x=re_match.group("x"), coord_y=re_match.group("y"), coord_z=re_match.group("z") @@ -154,7 +154,7 @@ class PlayersCog(commands.Cog): embed.title = "Player Has Connected" embed.colour = Colour.brand_green() - # await channel.send(embed=embed) + await channel.send(embed=embed) async def process_disconnected_player(self, line: str, alert: bool = False): """ @@ -169,7 +169,7 @@ class PlayersCog(commands.Cog): player, created = await Player.get_or_create(username=re_match.group("username")) await player.close_session( - timestamp=datetime.strptime(re_match.group("timestamp"), "%m-%d-%y %H:%M:%S.%f"), + timestamp=datetime.strptime(re_match.group("timestamp"), "%d-%m-%y %H:%M:%S.%f"), coord_x=re_match.group("x"), coord_y=re_match.group("y"), coord_z=re_match.group("z") @@ -188,7 +188,7 @@ class PlayersCog(commands.Cog): embed.title = "Player Has Disconnected" embed.colour = Colour.brand_red() - # await channel.send(embed=embed) + await channel.send(embed=embed) async def setup(bot: commands.Bot): From bfe8651ee7079fc15921ec0989d06fd3a2dd6b2d Mon Sep 17 00:00:00 2001 From: Corban-Lee Jones Date: Thu, 12 Dec 2024 16:44:47 +0000 Subject: [PATCH 23/29] make summary age check timezone aware --- utils/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/utils/models.py b/utils/models.py index 8fa7908..9c1b691 100644 --- a/utils/models.py +++ b/utils/models.py @@ -219,7 +219,7 @@ class Player(Model): 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.today(): + 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 From 96fe28b15c7ad0e097424f45bc450da52d2e3be9 Mon Sep 17 00:00:00 2001 From: Corban-Lee Jones Date: Thu, 12 Dec 2024 17:14:09 +0000 Subject: [PATCH 24/29] zomboid log timestamp format as environment variable --- README.md | 5 ++++- cogs/players.py | 7 ++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 4b208d2..c19a037 100644 --- a/README.md +++ b/README.md @@ -29,9 +29,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__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__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__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. diff --git a/cogs/players.py b/cogs/players.py index 8ed40c8..e86ad6c 100644 --- a/cogs/players.py +++ b/cogs/players.py @@ -17,6 +17,7 @@ from utils.models import Player ZOMBOID_FOLDER_PATH = Path(getenv("SPIFFO__ZOMBOID_FOLDER_PATH")) LOGS_FOLDER_PATH = ZOMBOID_FOLDER_PATH / "Logs" USER_LOG_FILE_PATH = next(LOGS_FOLDER_PATH.glob("*_user.txt"), None) +TIMESTAMP_FORMAT = getenv("SPIFFO__ZOMBOID_LOG_TIMESTAMP_FORMAT", "%d-%m-%y %H:%M:%S.%f") log = logging.getLogger(__name__) @@ -99,7 +100,7 @@ class PlayersCog(commands.Cog): cause=re_match.group("cause"), timestamp=datetime.strptime( re_match.group("timestamp"), - "%m-%d-%y %H:%M:%S.%f" + TIMESTAMP_FORMAT ) ) await player.save() @@ -129,7 +130,7 @@ class PlayersCog(commands.Cog): player, created = await Player.get_or_create(username=re_match.group("username")) await player.open_session( - timestamp=datetime.strptime(re_match.group("timestamp"), "%d-%m-%y %H:%M:%S.%f"), + timestamp=datetime.strptime(re_match.group("timestamp"), TIMESTAMP_FORMAT), coord_x=re_match.group("x"), coord_y=re_match.group("y"), coord_z=re_match.group("z") @@ -169,7 +170,7 @@ class PlayersCog(commands.Cog): player, created = await Player.get_or_create(username=re_match.group("username")) await player.close_session( - timestamp=datetime.strptime(re_match.group("timestamp"), "%d-%m-%y %H:%M:%S.%f"), + timestamp=datetime.strptime(re_match.group("timestamp"), TIMESTAMP_FORMAT), coord_x=re_match.group("x"), coord_y=re_match.group("y"), coord_z=re_match.group("z") From 3ae7b284335b6f26884f3c42a6d60ca6f6a44cca Mon Sep 17 00:00:00 2001 From: Corban-Lee Jones Date: Thu, 12 Dec 2024 17:24:11 +0000 Subject: [PATCH 25/29] Delete pyproject.toml --- pyproject.toml | 4 ---- 1 file changed, 4 deletions(-) delete mode 100644 pyproject.toml diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index 7f669ab..0000000 --- a/pyproject.toml +++ /dev/null @@ -1,4 +0,0 @@ -[tool.aerich] -tortoise_orm = "bot.TORTOISE_ORM" -location = "./migrations" -src_folder = "./." From 1be47acdd81a8025cc8826019101a80cbaab9f49 Mon Sep 17 00:00:00 2001 From: Corban-Lee Jones Date: Fri, 13 Dec 2024 12:17:03 +0000 Subject: [PATCH 26/29] rotate expired user log files --- cogs/players.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/cogs/players.py b/cogs/players.py index e86ad6c..58c8309 100644 --- a/cogs/players.py +++ b/cogs/players.py @@ -16,7 +16,6 @@ from utils.models import Player ZOMBOID_FOLDER_PATH = Path(getenv("SPIFFO__ZOMBOID_FOLDER_PATH")) LOGS_FOLDER_PATH = ZOMBOID_FOLDER_PATH / "Logs" -USER_LOG_FILE_PATH = next(LOGS_FOLDER_PATH.glob("*_user.txt"), None) TIMESTAMP_FORMAT = getenv("SPIFFO__ZOMBOID_LOG_TIMESTAMP_FORMAT", "%d-%m-%y %H:%M:%S.%f") log = logging.getLogger(__name__) @@ -30,7 +29,7 @@ class PlayersCog(commands.Cog): def __init__(self, bot: commands.Bot): self.bot = bot - self.file_handler = LogFileReader(USER_LOG_FILE_PATH) + self.create_file_handler() self.listen_for_changes.start() cmd_group = app_commands.Group( @@ -61,13 +60,22 @@ class PlayersCog(commands.Cog): 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) async def listen_for_changes(self): """ + Listen for changes in the user.txt log file, and process them. """ log.debug("listening for changes") - for line in await self.file_handler.read(): - await self.process_log_line(line) + try: + 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, alert: bool = True): log.debug("processing log line") From f131260bbc02a89443a0de6e0373f6533ed9cd87 Mon Sep 17 00:00:00 2001 From: Corban-Lee Jones Date: Fri, 13 Dec 2024 12:22:37 +0000 Subject: [PATCH 27/29] Update CHANGELOG.md --- CHANGELOG.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6999fe3..1d0c481 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Debug environment variable flag. - Command to build player data from existing log files. +### Fixed + +- Unhandled situation where zomboid rotates the log files, causing FileNotFoundError to be raised. + ### Changed - Log level dependent on the debug flag. @@ -27,7 +31,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- "User has connected/disconnected" alert on player death. +- "User has connected/disconnected" alert incorrectly firing on player death. ### Changed From d5dccdb5333b4ea792bf2ddc66e864b5615e9135 Mon Sep 17 00:00:00 2001 From: Corban-Lee Jones Date: Fri, 13 Dec 2024 23:50:37 +0000 Subject: [PATCH 28/29] update changelog --- CHANGELOG.md | 5 ++++- utils/models.py | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d0c481..b6e7ae7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Debug environment variable flag. -- Command to build player data from existing log files. +- 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 @@ -20,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 diff --git a/utils/models.py b/utils/models.py index 9c1b691..ab81e3d 100644 --- a/utils/models.py +++ b/utils/models.py @@ -102,7 +102,7 @@ class Player(Model): # I know this is terrible efficiency-wise, but the tortoise docs # are so bad and the annotations don't work like Django's models. Deal with it! for session in sessions: - log.info( + log.debug( "playtime info:\nsession start: %s\nsession end: %s\nsession playtime: %s", session.connected_at, session.disconnected_at, From 317a05c14fb167531d92666f0606c8e938681e71 Mon Sep 17 00:00:00 2001 From: Corban-Lee Jones Date: Sun, 16 Feb 2025 20:51:44 +0000 Subject: [PATCH 29/29] Update README.md --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index c19a037..dbd3b5b 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,10 @@ # 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. ## Features