diff --git a/.dockerignore b/.dockerignore index c251ba6..1c7a300 100644 --- a/.dockerignore +++ b/.dockerignore @@ -5,4 +5,7 @@ logs/ __pycache__/ *.pyc .gitea/ -.vscode/ \ No newline at end of file +.vscode/ +db.sqlite +db.sqlite-shm +db.sqlite-wal \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1cdca00 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.env +venv/ +logs/ +__pycache__/ +*.pyc +db.sqlite +db.sqlite-shm +db.sqlite-wal \ No newline at end of file diff --git a/bot.py b/bot.py index 4164984..56668d7 100644 --- a/bot.py +++ b/bot.py @@ -14,12 +14,13 @@ from pathlib import Path from discord import Intents from discord.ext import commands from dotenv import load_dotenv +from tortoise import Tortoise load_dotenv(override=True) log = logging.getLogger(__name__) BASE_DIR = Path(__file__).resolve().parent -DATA_DIR = BASE_DIR / "data" +DATA_DIR = getenv("SPIFFO__DATA_FOLDER_PATH") class DiscordBot(commands.Bot): @@ -41,23 +42,30 @@ class DiscordBot(commands.Bot): "passwd": getenv("SPIFFO__RCON_PASSWORD") } - async def sync_app_commands(self): - """ - Sync application commands between Discord and the bot. - """ - await self.wait_until_ready() - await self.tree.sync() - log.info("Application commands successfully synced") - async def on_ready(self): """ - Execute init operations that require the bot to be ready. + Execute initial operations that require the bot to be ready. Ideally should not be manually called, this is handled by discord.py """ - await self.sync_app_commands() + # 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): + await Tortoise.close_connections() + await super().close(); + log.info("Shutdown successfully and safely") + async def load_cogs(self): """ Load any extensions found in the cogs dictionary. diff --git a/cogs/console.py b/cogs/console.py index b5867c4..4dc6503 100644 --- a/cogs/console.py +++ b/cogs/console.py @@ -10,13 +10,12 @@ from pathlib import Path import aiofiles from rcon.source import rcon -from discord import Embed, Colour from discord.ext import commands, tasks +from utils.reader import LogFileReader + ZOMBOID_FOLDER_PATH = Path(getenv("SPIFFO__ZOMBOID_FOLDER_PATH")) CONSOLE_FILE_PATH = ZOMBOID_FOLDER_PATH / "server-console.txt" -assert ZOMBOID_FOLDER_PATH.exists() -assert CONSOLE_FILE_PATH.exists() log = logging.getLogger(__name__) @@ -29,37 +28,47 @@ class ConsoleCog(commands.Cog): def __init__(self, bot: commands.Bot): self.bot = bot - self.monitor_console.start() + self.listen_for_changes.start() - @tasks.loop(seconds=1) - async def monitor_console(self): - """ - Check the latest version of the console log file. - """ + @tasks.loop(seconds=3) + async def listen_for_changes(self): + try: + file_reader = LogFileReader(CONSOLE_FILE_PATH) + except FileNotFoundError: + self.listen_for_changes.cancel() - if not CONSOLE_FILE_PATH.exists(): - self.monitor_console.cancel() - raise FileNotFoundError("Server console file doesn't exist, task cancelled.") + async for line in file_reader.read(): + await self.process_console_line(line) - async with aiofiles.open(CONSOLE_FILE_PATH, "r", encoding="utf-8") as file: + # @tasks.loop(seconds=1) + # async def monitor_console(self): + # """ + # Check the latest version of the console log file. + # """ - # If we are at 0, restarting the bot would cause rapid fire of all log lines, - # instead lets grab the latest line, to prevent spam. - if self._last_line_number == 0: - await file.seek(0, 2) - self._last_line_number = await file.tell() - return + # if not CONSOLE_FILE_PATH.exists(): + # self.monitor_console.cancel() + # raise FileNotFoundError("Server console file doesn't exist, task cancelled.") - await file.seek(self._last_line_number) - lines = await file.readlines() - if not lines: - log.debug("no new lines to read") - return + # async with aiofiles.open(CONSOLE_FILE_PATH, "r", encoding="utf-8") as file: - for line in lines: - await self.process_console_line(line.strip()) + # # If we are at 0, restarting the bot would cause rapid fire of all log lines, + # # instead lets grab the latest line, to prevent spam. + # if self._last_line_number == 0: + # await file.seek(0, 2) + # self._last_line_number = await file.tell() + # return - self._last_line_number = await file.tell() + # await file.seek(self._last_line_number) + # lines = await file.readlines() + # if not lines: + # log.debug("no new lines to read") + # return + + # for line in lines: + # await self.process_console_line(line.strip()) + + # self._last_line_number = await file.tell() async def process_console_line(self, line: str): """ diff --git a/requirements.txt b/requirements.txt index 3e740bb..8f37e38 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,6 +2,8 @@ aiofiles==24.1.0 aiohappyeyeballs==2.4.4 aiohttp==3.11.9 aiosignal==1.3.1 +aiosqlite==0.20.0 +annotated-types==0.7.0 anyio==4.7.0 attrs==24.2.0 bump2version==1.0.1 @@ -12,10 +14,16 @@ h11==0.14.0 httpcore==1.0.7 httpx==0.28.0 idna==3.10 +iso8601==2.1.0 multidict==6.1.0 propcache==0.2.1 +pydantic==2.10.3 +pydantic_core==2.27.1 +pypika-tortoise==0.3.2 python-dotenv==1.0.1 +pytz==2024.2 rcon==2.4.9 sniffio==1.3.1 +tortoise-orm==0.22.1 typing_extensions==4.12.2 yarl==1.18.3 diff --git a/utils/models.py b/utils/models.py new file mode 100644 index 0000000..19697a5 --- /dev/null +++ b/utils/models.py @@ -0,0 +1,36 @@ +""" +Database schemas. +""" + +from tortoise import Tortoise, fields +from tortoise.models import Model + + +class Player(Model): + username = fields.CharField(max_length=20) + steam_id = fields.CharField(max_length=20) + last_connection = fields.DatetimeField() + last_disconnection = fields.DatetimeField() + deaths = fields.ManyToManyField( + model_name="models.PlayerDeath", + on_delete=fields.CASCADE + ) + + @property + def is_online(self): + if not self.last_connection: + return False + + return (self.last_connection and not self.last_disconnection) \ + or self.last_connection > self.last_disconnection + + +class PlayerDeath(Model): + 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) + + class Meta: + table = "player_deaths" diff --git a/utils/reader.py b/utils/reader.py new file mode 100644 index 0000000..9f1b7f9 --- /dev/null +++ b/utils/reader.py @@ -0,0 +1,44 @@ +""" + +""" + +import logging +from pathlib import Path + +import aiofiles + +log = logging.getLogger(__name__) + + +class LogFileReader: + """ + """ + def __init__(self, file_path: Path): + if type(file_path) != Path: + raise TypeError(f"file_path must be type Path, not {type(file_path)}") + + self.file_path = file_path + self._last_line_number = 0 + + + async def read(self): + if not self.file_path.exists(): + log.error("Cannot read non-existant file path: '%s'", self.file_path) + raise FileNotFoundError(self.file_path) + + async with aiofiles.open(self.file_path, "r", encoding="utf-8") as file: + if self._last_line_number == 0: + await file.seek(0, 2) + self._last_line_number = await file.tell() + return + + await file.seek(self._last_line_number) + lines = await file.readlines() + if not lines: + log.debug("no new lines to read") + return + + for line in lines: + yield line.strip() + + self._last_line_number = await file.tell()