Compare commits
36 Commits
511b7b038e
...
3e622fcecb
Author | SHA1 | Date | |
---|---|---|---|
3e622fcecb | |||
b6a459802d | |||
103d14e5d4 | |||
38dbc1a419 | |||
2c30e719ce | |||
ac5389953f | |||
161d44a59d | |||
80d7a35f46 | |||
3d4a25791f | |||
718fa21c66 | |||
61af1b937f | |||
60f10d2d68 | |||
b03f1ba6df | |||
ba16063eff | |||
fee723b9a9 | |||
fec912c9e0 | |||
945b7422e4 | |||
a06966fc81 | |||
8fecd43f31 | |||
d495ffc0fa | |||
67bb52c767 | |||
1cce089cc3 | |||
ae07f1760a | |||
d2d0b1a03e | |||
c398cac067 | |||
3e8072fb67 | |||
919e921850 | |||
26fe8ca523 | |||
e6ddf290ca | |||
32b51fcd1d | |||
2b6f68de0d | |||
9522ec6ee0 | |||
2d2cf708ef | |||
3981edb006 | |||
ba23b9d868 | |||
796d001751 |
@ -1,4 +1,4 @@
|
||||
[bumpversion]
|
||||
current_version = 0.0.2
|
||||
current_version = 0.0.3
|
||||
commit = True
|
||||
tag = True
|
||||
|
@ -5,4 +5,7 @@ logs/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.gitea/
|
||||
.vscode/
|
||||
.vscode/
|
||||
db.sqlite
|
||||
db.sqlite-shm
|
||||
db.sqlite-wal
|
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
.env
|
||||
venv/
|
||||
logs/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
db.sqlite
|
||||
db.sqlite-shm
|
||||
db.sqlite-wal
|
17
CHANGELOG.md
17
CHANGELOG.md
@ -5,6 +5,23 @@ 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).
|
||||
|
||||
## [0.0.3] - 2024-12-09
|
||||
|
||||
### Added
|
||||
|
||||
- A Database for storing user data, such as connection state and deaths.
|
||||
- Better file handlers for reading log files.
|
||||
- More cogs for dedicated purposes (players, console, commands, activity).
|
||||
|
||||
### Fixed
|
||||
|
||||
- "User has connected/disconnected" alert on player death.
|
||||
|
||||
### Changed
|
||||
|
||||
- Streamlined ways of reading from server log files.
|
||||
- Improved embeds for users connecting and disconnecting from the server.
|
||||
|
||||
## [0.0.2] - 2024-12-07
|
||||
|
||||
### Added
|
||||
|
39
README.md
39
README.md
@ -1,3 +1,40 @@
|
||||
# Spiffo
|
||||
|
||||
A Discord Bot for integration with a Project Zomboid game server.
|
||||
A multi-purpose Discord integration bot for Project Zomboid.
|
||||
|
||||
## Features
|
||||
|
||||
- Interact with the remote console via slash commands in Discord.
|
||||
- A live count of all online players as the bot's activity status.
|
||||
- Views of complicated player data through direct access to the players.db file.
|
||||
- Automated mod update management including: restart warnings, saving and exiting, restarting the server.
|
||||
- Player connect and disconnect alerts in a specified Discord channel.
|
||||
- Ability to read directly from the server console log for accurate up-to-date info.
|
||||
- Super easy deployment via docker with environment variables for configuration.
|
||||
|
||||
## Setup
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Despite being isolated to a docker container in deployment, the environment variables are prefixed with the application name (`SPIFFO__`).
|
||||
|
||||
This should make the environment variables used be more clear and pronounced when you choose to use them or otherwise.
|
||||
|
||||
|Variable|Description|Required|Default Value|
|
||||
|-|-|:-:|-|
|
||||
|`SPIFFO__BOT_TOKEN`|Credential to allow the application to authenticate as the bot.|✔||
|
||||
|`SPIFFO__RCON_HOST`|Host of the server's remote console.|✔||
|
||||
|`SPIFFO__RCON_PORT`|Port of the server's remote console.|✔||
|
||||
|`SPIFFO__RCON_PASSWORD`|Password of the server's remote console.|✔||
|
||||
|`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__SERVER_NAME`|Name of the server instance, don't change unless you know what you are doing!||servertest|
|
||||
|
||||
## Why Did I Make This?
|
||||
|
||||
All existing bots are poorly coded, slow and/or lack certain features.
|
||||
|
||||
They usually fall into a couple of categories; single purpose and great or multi-purpose and bad.
|
||||
|
||||
I wanted to make a clean, beautiful and functional bot with ALL of the features!
|
||||
|
30
bot.py
30
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.
|
||||
|
@ -8,15 +8,13 @@ import asyncio
|
||||
from os import getenv
|
||||
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__)
|
||||
|
||||
@ -25,58 +23,28 @@ class ConsoleCog(commands.Cog):
|
||||
"""
|
||||
Reads and handles the server-console.txt file.
|
||||
"""
|
||||
_last_line_number = 0
|
||||
file_handler: LogFileReader
|
||||
|
||||
def __init__(self, bot: commands.Bot):
|
||||
self.bot = bot
|
||||
self.monitor_console.start()
|
||||
self.file_handler = LogFileReader(CONSOLE_FILE_PATH)
|
||||
self.listen_for_changes.start()
|
||||
|
||||
@tasks.loop(seconds=1)
|
||||
async def monitor_console(self):
|
||||
"""
|
||||
Check the latest version of the console log file.
|
||||
"""
|
||||
|
||||
if not CONSOLE_FILE_PATH.exists():
|
||||
self.monitor_console.cancel()
|
||||
raise FileNotFoundError("Server console file doesn't exist, task cancelled.")
|
||||
|
||||
async with aiofiles.open(CONSOLE_FILE_PATH, "r", encoding="utf-8") as 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
|
||||
|
||||
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()
|
||||
@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_console_line(line)
|
||||
|
||||
async def process_console_line(self, line: str):
|
||||
"""
|
||||
Determine how to handle the given line from the server console.
|
||||
"""
|
||||
log.debug("processing console line: %s", line)
|
||||
|
||||
if "CheckModsNeedUpdate: Mods need update" in line:
|
||||
await self.handle_mod_needs_update(line)
|
||||
|
||||
elif "ConnectionManager: [fully-connected]" in line:
|
||||
cog = self.bot.get_cog("PlayersCog")
|
||||
await cog.handle_player_connected(line)
|
||||
|
||||
elif "ConnectionManager: [disconnect]" in line:
|
||||
cog = self.bot.get_cog("PlayersCog")
|
||||
await cog.handle_player_disconnected(line)
|
||||
|
||||
async def alert_and_wait_for_restart(self, intervals_ms: list[int], reason: str):
|
||||
for interval_ms in intervals_ms:
|
||||
seconds_remaining = interval_ms / 1000
|
||||
|
207
cogs/players.py
207
cogs/players.py
@ -4,143 +4,158 @@ Handles tasks related to in-game players, such as connect/disconnect alerts.
|
||||
|
||||
import re
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from os import getenv
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
import httpx
|
||||
from discord import Embed, Colour
|
||||
from discord import Colour
|
||||
from discord.ext import commands, tasks
|
||||
from rcon.source import rcon
|
||||
|
||||
from utils.reader import LogFileReader
|
||||
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
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SteamProfileSummary:
|
||||
steam_id: int
|
||||
profile_name: str
|
||||
url: str
|
||||
avatar_url: str
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ZomboidUser:
|
||||
guid: str
|
||||
ip: str
|
||||
steam_id: str
|
||||
access: str
|
||||
username: str
|
||||
connection_type: str
|
||||
steam_profile_summary: SteamProfileSummary | None = None
|
||||
|
||||
async def fetch_steam_profile(self, steam_api_key: str):
|
||||
if not steam_api_key:
|
||||
log.warning("No steam API key, can't get profile summary.")
|
||||
return
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(url=f"https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v2/?key={steam_api_key}&steamids={self.steam_id}")
|
||||
response.raise_for_status()
|
||||
|
||||
all_data = response.json()
|
||||
user_data = all_data["response"]["players"][0]
|
||||
|
||||
log.debug("fetched user data for: %s", self.steam_id)
|
||||
|
||||
self.steam_profile_summary = SteamProfileSummary(
|
||||
steam_id=user_data["steamid"],
|
||||
profile_name=user_data["personaname"],
|
||||
url=user_data["profileurl"],
|
||||
avatar_url=user_data["avatarfull"]
|
||||
)
|
||||
|
||||
log.debug("successfully parsed steam profile summary for: %s", self.steam_id)
|
||||
|
||||
@property
|
||||
def embed(self) -> Embed:
|
||||
if not self.steam_profile_summary:
|
||||
raise ValueError("You must fetch the steam_profile_summary before creating an embed.")
|
||||
|
||||
embed = Embed(
|
||||
title="Player",
|
||||
description=(
|
||||
f"{self.username} ([{self.steam_profile_summary.profile_name}]({self.steam_profile_summary.url}))\n"
|
||||
"kills: ???\n"
|
||||
"Playtime: ???"
|
||||
)
|
||||
)
|
||||
embed.set_thumbnail(url=self.steam_profile_summary.avatar_url)
|
||||
return embed
|
||||
|
||||
|
||||
class PlayersCog(commands.Cog):
|
||||
"""
|
||||
Handles tasks related to in-game players.
|
||||
"""
|
||||
file_handler: LogFileReader
|
||||
|
||||
def __init__(self, bot: commands.Bot):
|
||||
self.bot = bot
|
||||
self.file_handler = LogFileReader(USER_LOG_FILE_PATH)
|
||||
self.listen_for_changes.start()
|
||||
|
||||
async def handle_player_connected(self, line: str):
|
||||
"""
|
||||
Report when a user has joined the server into a specified Discord channel.
|
||||
Example of line:
|
||||
ConnectionManager: [fully-connected] "" connection: guid=*** ip=*** steam-id=*** access=admin username="corbz" connection-type="UDPRakNet"
|
||||
"""
|
||||
re_pattern = r"guid=(\d+)\s+ip=([\d\.]+)\s+steam-id=(\d+)\s+access=(\w*)\s+username=\"([^\"]+)\"\s+connection-type=\"([^\"]+)\""
|
||||
@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)
|
||||
|
||||
async def process_log_line(self, line: str):
|
||||
log.debug("processing log line")
|
||||
|
||||
if "died" in line:
|
||||
await self.process_player_death(line)
|
||||
elif "fully connected" in line:
|
||||
await self.process_connected_player(line)
|
||||
elif "disconnected player" in line:
|
||||
await self.process_disconnected_player(line)
|
||||
|
||||
async def process_player_death(self, line: str):
|
||||
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)
|
||||
if not re_match:
|
||||
log.warning("failed to parse player data: %s", line)
|
||||
log.warning("failed to parse player death log: %s", line)
|
||||
return
|
||||
|
||||
user = ZomboidUser(
|
||||
guid=re_match.group(1),
|
||||
ip=re_match.group(2),
|
||||
steam_id=re_match.group(3),
|
||||
access=re_match.group(4),
|
||||
username=re_match.group(5),
|
||||
connection_type=re_match.group(6)
|
||||
username = re_match.group("username")
|
||||
player = await Player.get_or_none(username=username)
|
||||
if not player:
|
||||
log.warning("Player returned none, cannot add death: %s", username)
|
||||
return
|
||||
|
||||
await player.add_death(
|
||||
coord_x=re_match.group("x"),
|
||||
coord_y=re_match.group("y"),
|
||||
coord_z=re_match.group("z"),
|
||||
cause=re_match.group("cause"),
|
||||
timestamp=datetime.strptime(
|
||||
re_match.group("timestamp"),
|
||||
"%m-%d-%y %H:%M:%S.%f"
|
||||
)
|
||||
)
|
||||
await user.fetch_steam_profile(self.bot.steam_api_key)
|
||||
await player.save()
|
||||
|
||||
channel = self.bot.get_channel(self.bot.in_game_channel_id)
|
||||
channel = await self.bot.fetch_channel(self.bot.in_game_channel_id) if not channel else channel
|
||||
channel = channel or await self.bot.fetch_channel(self.bot.in_game_channel_id)
|
||||
|
||||
embed = user.embed
|
||||
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):
|
||||
"""
|
||||
"""
|
||||
log.debug("processing connected player")
|
||||
re_pattern = r'\[(?P<timestamp>.*?)\] (?P<steam_id>\d+) "(?P<username>.*?)" fully connected \((?P<coordinates>\d+,\d+,\d+)\)'
|
||||
re_match = re.search(re_pattern, line)
|
||||
|
||||
if not re_match:
|
||||
log.warning("Failed to parse player data: %s", line)
|
||||
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.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
|
||||
if player.is_dead:
|
||||
player.is_dead = False # player must be alive if fully connected
|
||||
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)
|
||||
|
||||
embed = await player.get_embed()
|
||||
embed.title = "Player Has Connected"
|
||||
embed.colour = Colour.brand_green()
|
||||
|
||||
await channel.send(embed=embed)
|
||||
|
||||
async def handle_player_disconnected(self, line: str):
|
||||
async def process_disconnected_player(self, line: str):
|
||||
"""
|
||||
Report when a user has left the server into a specified Discord channel.
|
||||
Example of line:
|
||||
ConnectionManager: [disconnect] "receive-disconnect" connection: guid=*** ip=*** steam-id=*** access=admin username="corbz" connection-type="Disconnected"
|
||||
"""
|
||||
re_pattern = r"guid=(\d+)\s+ip=([\d\.]+)\s+steam-id=(\d+)\s+access=(\w*)\s+username=\"([^\"]+)\"\s+connection-type=\"([^\"]+)\""
|
||||
log.debug("processing disconnected player")
|
||||
re_pattern = r'\[(?P<timestamp>.*?)\] (?P<steam_id>\d+) "(?P<username>.*?)" disconnected player \((?P<coordinates>\d+,\d+,\d+)\)'
|
||||
re_match = re.search(re_pattern, line)
|
||||
|
||||
if not re_match:
|
||||
log.warning("failed to parse player data: %s", line)
|
||||
log.warning("Failed to parse player data: %s", line)
|
||||
return
|
||||
|
||||
user = ZomboidUser(
|
||||
guid=re_match.group(1),
|
||||
ip=re_match.group(2),
|
||||
steam_id=re_match.group(3),
|
||||
access=re_match.group(4),
|
||||
username=re_match.group(5),
|
||||
connection_type=re_match.group(6)
|
||||
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 user.fetch_steam_profile(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:
|
||||
return
|
||||
|
||||
channel = self.bot.get_channel(self.bot.in_game_channel_id)
|
||||
channel = await self.bot.fetch_channel(self.bot.in_game_channel_id) if not channel else channel
|
||||
channel = channel or await self.bot.fetch_channel(self.bot.in_game_channel_id)
|
||||
|
||||
embed = user.embed
|
||||
embed = await player.get_embed()
|
||||
embed.title = "Player Has Disconnected"
|
||||
embed.colour = Colour.brand_red()
|
||||
|
||||
await channel.send(embed=embed)
|
||||
|
||||
|
||||
async def setup(bot: commands.Bot):
|
||||
cog = PlayersCog(bot)
|
||||
await bot.add_cog(cog)
|
||||
|
@ -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
|
||||
|
152
utils/models.py
Normal file
152
utils/models.py
Normal file
@ -0,0 +1,152 @@
|
||||
"""
|
||||
Database schemas.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
import httpx
|
||||
from tortoise import fields
|
||||
from tortoise.queryset import QuerySet
|
||||
from tortoise.models import Model
|
||||
from discord import Embed
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SteamProfileSummary(Model):
|
||||
player = fields.ForeignKeyField(
|
||||
model_name="models.Player",
|
||||
on_delete=fields.CASCADE
|
||||
)
|
||||
steam_id = fields.CharField(max_length=20, unique=True)
|
||||
profile_name = fields.CharField(max_length=32)
|
||||
url = fields.CharField(max_length=128)
|
||||
avatar_url = fields.CharField(max_length=128)
|
||||
last_update = fields.DatetimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
table = "steam_profile_summary"
|
||||
|
||||
|
||||
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)
|
||||
|
||||
class Meta:
|
||||
table = "player_deaths"
|
||||
|
||||
|
||||
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
|
||||
|
||||
return (self.last_connection and not self.last_disconnection) \
|
||||
or self.last_connection > self.last_disconnection
|
||||
|
||||
async def get_deaths(self) -> QuerySet[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:
|
||||
"""
|
||||
"""
|
||||
log.debug("Assigning death to player: %s", self.username)
|
||||
self.is_dead = True
|
||||
await self.save()
|
||||
return await PlayerDeath.create(
|
||||
player=self,
|
||||
coordinate_x=coord_x,
|
||||
coordinate_y=coord_y,
|
||||
coordinate_z=coord_z,
|
||||
cause=cause,
|
||||
timestamp=timestamp
|
||||
)
|
||||
|
||||
async def get_steam_summary(self) -> SteamProfileSummary | None:
|
||||
"""
|
||||
"""
|
||||
return await SteamProfileSummary.get_or_none(player=self)
|
||||
|
||||
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)
|
||||
|
||||
if not steam_api_key:
|
||||
raise ValueError("No Steam API key provided, can't get profile summary.")
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(url=f"https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v2/?key={steam_api_key}&steamids={steam_id}")
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
profiles = data.get("response", {}).get("players", [])
|
||||
if not profiles:
|
||||
raise ValueError("No profiles found in response")
|
||||
|
||||
profile = profiles[0]
|
||||
|
||||
summary, created = await SteamProfileSummary.get_or_create(
|
||||
steam_id=steam_id,
|
||||
defaults={
|
||||
"player": self,
|
||||
"profile_name": profile.get("personaname"),
|
||||
"url": profile.get("profileurl"),
|
||||
"avatar_url": profile.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:
|
||||
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())
|
||||
|
||||
embed = Embed(
|
||||
title="Player",
|
||||
description=(
|
||||
f"{self.username} ([{summary.profile_name}]({summary.url}))\n"
|
||||
f"Deaths: {death_count}\n"
|
||||
f"Playtime ???"
|
||||
)
|
||||
)
|
||||
embed.set_thumbnail(url=summary.avatar_url)
|
||||
return embed
|
42
utils/reader.py
Normal file
42
utils/reader.py
Normal file
@ -0,0 +1,42 @@
|
||||
"""
|
||||
|
||||
"""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import aiofiles
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LogFileReader:
|
||||
"""
|
||||
"""
|
||||
def __init__(self, file_path: Path):
|
||||
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._last_line_number = 0
|
||||
log.debug("%s created with path %s", self.__class__.__name__, str(file_path))
|
||||
|
||||
async def read(self) -> list[str]:
|
||||
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 []
|
||||
|
||||
self._last_line_number = await file.tell()
|
||||
return lines
|
Reference in New Issue
Block a user