Compare commits

...

44 Commits

Author SHA1 Message Date
511b7b038e Update CHANGELOG.md
All checks were successful
Build and Push Docker Image / build (push) Successful in 13s
2024-12-07 00:41:28 +00:00
27a2a1f558 Bump version: 0.0.1 → 0.0.2 2024-12-07 00:39:55 +00:00
6286887632 remove whitespace 2024-12-07 00:39:46 +00:00
f6aa67c739 ensure path exists and use path obj over string
All checks were successful
Build and Push Docker Image / build (push) Successful in 11s
2024-12-06 18:51:21 +00:00
19ec17a69a find console file through zomboid folder
All checks were successful
Build and Push Docker Image / build (push) Successful in 13s
2024-12-06 18:49:08 +00:00
8344bfa3b5 full avatar & remove incorrect on_ready code
All checks were successful
Build and Push Docker Image / build (push) Successful in 13s
2024-12-06 18:35:05 +00:00
512254a05d add missing steam api key
All checks were successful
Build and Push Docker Image / build (push) Successful in 17s
2024-12-06 18:21:11 +00:00
7bb2c8dbef player data handled in own cog
All checks were successful
Build and Push Docker Image / build (push) Successful in 14s
2024-12-06 18:18:18 +00:00
09cfb2ac58 fix player leave/join ignored if no access level
All checks were successful
Build and Push Docker Image / build (push) Successful in 14s
caused by bad regex pattern
2024-12-06 17:42:10 +00:00
cffe8546c1 move activity task starter into 'on_ready' event
All checks were successful
Build and Push Docker Image / build (push) Successful in 13s
it was being started before the bot websocket was established
2024-12-06 17:17:30 +00:00
cf580d741d changelog and activity fix
All checks were successful
Build and Push Docker Image / build (push) Successful in 12s
2024-12-06 17:14:08 +00:00
8bfcf7eca4 add missing await to coroutine
All checks were successful
Build and Push Docker Image / build (push) Successful in 12s
2024-12-06 17:02:32 +00:00
c302b54eae improve the way activity is changed
All checks were successful
Build and Push Docker Image / build (push) Successful in 15s
should fix issues with not updating correctly
2024-12-06 16:57:46 +00:00
78d2c7a850 fix rcon call with incorrect arguments
All checks were successful
Build and Push Docker Image / build (push) Successful in 11s
2024-12-06 14:19:54 +00:00
226c05526d admin command for forcing mod update
All checks were successful
Build and Push Docker Image / build (push) Successful in 11s
2024-12-06 14:14:52 +00:00
c947aa2148 handle mod updates
All checks were successful
Build and Push Docker Image / build (push) Successful in 13s
2024-12-06 14:10:16 +00:00
95e8672fd5 use larger avatar for embed
All checks were successful
Build and Push Docker Image / build (push) Successful in 12s
2024-12-06 13:28:52 +00:00
4e42a07b27 await thumbnail fetch
All checks were successful
Build and Push Docker Image / build (push) Successful in 12s
2024-12-06 13:12:40 +00:00
f7f2401174 Update console.py
All checks were successful
Build and Push Docker Image / build (push) Successful in 12s
2024-12-06 13:09:32 +00:00
3a85af4028 fetch steam profile via API
All checks were successful
Build and Push Docker Image / build (push) Successful in 22s
2024-12-06 13:05:11 +00:00
ed22e552a1 fix argument error for setting embed image
All checks were successful
Build and Push Docker Image / build (push) Successful in 11s
2024-12-06 12:51:51 +00:00
21bd1589a4 user class and join/leave embeds
All checks were successful
Build and Push Docker Image / build (push) Successful in 12s
2024-12-06 12:49:47 +00:00
5e216862a6 fix spam + add player disconnect message
All checks were successful
Build and Push Docker Image / build (push) Successful in 12s
2024-12-06 12:34:18 +00:00
60ad27126b console reader and player join alert
All checks were successful
Build and Push Docker Image / build (push) Successful in 23s
2024-12-06 12:23:15 +00:00
7a18ebc22d fix wrong calculation for activity message again
All checks were successful
Build and Push Docker Image / build (push) Successful in 12s
2024-12-06 11:58:04 +00:00
c09acbe9e3 move defer before blacklist check
All checks were successful
Build and Push Docker Image / build (push) Successful in 22s
2024-12-06 11:54:32 +00:00
82d2eb6ed2 fix wrong number in count calculation
All checks were successful
Build and Push Docker Image / build (push) Successful in 11s
2024-12-06 11:49:21 +00:00
82fcedd5a4 plural dependent on player count
All checks were successful
Build and Push Docker Image / build (push) Successful in 11s
2024-12-06 11:48:55 +00:00
fa1ce10b0f nicer activity message
All checks were successful
Build and Push Docker Image / build (push) Successful in 12s
done away with the debugging **{}** message
2024-12-06 11:47:40 +00:00
3bc52db5aa improve player count regex
All checks were successful
Build and Push Docker Image / build (push) Successful in 12s
2024-12-06 11:42:16 +00:00
3a286251f4 fix incorrect rcon usage
All checks were successful
Build and Push Docker Image / build (push) Successful in 11s
2024-12-06 11:38:10 +00:00
271f067eb3 player count in activity
All checks were successful
Build and Push Docker Image / build (push) Successful in 13s
2024-12-06 11:35:10 +00:00
c291f486da Delete console.py
All checks were successful
Build and Push Docker Image / build (push) Successful in 11s
2024-12-06 11:07:34 +00:00
f150bf9ddb fix duplicate cog name 'ActivityCog'
All checks were successful
Build and Push Docker Image / build (push) Successful in 12s
2024-12-06 11:06:33 +00:00
258544fad5 remove container item
All checks were successful
Build and Push Docker Image / build (push) Successful in 12s
2024-12-06 10:54:11 +00:00
dd94b878ca lower case repo name
All checks were successful
Build and Push Docker Image / build (push) Successful in 23s
2024-12-06 10:53:29 +00:00
40e0f199eb update container network
Some checks failed
Build and Push Docker Image / build (push) Failing after 13s
2024-12-06 10:31:46 +00:00
9faa3b14d3 add retries and fetch depth to checkout step
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
2024-12-06 10:26:48 +00:00
308fa4d197 comment out unused line
Some checks failed
Build and Push Docker Image / build (push) Failing after 7m14s
temp
2024-12-06 10:04:39 +00:00
2ca87c1227 Bump version: 0.0.0 → 0.0.1
Some checks failed
Build and Push Docker Image / build (push) Failing after 7m5s
2024-12-06 09:48:24 +00:00
de511f67c7 bad file name corrected 2024-12-06 09:48:16 +00:00
7642da6b43 console reader 2024-12-06 09:45:38 +00:00
cb745bc8b1 activity handler 2024-12-06 09:45:32 +00:00
0e566d9042 Versioning and docker builds
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
2024-12-06 09:44:38 +00:00
11 changed files with 468 additions and 7 deletions

