Merge pull request 'dev' (#4) from dev into master
Some checks failed
Build and Push Docker Image / build (push) Failing after 3s
Some checks failed
Build and Push Docker Image / build (push) Failing after 3s
Reviewed-on: #4
This commit is contained in:
commit
6081324320
@ -8,4 +8,6 @@ __pycache__/
|
||||
.vscode/
|
||||
db.sqlite
|
||||
db.sqlite-shm
|
||||
db.sqlite-wal
|
||||
db.sqlite-wal
|
||||
zomboid/
|
||||
data/
|
4
.gitignore
vendored
4
.gitignore
vendored
@ -5,4 +5,6 @@ __pycache__/
|
||||
*.pyc
|
||||
db.sqlite
|
||||
db.sqlite-shm
|
||||
db.sqlite-wal
|
||||
db.sqlite-wal
|
||||
zomboid/
|
||||
data/
|
21
CHANGELOG.md
21
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
|
||||
|
||||
|
10
README.md
10
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.
|
||||
|
||||
|
46
bot.py
46
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)
|
||||
|
||||
|
120
cogs/players.py
120
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<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()
|
||||
|
@ -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
|
||||
|
196
utils/models.py
196
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)
|
||||
|
@ -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 []
|
||||
|
Reference in New Issue
Block a user