All checks were successful
Build and Push Docker Image / build (push) Successful in 14s
caused by bad regex pattern
219 lines
7.7 KiB
Python
219 lines
7.7 KiB
Python
"""
|
|
Reads and handles updates in the server console file.
|
|
"""
|
|
|
|
import re
|
|
import logging
|
|
import asyncio
|
|
from pathlib import Path
|
|
from dataclasses import dataclass
|
|
|
|
import httpx
|
|
import aiofiles
|
|
from rcon.source import rcon
|
|
from discord import Embed, Colour
|
|
from discord.ext import commands, tasks
|
|
|
|
CONSOLE_FILE_PATH = Path(__file__).parent.parent / "data" / "server-console.txt"
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class ZomboidUser:
|
|
guid: str
|
|
ip: str
|
|
steam_id: str
|
|
access: str
|
|
username: str
|
|
connection_type: str
|
|
|
|
@property
|
|
def steam_url(self):
|
|
return f"https://steamcommunity.com/profiles/{self.steam_id}"
|
|
|
|
async def get_steam_profile_picture(self, steam_api_key: str):
|
|
if not steam_api_key:
|
|
log.warning("No steam API key, can't get profile picture.")
|
|
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()
|
|
|
|
data = response.json()
|
|
avatar_url = data["response"]["players"][0]["avatarfull"]
|
|
return avatar_url
|
|
|
|
|
|
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:
|
|
await self.handle_player_joined(line)
|
|
|
|
elif "ConnectionManager: [disconnect]" in line:
|
|
await self.handle_player_left(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 handle_player_joined(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)
|
|
)
|
|
|
|
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 = Embed(
|
|
title=user.username,
|
|
url=user.steam_url,
|
|
description="Player has joined the server",
|
|
colour=Colour.brand_green()
|
|
)
|
|
embed.set_thumbnail(url=await user.get_steam_profile_picture(self.bot.steam_api_key))
|
|
|
|
await channel.send(embed=embed)
|
|
|
|
async def handle_player_left(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)
|
|
)
|
|
|
|
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 = Embed(
|
|
title=user.username,
|
|
url=user.steam_url,
|
|
description="Player has left the server",
|
|
colour=Colour.brand_red()
|
|
)
|
|
embed.set_thumbnail(url=await user.get_steam_profile_picture(self.bot.steam_api_key))
|
|
|
|
await channel.send(embed=embed)
|
|
|
|
|
|
async def setup(bot: commands.Bot):
|
|
cog = ConsoleCog(bot)
|
|
await bot.add_cog(cog)
|
|
log.info("Added %s cog", cog.__class__.__name__)
|