4
.bumpversion.cfg Normal file
View File

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

8
.dockerignore Normal file
View File

@ -0,0 +1,8 @@
.gitignore
.env
venv/
logs/
__pycache__/
*.pyc
.gitea/
.vscode/

View File

@ -0,0 +1,62 @@
name: Build and Push Docker Image
run-name: ${{ gitea.actor }} is building and pushing a Docker Image
on:
push:
branches:
- master
- staging
- dev
pull_request:
branches:
- master
- staging
- dev
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v3
with:
retries: 2
fetch-depth: 1
- name: Install bump2version
run: pip install bump2version
- name: Get current version from bump2version
id: version
run: echo "VERSION=$(bump2version --dry-run --list patch | grep current_version | sed -r s,"^.*=",,)" >> $GITHUB_ENV
- name: Set Docker tag based on branch
id: tag
run: |
# master branch uses specific version tagging, others use the branch name
if [[ "${{ gitea.ref_name }}" == "master" ]]; then
TAG="${{ env.VERSION }}"
else
TAG="${{ gitea.ref_name }}"
fi
echo "TAG=$TAG" >> $GITHUB_ENV
- name: Build Docker image
run: |
docker build -t spiffo:${{ env.TAG }} .
- name: Login to Docker registry
run: echo ${{ secrets.DOCKER_TOKEN }} | docker login -u ${{ secrets.DOCKER_USERNAME }} --password-stdin
- name: Tag & Push Docker image
run: |
# Push the branch-specific or version-specific tag
docker tag spiffo:${{ env.TAG }} xordk/spiffo:${{ env.TAG }}
docker push xordk/spiffo:${{ env.TAG }}
# If on master, push an additional "latest" tag
if [[ "${{ gitea.ref_name }}" == "master" ]]; then
docker tag spiffo:${{ env.TAG }} xordk/spiffo:latest
docker push xordk/spiffo:latest
fi

