Compare commits

..

No commits in common. "3e622fcecb2be32585818a0b0840c7ac3a334781" and "511b7b038e26df2b1ded812842ed1c0cf53faeae" have entirely different histories.

11 changed files with 153 additions and 411 deletions

View File

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

View File

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

8
.gitignore vendored
View File

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

View File

@ -5,23 +5,6 @@ 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,40 +1,3 @@
# Spiffo
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!
A Discord Bot for integration with a Project Zomboid game server.

30
bot.py
View File

@ -14,13 +14,12 @@ 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 = getenv("SPIFFO__DATA_FOLDER_PATH")
DATA_DIR = BASE_DIR / "data"
class DiscordBot(commands.Bot):
@ -42,30 +41,23 @@ 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 initial operations that require the bot to be ready.
Execute init operations that require the bot to be ready.
Ideally should not be manually called, this is handled by discord.py
"""
# 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()
await self.sync_app_commands()
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,13 +8,15 @@ 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__)
@ -23,28 +25,58 @@ class ConsoleCog(commands.Cog):
"""
Reads and handles the server-console.txt file.
"""
file_handler: LogFileReader
_last_line_number = 0
def __init__(self, bot: commands.Bot):
self.bot = bot
self.file_handler = LogFileReader(CONSOLE_FILE_PATH)
self.listen_for_changes.start()
self.monitor_console.start()
@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)
@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()
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,158 +4,143 @@ Handles tasks related to in-game players, such as connect/disconnect alerts.
import re
import logging
from os import getenv
from pathlib import Path
from datetime import datetime
from dataclasses import dataclass
from discord import Colour
import httpx
from discord import Embed, Colour
from discord.ext import commands, tasks
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
from rcon.source import rcon
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()
@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>.+?)\)"
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=\"([^\"]+)\""
re_match = re.search(re_pattern, line)
if not re_match:
log.warning("failed to parse player death log: %s", line)
log.warning("failed to parse player data: %s", line)
return
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"
)
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)
)
await player.save()
await user.fetch_steam_profile(self.bot.steam_api_key)
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.fetch_channel(self.bot.in_game_channel_id) if not channel else 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):
"""
"""
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 = user.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 handle_player_disconnected(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"
"""
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"guid=(\d+)\s+ip=([\d\.]+)\s+steam-id=(\d+)\s+access=(\w*)\s+username=\"([^\"]+)\"\s+connection-type=\"([^\"]+)\""
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
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"
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)
)
await player.update_steam_summary(re_match.group("steam_id"), self.bot.steam_api_key)
await player.save()
if player.is_dead:
return
await user.fetch_steam_profile(self.bot.steam_api_key)
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.fetch_channel(self.bot.in_game_channel_id) if not channel else channel
embed = await player.get_embed()
embed = user.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,8 +2,6 @@ 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
@ -14,16 +12,10 @@ 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

View File

@ -1,152 +0,0 @@
"""
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

View File

@ -1,42 +0,0 @@
"""
"""
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