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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 21c2a15..b6e7ae7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/), 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 ### Added @@ -15,7 +34,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 diff --git a/README.md b/README.md index 4b208d2..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 @@ -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__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/bot.py b/bot.py index 56668d7..59a7f4e 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 @@ -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"], + "default_connection": "default" + } + } +} class DiscordBot(commands.Bot): @@ -28,12 +37,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 = { @@ -51,14 +57,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): @@ -75,6 +73,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.get_channel(self.in_game_channel_id) + return channel or await self.fetch_channel(self.in_game_channel_id) + def get_bot_token() -> str: """ @@ -87,7 +91,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 +105,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 +121,15 @@ 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: + # 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.start(bot_token, reconnect=True) diff --git a/cogs/players.py b/cogs/players.py index 11fa4aa..58c8309 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 @@ -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 +TIMESTAMP_FORMAT = getenv("SPIFFO__ZOMBOID_LOG_TIMESTAMP_FORMAT", "%d-%m-%y %H:%M:%S.%f") log = logging.getLogger(__name__) @@ -33,26 +29,65 @@ 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( + 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) 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): + 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): + 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) @@ -73,28 +108,28 @@ 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() - 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") - 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: @@ -102,11 +137,16 @@ 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"), TIMESTAMP_FORMAT), + 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() # This connection method is called when the player respawns @@ -115,20 +155,21 @@ 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") - 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: @@ -136,19 +177,22 @@ 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"), TIMESTAMP_FORMAT), + 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() - 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() diff --git a/requirements.txt b/requirements.txt index 8f37e38..a60c0be 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,9 +5,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 +26,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 diff --git a/utils/models.py b/utils/models.py index 1704fcb..ab81e3d 100644 --- a/utils/models.py +++ b/utils/models.py @@ -3,15 +3,16 @@ Database schemas. """ import logging -from datetime import datetime +from datetime import datetime, timedelta +from pytz import timezone import httpx from tortoise import fields -from tortoise.queryset import QuerySet from tortoise.models import Model from discord import Embed log = logging.getLogger(__name__) +utc = timezone("UTC") class SteamProfileSummary(Model): @@ -29,78 +30,167 @@ 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=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): """ """ 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: + log.info("Getting total playtime for player: %s", self.username) + sessions = await PlayerSession.filter(player=self) + total_playtime = timedelta() - return (self.last_connection and not self.last_disconnection) \ - or self.last_connection > self.last_disconnection + # 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.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) 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) + 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) 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.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: """ + 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.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.") @@ -113,39 +203,55 @@ 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.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, 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: + 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.") 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( 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) diff --git a/utils/reader.py b/utils/reader.py index ee55509..a49ebad 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,8 @@ 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: + 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() return []