23
CHANGELOG.md Normal file
View File

@ -0,0 +1,23 @@
# Changelog
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.2] - 2024-12-07
### Added
- Commands for accessing remote console.
- Shorthand commands for commonly used rcon commands.
- Online user count that is automatically updated and set as the bot's activity presence.
- Console reader for handling events in-game.
- Announce when user has joined/left the server.
- Mod update checker and manager, with announcements for server restart.
## [0.0.1] - 2024-12-06
### Added
- Initial project files and repository

16
Dockerfile Normal file
View File

@ -0,0 +1,16 @@
FROM python:3.12.5-slim-bullseye
# python related environment variables
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1
WORKDIR /app
# install python dependencies
COPY requirements.txt /app/
RUN pip install --upgrade pip
RUN pip install --no-cache-dir -r requirements.txt
COPY . /app/
CMD ["python", "bot.py"]

5
bot.py
View File

@ -19,6 +19,7 @@ load_dotenv(override=True)
log = logging.getLogger(__name__)
BASE_DIR = Path(__file__).resolve().parent
DATA_DIR = BASE_DIR / "data"
class DiscordBot(commands.Bot):
@ -26,10 +27,14 @@ 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):
super().__init__(command_prefix="-", intents=Intents.all())
self.in_game_channel_id = int(getenv("SPIFFO__DISCORD_CHANNEL_ID"))
self.steam_api_key = getenv("SPIFFO__STEAM_API_KEY")
self.rcon_details = {
"host": getenv("SPIFFO__RCON_HOST"),
"port": getenv("SPIFFO__RCON_PORT"),

52
cogs/activity.py Normal file
View File

@ -0,0 +1,52 @@
"""
Handles the activity displayed on the bot's Discord profile.
The current intent is to display the server's player count as the activity.
"""
import re
import logging
from discord import Game, Status
from discord.ext import commands, tasks
from rcon.source import rcon
log = logging.getLogger(__name__)
class ActivityCog(commands.Cog):
"""
Handles the bot's profile activity.
"""
def __init__(self, bot: commands.Bot):
self.bot = bot
@commands.Cog.listener()
async def on_ready(self):
if not self.update_activity.is_running():
self.update_activity.start()
@tasks.loop(seconds=30)
async def update_activity(self):
"""
Update the bot's profile activity on an interval of 30 seconds.
"""
log.debug("updating activity")
players_message = await rcon("players", **self.bot.rcon_details)
re_match = re.search(r"Players connected \((\d+)\):\s*", players_message)
if not re_match:
log.error("Failed to parse player count from rcon response: %s", players_message)
return
players_count = int(re_match.group(1))
activity = Game(name=f"with {players_count} survivor{'s' if players_count != 1 else ''}!")
await self.bot.change_presence(activity=activity, status=Status.online)
log.debug("player count in activity updated to: %s", players_count)
async def setup(bot: commands.Bot):
cog = ActivityCog(bot)
await bot.add_cog(cog)
log.info("Added %s cog", cog.__class__.__name__)

View File

@ -36,7 +36,6 @@ COMMANDS_BLACKLIST = [
"teleport",
"teleportto",
"thunder"
]
@ -51,6 +50,19 @@ class CommandCog(commands.Cog):
async def on_ready(self):
log.info("%s cog is ready", self.__class__.__name__)
admin_group = app_commands.Group(
name="admin",
description="Admin-only commands",
default_permissions=Permissions.all(),
guild_only=True
)
@admin_group.command(name="force-mod-restart-event")
async def admin_force_mod_restart_event(self, inter: Interaction):
await inter.response.send_message(content="Acknowledged", ephemeral=True)
cog = self.bot.get_cog("ConsoleCog")
await cog.handle_mod_needs_update("")
rcon_group = app_commands.Group(
name="rcon",
description="Remote console commands",
@ -59,17 +71,13 @@ class CommandCog(commands.Cog):
)
async def send_rcon_response(self, inter: Interaction, command: str) -> str:
# if inter.user.id != 377453890523627522:
# log.warning("Bad user tried to send rcon command: '%s', '%s'", inter.user.name, command)
# await inter.response.send_message("Permissions Error", ephemeral=True)
# return
await inter.response.defer()
if command in COMMANDS_BLACKLIST:
log.warning("Attempt to use banned command: '%s', '%s'", inter.user.name, command)
await inter.response.send_message("Blacklisted command", ephemeral=True)
await inter.followup.send("Blacklisted command")
return
await inter.response.defer()
response = await rcon(command, **self.bot.rcon_details)
await inter.followup.send(content=response)

127
cogs/console.py Normal file
View File

@ -0,0 +1,127 @@
"""
Reads and handles updates in the server console file.
"""
import re
import logging
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
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__)
class ConsoleCog(commands.Cog):
"""
Reads and handles the server-console.txt file.
"""
_last_line_number = 0
def __init__(self, bot: commands.Bot):
self.bot = bot
self.monitor_console.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()
async def process_console_line(self, line: str):
"""
Determine how to handle the given line from the server console.
"""
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
log.info("Planned restart in %s seconds, reason: %s", seconds_remaining, reason)
await rcon(f"Planned restart in {seconds_remaining} seconds, reason: {reason}", **self.bot.rcon_details)
while seconds_remaining > 0:
await asyncio.sleep(1)
seconds_remaining -= 1
async def kick_all_users(self):
players_message = await rcon("players", **self.bot.rcon_details)
re_pattern = r"Players connected \(\d+\):\s*(-[\w-]+(?:\s*[\w-]+)*)"
re_match = re.search(re_pattern, players_message)
if not re_match:
log.info("No players found to kick")
usernames_string = re_match.group(1)
for i, username in enumerate(usernames_string.split("-")):
if not username:
continue
await rcon(f'kickuser "{username}" -r "Server is Updating. Kicked to ensure your progress is saved"', **self.bot.rcon_details)
log.info("Kicked '%s' users for restart", i + 1)
async def handle_mod_needs_update(self, line: str):
"""
Report when one or more mods need to be updated.
"""
log.info("one or more mods are outdated")
await self.alert_and_wait_for_restart(
intervals_ms=[300000, 60000, 6000],
reason="Mod needs updating"
)
await self.kick_all_users()
await rcon("save", **self.bot.rcon_details)
await rcon("quit", **self.bot.rcon_details)
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
await channel.send(content="The server is currently being restarted for mod updates.")
async def setup(bot: commands.Bot):
cog = ConsoleCog(bot)
await bot.add_cog(cog)
log.info("Added %s cog", cog.__class__.__name__)

