Compare commits

...

36 Commits

Author SHA1 Message Date
3e622fcecb Bump version: 0.0.2 → 0.0.3
All checks were successful
Build and Push Docker Image / build (push) Successful in 17s
2024-12-09 15:07:22 +00:00
b6a459802d Update CHANGELOG.md 2024-12-09 15:07:01 +00:00
103d14e5d4 fix stupid await placement
All checks were successful
Build and Push Docker Image / build (push) Successful in 12s
2024-12-09 14:55:55 +00:00
38dbc1a419 track player is dead
All checks were successful
Build and Push Docker Image / build (push) Successful in 12s
2024-12-09 14:53:52 +00:00
2c30e719ce fix death regex failing to parse timestamp
All checks were successful
Build and Push Docker Image / build (push) Successful in 13s
2024-12-09 14:47:08 +00:00
ac5389953f embed alert on player death
All checks were successful
Build and Push Docker Image / build (push) Successful in 15s
2024-12-09 14:25:11 +00:00
161d44a59d colour the join/leave embeds
All checks were successful
Build and Push Docker Image / build (push) Successful in 13s
2024-12-09 02:11:15 +00:00
80d7a35f46 add missing new line in embed description
All checks were successful
Build and Push Docker Image / build (push) Successful in 15s
2024-12-09 02:09:55 +00:00
3d4a25791f add missing position argument to embed
All checks were successful
Build and Push Docker Image / build (push) Successful in 16s
2024-12-09 01:59:25 +00:00
718fa21c66 Update models.py
All checks were successful
Build and Push Docker Image / build (push) Successful in 16s
2024-12-09 01:58:16 +00:00
61af1b937f use len over .count method for death count
All checks were successful
Build and Push Docker Image / build (push) Successful in 16s
2024-12-09 01:56:12 +00:00
60f10d2d68 add missing await
All checks were successful
Build and Push Docker Image / build (push) Successful in 21s
these smaller issues are becoming annoying
need a better way of testing other than building and pushing
2024-12-09 01:54:01 +00:00
b03f1ba6df fix incorrect order of passed variables for steam summary
All checks were successful
Build and Push Docker Image / build (push) Successful in 16s
2024-12-09 01:51:56 +00:00
ba16063eff fix attribute error from failing to unpack tuple
All checks were successful
Build and Push Docker Image / build (push) Successful in 15s
2024-12-09 01:49:58 +00:00
fee723b9a9 fix not null error for connection fields
All checks were successful
Build and Push Docker Image / build (push) Successful in 15s
2024-12-09 01:46:32 +00:00
fec912c9e0 fix regex issue for player join/leave events
All checks were successful
Build and Push Docker Image / build (push) Successful in 15s
2024-12-09 01:40:29 +00:00
945b7422e4 use path name over stem
All checks were successful
Build and Push Docker Image / build (push) Successful in 15s
2024-12-09 01:33:36 +00:00
a06966fc81 playtime field as int over float
All checks were successful
Build and Push Docker Image / build (push) Successful in 14s
2024-12-09 01:29:03 +00:00
8fecd43f31 fix futures error + remove unused imports
All checks were successful
Build and Push Docker Image / build (push) Successful in 15s
2024-12-09 01:27:00 +00:00
d495ffc0fa simplify join/leave alert
All checks were successful
Build and Push Docker Image / build (push) Successful in 16s
2024-12-09 01:22:55 +00:00
67bb52c767 steam summary and player death tracker
All checks were successful
Build and Push Docker Image / build (push) Successful in 16s
2024-12-09 01:04:34 +00:00
1cce089cc3 incorrect usage of async await
All checks were successful
Build and Push Docker Image / build (push) Successful in 11s
2024-12-08 00:53:01 +00:00
ae07f1760a attribute error fix
All checks were successful
Build and Push Docker Image / build (push) Successful in 11s
2024-12-08 00:51:22 +00:00
d2d0b1a03e track filehandler
All checks were successful
Build and Push Docker Image / build (push) Successful in 12s
2024-12-08 00:49:08 +00:00
c398cac067 fix improper generator use
All checks were successful
Build and Push Docker Image / build (push) Successful in 12s
2024-12-08 00:46:57 +00:00
3e8072fb67 Update reader.py
All checks were successful
Build and Push Docker Image / build (push) Successful in 18s
2024-12-08 00:31:57 +00:00
919e921850 fix line number assignment in bad position
All checks were successful
Build and Push Docker Image / build (push) Successful in 12s
2024-12-08 00:06:26 +00:00
26fe8ca523 more logging & remove unused code
All checks were successful
Build and Push Docker Image / build (push) Successful in 11s
2024-12-07 23:50:24 +00:00
e6ddf290ca return after FileNotFoundError and log line output
All checks were successful
Build and Push Docker Image / build (push) Successful in 11s
2024-12-07 23:38:21 +00:00
32b51fcd1d fix broken instance/type check
All checks were successful
Build and Push Docker Image / build (push) Successful in 12s
2024-12-07 23:28:22 +00:00
2b6f68de0d database, data folder path & file reader class
All checks were successful
Build and Push Docker Image / build (push) Successful in 28s
2024-12-07 23:23:10 +00:00
9522ec6ee0 We will count deaths
All checks were successful
Build and Push Docker Image / build (push) Successful in 14s
2024-12-07 15:20:34 +00:00
2d2cf708ef Update README.md
All checks were successful
Build and Push Docker Image / build (push) Successful in 12s
2024-12-07 01:10:04 +00:00
3981edb006 environment variables in readme
All checks were successful
Build and Push Docker Image / build (push) Successful in 12s
2024-12-07 01:07:55 +00:00
ba23b9d868 Update CHANGELOG.md
All checks were successful
Build and Push Docker Image / build (push) Successful in 11s
2024-12-07 00:55:33 +00:00
796d001751 Update README.md 2024-12-07 00:55:25 +00:00
11 changed files with 411 additions and 153 deletions

View File

@ -1,4 +1,4 @@
[bumpversion]
current_version = 0.0.2
current_version = 0.0.3
commit = True
tag = True

View File

@ -5,4 +5,7 @@ logs/
__pycache__/
*.pyc
.gitea/
.vscode/
.vscode/
db.sqlite
db.sqlite-shm
db.sqlite-wal

8
.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
.env
venv/
logs/
__pycache__/
*.pyc
db.sqlite
db.sqlite-shm
db.sqlite-wal

View File

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

View File

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

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

View File

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

View File

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

View File

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