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/
db.sqlite
db.sqlite-shm
db.sqlite-wal
db.sqlite-wal
zomboid/
data/

4
.gitignore vendored
View File

@ -5,4 +5,6 @@ __pycache__/
*.pyc
db.sqlite
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/),
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

View File

@ -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.

46
bot.py
View File

@ -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)

View File

@ -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<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)
@ -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<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)
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<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)
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()

View File

@ -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

View File

@ -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)

View File

@ -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 []