147
cogs/players.py Normal file
View File

@ -0,0 +1,147 @@
"""
Handles tasks related to in-game players, such as connect/disconnect alerts.
"""
import re
import logging
from dataclasses import dataclass
import httpx
from discord import Embed, Colour
from discord.ext import commands, tasks
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.
"""
def __init__(self, bot: commands.Bot):
self.bot = bot
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 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)
)
await user.fetch_steam_profile(self.bot.steam_api_key)
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
embed = user.embed
embed.title = "Player Has Connected"
embed.colour = Colour.brand_green()
await channel.send(embed=embed)
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"
"""
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)
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)
)
await user.fetch_steam_profile(self.bot.steam_api_key)
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
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)
log.info("Added %s cog", cog.__class__.__name__)

View File

@ -1,12 +1,21 @@
aiofiles==24.1.0
aiohappyeyeballs==2.4.4
aiohttp==3.11.9
aiosignal==1.3.1
anyio==4.7.0
attrs==24.2.0
bump2version==1.0.1
certifi==2024.8.30
discord.py==2.4.0
frozenlist==1.5.0
h11==0.14.0
httpcore==1.0.7
httpx==0.28.0
idna==3.10
multidict==6.1.0
propcache==0.2.1
python-dotenv==1.0.1
rcon==2.4.9
sniffio==1.3.1
typing_extensions==4.12.2
yarl==1.18.3