├── .env.sample ├── .gitignore ├── PZ-Command-Bot.service ├── PZ-Watcher-Bot.service ├── README.md ├── SourceRcon.py ├── pzbot.py └── pzwatcher.py /.env.sample: -------------------------------------------------------------------------------- 1 | RCON_PASS=SuperPassword 2 | RCON_SERVER=127.0.0.1 3 | RCON_PORT=27015 4 | DISCORD_GUILD="My Discord Server" 5 | DISCORD_TOKEN=CoolTokenHere 6 | ADMIN_ROLES="Admin" 7 | MODERATOR_ROLES="Moderator" 8 | IGNORE_CHANNELS="some-channel-name,some-other-channel" 9 | LOG_PATH="/home/steam/Zomboid/Logs" 10 | NOTIFICATION_CHANNEL="934442816493486161" 11 | INGAME_CHANNEL="934187339117391934" 12 | PROCESS_NAME="ProjectZomboid64" 13 | WHITELIST_ROLES="Survivor" 14 | SERVER_ADDRESS="1.2.3.4:16261" 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | -------------------------------------------------------------------------------- /PZ-Command-Bot.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Project Zomboid Discord Commands Bot 3 | 4 | [Service] 5 | Type=simple 6 | User=steam 7 | Group=steam 8 | WorkingDirectory=/home/steam/pz_bot/ 9 | ExecStart=-/usr/bin/python3 /home/steam/pz_bot/pzbot.py 10 | -------------------------------------------------------------------------------- /PZ-Watcher-Bot.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Project Zomboid Discord Watcher Bot 3 | 4 | [Service] 5 | Type=simple 6 | User=steam 7 | Group=steam 8 | WorkingDirectory=/home/steam/pz_bot/ 9 | ExecStart=-/usr/bin/python3 /home/steam/pz_bot/pzwatcher.py 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Project Zomboid Discord Bot 2 | Discord bot for managing your PZ server and enabling player interactions 3 | 4 | 5 | # Features 6 | #### Bot status displays the server status. Either offline, or player count ingame: 7 | Bot updates it's status with either the current count of players ingame 8 | or 'Offline' if the server is currently down 9 | 10 | Join and leave announcements 11 | 12 | #### Role based commands 13 | Limit administrative server commands to users with specific discord roles 14 | 15 | 16 | Moderators can do everything except elevate to admin 17 | 18 | Give players insight into their time on the server, like death counts (more to come here): 19 | 20 | 21 | 22 | #### User Self Service 23 | Players with the correct role can request access to the server whitelist via a command 24 | The bot will DM them with their new password and connection information to the server 25 | 26 | ``` 27 | !pzrequestaccess someuser 28 | ``` 29 | 30 | #### Player deaths and time on server 31 | Generate a live report of active playtime by users on your server. Updates live for actively connected users as well. 32 | ``` 33 | !pzplaytime 34 | User1 has played for 1d, 7h, 24m, 58s 35 | User2 has played for 1d, 1h, 46m, 15s 36 | Survivor1 has played for 17h, 13m, 40s 37 | Noob1 has played for 10h, 0m, 40s 38 | Survivor2 has played for 3h, 43m, 41s 39 | 40 | ``` 41 | 42 | ### This must be locally hosted on your PZ server due to the interactions it requires to get specific information 43 | Due to how this bot interacts with the server for specific information (rcon, log files, active processes) it must be running on the server that runs project zomboid 44 | 45 | I will not go into how to setup a bot or a service for a python script here, there are tons of guides already, but have included basic unit files for this bot 46 | 47 | 48 | pzbot.py - Handles all commands and server communication. It also will handle the bot status changing and updating. 49 | 50 | 51 | pzwatcher.py - Will watch logs and report ingame activities to specified channels 52 | 53 | 54 | You can use one or the other, or both. 55 | 56 | # Requirements and Setup 57 | Make sure you have python3-pip 58 | 59 | This requires the rcon executable in the same directory as the script 60 | Yes, I tried using python rcon, and python-valve but it did not work and consistently timed out talking to the pz server 61 | 62 | https://leviwheatcroft.github.io/selfhosted-awesome-unlist/rcon-cli.html 63 | 64 | ```pip install rcon python-dotenv discord.py psutil watchgod file_read_backwards``` 65 | 66 | Make sure your PZ rcon server is listening on 27015 67 | 68 | You must have a .`env` file present in the root directory of the project. Copy the sample environment variable file template provided in the project root directory: `cp .env.sample .env` 69 | 70 | All of these should be filled out. To get channel id's, enable dev mode on your discord app, and right click a channel and click 'copy id' 71 | ``` 72 | RCON_PASS=SuperPassword 73 | RCON_SERVER=127.0.0.1 74 | RCON_PORT=27015 75 | DISCORD_GUILD="My Discord Server" 76 | DISCORD_TOKEN=CoolTokenHere 77 | ADMIN_ROLES="Admin, Moderator" 78 | LOG_PATH="/home/steamd/Zomboid/Logs" 79 | NOTIFICATION_CHANNEL="123123211" 80 | INGAME_CHANNEL="123123123123213" 81 | PROCESS_NAME="ProjectZomboid64" 82 | WHITELIST_ROLES="Survivor" 83 | SERVER_ADDRESS="69.164.202.83:16261" 84 | ``` 85 | LOG_PATH should point to where the PZ server logs root is. This is how the player deaths are reported. 86 | 87 | ADMIN_ROLES are the discord server roles that will allow those users to run 'AdminCommands' 88 | 89 | INGAME_CHANNEL is the channel that project zomboid is attached to, and it gets excluded from command runs 90 | 91 | NOTIFICATION_CHANNEL will send player death and join/leave notification 92 | 93 | WHITELIST_ROLES="Survivor" - The role name of users that can request accounts on the pz server 94 | 95 | SERVER_ADDRESS="1.2.3.4:16261" 96 | 97 | Start the bot script 98 | 99 | Default settings are setup to work with this installer: https://github.com/rfalias/project_zomboid_installer 100 | # Usage 101 | ``` 102 | AdminCommands: 103 | pzrestartserver Restart the PZ server 104 | pzsetaccess Set the access level of a specific user. 105 | ModeratorCommands: 106 | pzadditem Adds an item to the specified user's inventory 107 | pzgetsteamid Lookup steamid of user 108 | pzkick Kick a user 109 | pzsave Save the current world 110 | pzservermsg Broadcast a server message 111 | pzsteamban Steam ban a user 112 | pzsteamunban Steam unban a user 113 | pzteleport Teleport a user to another user 114 | pzunwhitelist Remove a whitelisted user 115 | pzwhitelist Whitelist a user 116 | pzwhitelistall Whitelist all active users 117 | UserCommands: 118 | pzdeathcount Get the total death count of a player 119 | pzdeaths Get the total death count of all players 120 | pzgetoption Get the value of a server option 121 | pzlistmods List currently installed mods 122 | pzplayers Show current active players on the server 123 | pzplaytime Get the total playtime of all players 124 | pzrequestaccess Request access to the PZ server. A password will be DMd to ... 125 | whatareyou What is the bot 126 | ​No Category: 127 | help Shows this message 128 | 129 | Type !help command for more info on a command. 130 | You can also type !help category for more info on a category. 131 | ``` 132 | 133 | # Examples 134 | Admin commands can only be run by users in discord with the "Admin" role. 135 | 136 | ## Ban a user 137 | !pzsteamban SteamIDOfUser 138 | 139 | ## Make a user an admin 140 | !pzsetaccess SomeUser admin 141 | 142 | ## Get a server option 143 | Does a fuzzy lookup for a specific server option 144 | 145 | !pzgetoption zombie 146 | ``` 147 | Server options: 148 | ZombieUpdateDelta=0.5 149 | ZombieUpdateMaxHighPriority=50 150 | ZombieUpdateRadiusHighPriority=10.0 151 | ZombieUpdateRadiusLowPriority=45.0 152 | ``` 153 | -------------------------------------------------------------------------------- /SourceRcon.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | 4 | # Reference: https://github.com/frostschutz/SourceLib/blob/master/SourceRcon.py 5 | # Converted to Python3 by Guillaume "Elektordi" Genty (Tested on 3.7) 6 | #------------------------------------------------------------------------------ 7 | # SourceRcon - Python class for executing commands on Source Dedicated Servers 8 | # Copyright (c) 2010 Andreas Klauer 9 | # 10 | # Permission is hereby granted, free of charge, to any person obtaining a copy 11 | # of this software and associated documentation files (the "Software"), to deal 12 | # in the Software without restriction, including without limitation the rights 13 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 14 | # copies of the Software, and to permit persons to whom the Software is 15 | # furnished to do so, subject to the following conditions: 16 | # 17 | # The above copyright notice and this permission notice shall be included in 18 | # all copies or substantial portions of the Software. 19 | # 20 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 21 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 23 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 24 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 25 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 26 | # THE SOFTWARE. 27 | #------------------------------------------------------------------------------ 28 | 29 | """http://developer.valvesoftware.com/wiki/Source_RCON_Protocol""" 30 | 31 | import select 32 | import socket 33 | import struct 34 | 35 | SERVERDATA_AUTH = 3 36 | SERVERDATA_AUTH_RESPONSE = 2 37 | 38 | SERVERDATA_EXECCOMMAND = 2 39 | SERVERDATA_RESPONSE_VALUE = 0 40 | 41 | MAX_COMMAND_LENGTH=510 # found by trial & error 42 | 43 | MIN_MESSAGE_LENGTH=4+4+1+1 # command (4), id (4), string1 (1), string2 (1) 44 | MAX_MESSAGE_LENGTH=4+4+4096+1 # command (4), id (4), string (4096), string2 (1) 45 | 46 | # there is no indication if a packet was split, and they are split by lines 47 | # instead of bytes, so even the size of split packets is somewhat random. 48 | # Allowing for a line length of up to 400 characters, risk waiting for an 49 | # extra packet that may never come if the previous packet was this large. 50 | PROBABLY_SPLIT_IF_LARGER_THAN = MAX_MESSAGE_LENGTH - 400 51 | 52 | class SourceRconError(Exception): 53 | pass 54 | 55 | class SourceRcon(object): 56 | """Example usage: 57 | 58 | import srcds 59 | server = srcds.SourceRcon('127.0.0.1', 27015, 'gerbouille') 60 | print(server.rcon('cvarlist')) 61 | """ 62 | def __init__(self, host, port=27015, password='', timeout=1.0): 63 | self.host = host 64 | self.port = port 65 | self.password = password 66 | self.timeout = timeout 67 | self.tcp = None 68 | self.reqid = 0 69 | 70 | def disconnect(self): 71 | """Disconnect from the server.""" 72 | if self.tcp: 73 | self.tcp.close() 74 | 75 | def connect(self): 76 | """Connect to the server. Should only be used internally.""" 77 | try: 78 | self.tcp = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 79 | self.tcp.settimeout(self.timeout) 80 | self.tcp.setblocking(1) 81 | self.tcp.connect((self.host, self.port)) 82 | except socket.error as msg: 83 | raise SourceRconError('Disconnected from RCON, please restart program to continue.') 84 | 85 | def send(self, cmd, message): 86 | """Send command and message to the server. Should only be used internally.""" 87 | if len(message) > MAX_COMMAND_LENGTH: 88 | raise SourceRconError('RCON message too large to send') 89 | 90 | self.reqid += 1 91 | data = struct.pack(' MAX_MESSAGE_LENGTH: 126 | raise SourceRconError('RCON packet claims to have illegal size: %d bytes' % (packetsize,)) 127 | 128 | # read the whole packet 129 | buf = b'' 130 | 131 | while len(buf) < packetsize: 132 | try: 133 | recv = self.tcp.recv(packetsize - len(buf)) 134 | if not len(recv): 135 | raise SourceRconError('RCON connection unexpectedly closed by remote host') 136 | buf += recv 137 | except SourceRconError: 138 | raise 139 | except: 140 | break 141 | 142 | if len(buf) != packetsize: 143 | raise SourceRconError('Received RCON packet with bad length (%d of %d bytes)' % (len(buf),packetsize,)) 144 | 145 | # parse the packet 146 | requestid = struct.unpack('. 14 | # 15 | import string 16 | import os 17 | import socket 18 | import asyncio 19 | from concurrent.futures import ThreadPoolExecutor 20 | import discord 21 | from discord.ext import commands, tasks 22 | from dotenv import load_dotenv 23 | from subprocess import Popen 24 | import glob 25 | import subprocess 26 | import psutil 27 | import schedule 28 | import random 29 | from subprocess import check_output, STDOUT 30 | import time 31 | from datetime import datetime 32 | from SourceRcon import SourceRcon 33 | # Setup environment 34 | load_dotenv() 35 | TOKEN = os.getenv('DISCORD_TOKEN') 36 | RCONPASS = os.getenv('RCON_PASS') 37 | RCONSERVER = os.getenv('RCON_SERVER') 38 | RCONPORT = os.getenv('RCON_PORT') 39 | GUILD = os.getenv('DISCORD_GUILD') 40 | ADMIN_ROLES = os.getenv('ADMIN_ROLES') 41 | MODERATOR_ROLES = os.getenv('MODERATOR_ROLES') 42 | WHITELIST_ROLES = os.getenv('WHITELIST_ROLES') 43 | LOG_PATH = os.getenv('LOG_PATH', "/home/steam/Zomboid/Logs") 44 | SERVER_PATH = os.getenv('SERVER_PATH', "C:\Program Files (x86)\Steam\steamapps\common\Project Zomboid Dedicated Server") 45 | RCON_PATH = os.getenv('RCON_PATH','./') 46 | ADMIN_ROLES = ADMIN_ROLES.split(',') 47 | WHITELIST_ROLES = WHITELIST_ROLES.split(',') 48 | IGNORE_CHANNELS = os.getenv('IGNORE_CHANNELS') 49 | SERVER_ADDRESS = os.getenv('SERVER_ADDRESS') 50 | NOTIFICATION_CHANNEL = os.getenv('NOTIFICATION_CHANNEL') 51 | RESTART_CMD = os.getenv('RESTART_CMD', 'sudo systemctl restart Project-Zomboid') 52 | 53 | try: 54 | IGNORE_CHANNELS = IGNORE_CHANNELS.split(',') 55 | except: 56 | IGNORE_CHANNELS = "" 57 | intents = discord.Intents.default() 58 | intents.members = True 59 | bot = commands.Bot(command_prefix='!', intents=intents) 60 | access_levels = ['admin', 'none', 'moderator'] 61 | block_notified = list() 62 | client = discord.Client(intents=intents) 63 | 64 | async def GetDeathCount(ctx, player): 65 | deathcount = 0 66 | logs = list() 67 | for root, dirs, files in os.walk(LOG_PATH): 68 | for f in files: 69 | if "_user.txt" in f: 70 | lpath = os.path.join(root,f) 71 | logs.append(lpath) 72 | for log in logs: 73 | with open(log, 'r') as file: 74 | for line in file: 75 | if player.lower() in line.lower(): 76 | if "died" in line: 77 | deathcount += 1 78 | 79 | t = await lookuptime(ctx, player) 80 | return f"{player} has died {deathcount} times. Playtime: {t}" 81 | 82 | async def pretty_time_delta(seconds): 83 | sign_string = '-' if seconds < 0 else '' 84 | seconds = abs(int(seconds)) 85 | days, seconds = divmod(seconds, 86400) 86 | hours, seconds = divmod(seconds, 3600) 87 | minutes, seconds = divmod(seconds, 60) 88 | if days > 0: 89 | return '%s%dd, %dh, %dm, %ds' % (sign_string, days, hours, minutes, seconds) 90 | elif hours > 0: 91 | return '%s%dh, %dm, %ds' % (sign_string, hours, minutes, seconds) 92 | elif minutes > 0: 93 | return '%s%dm, %ds' % (sign_string, minutes, seconds) 94 | else: 95 | return '%s%ds' % (sign_string, seconds) 96 | 97 | async def getallplaytime(ctx): 98 | logs = list() 99 | user_time = {} 100 | for root, dirs, files in os.walk(LOG_PATH): 101 | for f in files: 102 | if "_user.txt" in f: 103 | lpath = os.path.join(root,f) 104 | logs.append(lpath) 105 | connect_time = {} 106 | fc_last_date = {} 107 | dc_last_date = {} 108 | for log in logs: 109 | with open(log, 'r') as file: 110 | for line in file: 111 | if "fully connected" in line: 112 | c_sl = line.split() 113 | c_username = c_sl[3].strip('"') 114 | c_etime = " ".join(c_sl[:2]).strip("[]") 115 | c_dt = datetime.strptime(c_etime, '%d-%m-%y %H:%M:%S.%f') 116 | connect_time[c_username] = c_dt 117 | if fc_last_date.get(c_username, datetime.min) < c_dt: 118 | fc_last_date[c_username] = c_dt 119 | 120 | if "removed connection" in line: 121 | r_sl = line.split() 122 | r_username = r_sl[3].strip('"') 123 | r_etime = " ".join(r_sl[:2]).strip("[]") 124 | r_dt = datetime.strptime(r_etime, '%d-%m-%y %H:%M:%S.%f') 125 | if r_username in connect_time: 126 | time_segment = r_dt - connect_time[r_username] 127 | time_segment = time_segment 128 | if r_username not in user_time.keys(): 129 | user_time[r_username] = time_segment 130 | else: 131 | user_time[r_username] = time_segment + user_time[r_username] 132 | del connect_time[r_username] 133 | if dc_last_date.get(r_username, datetime.min) < r_dt: 134 | dc_last_date[r_username] = r_dt 135 | 136 | for user in dc_last_date: 137 | if user in fc_last_date: 138 | if dc_last_date[user] < fc_last_date[user]: 139 | user_time[user] = user_time[user] + (datetime.now() - fc_last_date[user]) 140 | print(f"User {user} probably still connected") 141 | u = user + "(active)" 142 | user_time[u] = user_time[user] 143 | del user_time[user] 144 | user_time = dict(reversed(sorted(user_time.items(), key=lambda item: item[1]))) 145 | for user in user_time: 146 | time_pretty = await pretty_time_delta(user_time[user].total_seconds()) 147 | user_time[user] = time_pretty 148 | return user_time 149 | 150 | 151 | async def lookuptime(ctx, username): 152 | pl = await getallplaytime(ctx) 153 | for user in pl: 154 | if username == user.replace("(active)",""): 155 | return pl[user] 156 | 157 | async def getalldeaths(ctx): 158 | deathcount = 0 159 | logs = list() 160 | deathdict = {} 161 | for root, dirs, files in os.walk(LOG_PATH): 162 | for f in files: 163 | if "_user.txt" in f: 164 | lpath = os.path.join(root,f) 165 | logs.append(lpath) 166 | for log in logs: 167 | with open(log, 'r') as file: 168 | for line in file: 169 | if "died at (" in line: 170 | player = line.split()[3] 171 | if player in deathdict: 172 | deathdict[player] += 1 173 | else: 174 | deathdict[player] = 1 175 | rstring = "" 176 | deathdict = dict(reversed(sorted(deathdict.items(), key=lambda item: item[1]))) 177 | for x in deathdict: 178 | p = x 179 | c = deathdict[x] 180 | t = await lookuptime(ctx, p) 181 | rstring += f"{p} has died {c} times. Playtime: {t}\n" 182 | return rstring 183 | 184 | 185 | async def getmods(): 186 | modlist = list() 187 | with open(os.path.join(os.path.split(LOG_PATH)[0],"Server","servertest.ini"), 'r') as file: 188 | for line in file: 189 | if "Mods=" in line: 190 | mods_split = line.split("=") 191 | if len(mods_split) > 1: 192 | mods_list_split = mods_split[1].split(';') 193 | for mod in mods_list_split: 194 | modlist.append(mod) 195 | return "\n".join(modlist) 196 | 197 | 198 | async def lookupsteamid(name): 199 | for root, dirs, files in os.walk(LOG_PATH): 200 | for f in files: 201 | if "_user.txt" in f: 202 | lpath = os.path.join(root,f) 203 | with open(lpath, 'r') as file: 204 | for line in file: 205 | if "fully connected" in line: 206 | if name in line: 207 | return line.split()[2] 208 | 209 | async def IsAdmin(ctx): 210 | is_present = [i for i in ctx.author.roles if i.name in ADMIN_ROLES] 211 | return is_present 212 | 213 | async def IsMod(ctx): 214 | is_present = [i for i in ctx.author.roles if i.name in MODERATOR_ROLES] 215 | return is_present 216 | 217 | async def IsServerRunning(): 218 | for proc in psutil.process_iter(): 219 | lname = proc.name().lower() 220 | if "projectzomboid" in lname: 221 | return True 222 | return False 223 | 224 | async def restart_server(ctx): 225 | await ctx.send("Shutting server down, please wait...") 226 | await rcon_command(ctx,[f"save"]) 227 | co = check_output(RESTART_CMD, shell=True) 228 | await ctx.send(f"Server restarted, it may take a minute to be fully ready") 229 | server_down = False 230 | while not server_down: 231 | d = await rcon_command(ctx, [f"players"]) 232 | if "refused" in d: 233 | server_down = True 234 | await asyncio.sleep(5) 235 | 236 | if os.name == 'nt': 237 | terminate_zom = '''wmic PROCESS where "name like '%java.exe%' AND CommandLine like '%zomboid.steam%'" Call Terminate''' 238 | terminate_shell = '''wmic PROCESS where "name like '%cmd.exe%' AND CommandLine like '%StartServer64.bat%'" Call Terminate''' 239 | check_output(terminate_zom, shell=True) 240 | check_output(terminate_shell, shell=True) 241 | server_start = [os.path.join(SERVER_PATH,"StartServer64.bat")] 242 | p = Popen(server_start, creationflags=subprocess.CREATE_NEW_CONSOLE) 243 | r = p.stdout.read() 244 | r = r.decode("utf-8") 245 | else: 246 | check_output(RESTART_CMD, shell=True) 247 | 248 | await ctx.send("Server restarted, it may take a minute to be fully ready") 249 | 250 | async def rcon_command(ctx, command): 251 | try: 252 | sr = SourceRcon(RCONSERVER, int(RCONPORT), RCONPASS) 253 | r = sr.rcon(" ".join(command)) 254 | return r.decode('utf-8') 255 | except Exception as e: 256 | print(e) 257 | 258 | async def chunks(lst, n): 259 | """Yield successive n-sized chunks from lst.""" 260 | for i in range(0, len(lst), n): 261 | yield lst[i:i + n] 262 | 263 | 264 | async def IsChannelAllowed(ctx): 265 | channel_name = str(ctx.message.channel) 266 | is_present = [i for i in IGNORE_CHANNELS if i.lower() == channel_name.lower()] 267 | if channel_name in IGNORE_CHANNELS: 268 | if channel_name not in block_notified: 269 | await ctx.send("Not allowed to run commands in this channel") 270 | block_notified.append(channel_name) 271 | raise Exception("Not allowed to operate in channel") 272 | 273 | class AdminCommands(commands.Cog): 274 | """Admin Server Commands""" 275 | 276 | @commands.command(pass_context=True) 277 | async def pzsetaccess(self, ctx): 278 | """Set the access level of a specific user.""" 279 | await IsChannelAllowed(ctx) 280 | if await IsAdmin(ctx): 281 | print(ctx.message.content) 282 | access_split = ctx.message.content.split() 283 | user = "" 284 | level = "" 285 | try: 286 | user = access_split[1] 287 | access_level = access_split[2] 288 | except IndexError as ie: 289 | response = f"Invalid command. Try !pzsetaccess USER ACCESSLEVEL" 290 | await ctx.send(response) 291 | return 292 | if access_level not in access_levels: 293 | response = f"Invalid access level {level}. Muse be one of {access_levels}" 294 | await ctx.send(response) 295 | return 296 | c_run = await rcon_command(ctx, [f"setaccesslevel", f"{user}", f"{access_level}"]) 297 | response = f"{c_run}" 298 | else: 299 | response = f"{ctx.author}, you don't have admin rights." 300 | await ctx.send(response) 301 | 302 | @commands.command(pass_context=True) 303 | async def pzrestartserver(self, ctx): 304 | """Restart the PZ server""" 305 | await IsChannelAllowed(ctx) 306 | if await IsAdmin(ctx): 307 | bot.loop.create_task(restart_server(ctx)) 308 | 309 | class ModeratorCommands(commands.Cog): 310 | """Moderator Server Commands""" 311 | 312 | @commands.command(pass_context=True) 313 | async def pzsteamban(self, ctx): 314 | """Steam ban a user""" 315 | await IsChannelAllowed(ctx) 316 | if not await IsChannelAllowed(ctx): 317 | return 318 | if await IsMod(ctx): 319 | access_split = ctx.message.content.split() 320 | user = "" 321 | try: 322 | user = access_split[1] 323 | except IndexError as ie: 324 | response = f"Invalid command. Try !pzsteamban USER" 325 | await ctx.send(response) 326 | return 327 | c_run = await rcon_command(ctx,[f"banid", f"{user}"]) 328 | response = f"{c_run}" 329 | else: 330 | response = f"{ctx.author}, you don't have admin rights." 331 | await ctx.send(response) 332 | 333 | @commands.command(pass_context=True) 334 | async def pzsteamunban(self, ctx): 335 | """Steam unban a user""" 336 | await IsChannelAllowed(ctx) 337 | if await IsMod(ctx): 338 | access_split = ctx.message.content.split() 339 | user = "" 340 | try: 341 | user = access_split[1] 342 | except IndexError as ie: 343 | response = f"Invalid command. Try !pzsteamunban USER" 344 | await ctx.send(response) 345 | return 346 | c_run = await rcon_command(ctx,[f"unbanid", "{user}"]) 347 | response = f"{c_run}" 348 | else: 349 | response = f"{ctx.author}, you don't have admin rights." 350 | await ctx.send(response) 351 | 352 | 353 | @commands.command(pass_context=True) 354 | async def pzteleport(self, ctx): 355 | """Teleport a user to another user""" 356 | await IsChannelAllowed(ctx) 357 | if await IsMod(ctx): 358 | access_split = ctx.message.content.split() 359 | user = "" 360 | try: 361 | usera = access_split[1] 362 | userb = access_split[2] 363 | except IndexError as ie: 364 | response = f"Invalid command. Try !pzteleport USERA to USERB" 365 | await ctx.send(response) 366 | return 367 | c_run = await rcon_command(ctx,[f"teleport", f"{usera}",f"{userb}"]) 368 | response = f"{c_run}" 369 | else: 370 | response = f"{ctx.author}, you don't have admin rights." 371 | await ctx.send(response) 372 | 373 | @commands.command(pass_context=True) 374 | async def pzadditem(self, ctx): 375 | """Adds an item to the specified user's inventory""" 376 | await IsChannelAllowed(ctx) 377 | if await IsMod(ctx): 378 | print(ctx.message.content) 379 | access_split = ctx.message.content.split() 380 | user = "" 381 | item = "" 382 | try: 383 | user = access_split[1] 384 | item = access_split[2] 385 | except IndexError as ie: 386 | response =f"Invalid command. Try !pzadditem USER ITEM" 387 | await ctx.send(response) 388 | return 389 | c_run = await rcon_command(ctx,[f"additem", f"{user}", f"{item}"]) 390 | response = f"{c_run}" 391 | else: 392 | response = f"{ctx.author}, you don't have admin rights." 393 | await ctx.send(response) 394 | 395 | @commands.command(pass_context=True) 396 | async def pzkick(self, ctx): 397 | """Kick a user""" 398 | await IsChannelAllowed(ctx) 399 | if await IsMod(ctx): 400 | access_split = ctx.message.content.split() 401 | user = "" 402 | try: 403 | user = access_split[1] 404 | except IndexError as ie: 405 | response = f"Invalid command. Try !pzkick USER" 406 | await ctx.send(response) 407 | return 408 | c_run = await rcon_command(ctx,[f"kickuser", f"{user}"]) 409 | response = f"{c_run}" 410 | else: 411 | response = f"{ctx.author}, you don't have admin rights." 412 | await ctx.send(response) 413 | 414 | @commands.command(pass_context=True) 415 | async def pzwhitelist(self, ctx): 416 | """Whitelist a user""" 417 | await IsChannelAllowed(ctx) 418 | if await IsMod(ctx): 419 | access_split = ctx.message.content.split() 420 | user = "" 421 | try: 422 | user = access_split[1] 423 | except IndexError as ie: 424 | response = f"Invalid command. Try !pzwhitelist USER" 425 | await ctx.send(response) 426 | return 427 | c_run = await rcon_command(ctx,[f"addusertowhitelist", f"{user}"]) 428 | response = f"{c_run}" 429 | else: 430 | response = f"{ctx.author}, you don't have admin rights." 431 | await ctx.send(response) 432 | 433 | 434 | @commands.command(pass_context=True) 435 | async def pzservermsg(self, ctx): 436 | """Broadcast a server message""" 437 | await IsChannelAllowed(ctx) 438 | if await IsMod(ctx): 439 | access_split = ctx.message.content.split() 440 | try: 441 | access_split = access_split[1:] 442 | smsg = " ".join(access_split) 443 | except IndexError as ie: 444 | response = f"Invalid command. Try !pzservermsg My cool message" 445 | await ctx.send(response) 446 | return 447 | c_run = await rcon_command(ctx,[f'servermsg', f"{smsg}"]) 448 | response = f"{c_run}" 449 | else: 450 | response = f"{ctx.author}, you don't have admin rights." 451 | await ctx.send(response) 452 | 453 | @commands.command(pass_context=True) 454 | async def pzunwhitelist(self, ctx): 455 | """Remove a whitelisted user""" 456 | await IsChannelAllowed(ctx) 457 | if await IsMod(ctx): 458 | access_split = ctx.message.content.split() 459 | user = "" 460 | try: 461 | user = access_split[1] 462 | except IndexError as ie: 463 | response = f"Invalid command. Try !pzunwhitelist USER" 464 | await ctx.send(response) 465 | return 466 | c_run = await rcon_command(ctx,[f"removeuserfromwhitelist", f"{user}"]) 467 | response = f"{c_run}" 468 | else: 469 | response = f"{ctx.author}, you don't have admin rights." 470 | await ctx.send(response) 471 | 472 | 473 | @commands.command(pass_context=True) 474 | async def pzwhitelistall(self, ctx): 475 | """Whitelist all active users""" 476 | await IsChannelAllowed(ctx) 477 | if await IsMod(ctx): 478 | c_run = await rcon_command(ctx,[f"addalltowhitelist"]) 479 | response = f"{c_run}" 480 | else: 481 | response = f"{ctx.author}, you don't have admin rights." 482 | await ctx.send(response) 483 | 484 | 485 | @commands.command(pass_context=True) 486 | async def pzsave(self, ctx): 487 | """Save the current world""" 488 | await IsChannelAllowed(ctx) 489 | if await IsMod(ctx): 490 | c_run = await rcon_command(ctx,[f"save"]) 491 | response = f"{c_run}" 492 | else: 493 | response = f"{ctx.author}, you don't have admin rights." 494 | await ctx.send(response) 495 | 496 | @commands.command(pass_context=True) 497 | async def pzgetsteamid(self,ctx): 498 | """Lookup steamid of user""" 499 | await IsChannelAllowed(ctx) 500 | if await IsMod(ctx): 501 | access_split = ctx.message.content.split() 502 | user = "" 503 | try: 504 | user = access_split[1] 505 | except IndexError as ie: 506 | response = f"Invalid command. Try !pzunwhitelist USER" 507 | await ctx.send(response) 508 | return 509 | c_run = await lookupsteamid(user) 510 | response = f"{c_run}" 511 | else: 512 | response = f"{ctx.author}, you don't have admin rights." 513 | await ctx.send(response) 514 | 515 | 516 | bot.add_cog(AdminCommands()) 517 | bot.add_cog(ModeratorCommands()) 518 | 519 | class UserCommands(commands.Cog): 520 | """Commands open to users""" 521 | @commands.command(pass_context=True) 522 | async def pzplayers(self, ctx): 523 | """Show current active players on the server""" 524 | await IsChannelAllowed(ctx) 525 | c_run = "" 526 | c_run = await rcon_command(ctx, ["players"]) 527 | print(c_run) 528 | if not c_run: 529 | return 530 | c_run = "\n".join(c_run.split('\n')[1:-1]) 531 | results = f"Current players in game:\n{c_run}" 532 | await ctx.send(results) 533 | 534 | 535 | @commands.command(pass_context=True) 536 | async def pzgetoption(self, ctx): 537 | """Get the value of a server option""" 538 | await IsChannelAllowed(ctx) 539 | cmd_split = ctx.message.content.split() 540 | option_find = "" 541 | try: 542 | option_find = cmd_split[1] 543 | except IndexError as ie: 544 | response = f"Invalid command. Try !pzgetoption OPTIONNAME" 545 | await ctx.send(response) 546 | return 547 | copt = await rcon_command(ctx,'showoptions') 548 | copt_split = copt.split('\n') 549 | match = list(filter(lambda x: option_find.lower() in x.lower(), copt_split)) 550 | match = '\n'.join(list(map(lambda x: x.replace('* ',''),match))) 551 | results = f"Server options:\n{match}" 552 | await ctx.send(results) 553 | 554 | 555 | @commands.command(pass_context=True) 556 | async def pzdeathcount(self, ctx): 557 | """Get the total death count of a player""" 558 | await IsChannelAllowed(ctx) 559 | cmd_split = ctx.message.content.split() 560 | option_find = "" 561 | try: 562 | username = cmd_split[1] 563 | except IndexError as ie: 564 | response = f"Invalid command. Try !pzdeathcount USERNAME" 565 | await ctx.send(response) 566 | return 567 | dc = await GetDeathCount(ctx, username) 568 | results = dc 569 | await ctx.send(results) 570 | 571 | @commands.command(pass_context=True) 572 | async def pzplaytime(self, ctx): 573 | """Get the total playtime of all players""" 574 | await IsChannelAllowed(ctx) 575 | cmd_split = ctx.message.content.split() 576 | dc = await getallplaytime(ctx) 577 | pt_list = list() 578 | for user in dc: 579 | upt = dc[user] 580 | pt_list.append(f"{user} has played for {upt}") 581 | clist = chunks(pt_list, 100) 582 | async for c in clist: 583 | await ctx.send('\n'.join(c)) 584 | 585 | @commands.command(pass_context=True) 586 | async def pzdeaths(self, ctx): 587 | """Get the total death count of all players""" 588 | await IsChannelAllowed(ctx) 589 | cmd_split = ctx.message.content.split() 590 | dc = await getalldeaths(ctx) 591 | results = dc.split('\n') 592 | clist = chunks(results, 100) 593 | async for c in clist: 594 | await ctx.send('\n'.join(c)) 595 | 596 | 597 | @commands.command(pass_context=True) 598 | async def whatareyou(self, ctx): 599 | """What is the bot""" 600 | await IsChannelAllowed(ctx) 601 | results = f"I'm a bot for managing Project Zomboid servers, I'm written in python 3.\nRead more here: https://rfalias.github.io/project_zomboid_bot/" 602 | await ctx.send(results) 603 | 604 | @commands.command(pass_context=True) 605 | async def pzlistmods(self, ctx): 606 | """List currently installed mods""" 607 | await IsChannelAllowed(ctx) 608 | cmd_split = ctx.message.content.split() 609 | gm = await getmods() 610 | results = f"Currently installed mods:\n{gm}" 611 | await ctx.send(results) 612 | 613 | @commands.command(pass_context=True) 614 | async def pzrequestaccess(self, ctx): 615 | """Request access to the PZ server. A password will be DMd to you. These are hashed and can only be sent once""" 616 | is_present = [i for i in ctx.author.roles if i.name in WHITELIST_ROLES] 617 | if is_present: 618 | access_split = ctx.message.content.split() 619 | user = "" 620 | try: 621 | user = access_split[1] 622 | except IndexError as ie: 623 | response = f"Invalid command. Try !pzrequestaccess USER" 624 | await ctx.send(response) 625 | return 626 | password = ''.join(random.SystemRandom().choice(string.ascii_uppercase + string.digits + string.ascii_lowercase) for _ in range(8)) 627 | 628 | c_run = await rcon_command(ctx,[f"adduser {user} {password}"]) 629 | response = f"{c_run}" 630 | if "exists" in response: 631 | await ctx.message.author.send(f"Unable to create user, try another name") 632 | return 633 | if "created" in response: 634 | await ctx.message.author.send(f"Your request was accepted.\nUsername: {user}\nPassword: {password}\nAddress: {SERVER_ADDRESS}") 635 | return 636 | else: 637 | await ctx.message.author.send(f"You have not been given access to the server yet\nPlease wait for an admin to authorize you") 638 | return 639 | #await ctx.message.author.send(f"Your request user {user} has been created\nPassword: password\nServer Address: {SERVER_ADDRESS}") 640 | 641 | bot.add_cog(UserCommands()) 642 | 643 | async def pzplayers(): 644 | plist = list() 645 | c_run = "" 646 | c_run = await rcon_command(None, ["players"]) 647 | c_run = c_run.split('\n')[1:-1] 648 | return len(c_run) 649 | 650 | async def status_task(): 651 | while True: 652 | _serverUp = await IsServerRunning() 653 | if _serverUp: 654 | playercount = 0 655 | try: 656 | playercount = await pzplayers() 657 | except Exception as e: 658 | print(e) 659 | await asyncio.sleep(20) 660 | continue 661 | 662 | await bot.change_presence(activity=discord.Activity(type=discord.ActivityType.watching, name=f"{playercount} survivors online")) 663 | else: 664 | await bot.change_presence(activity=discord.Activity(type=discord.ActivityType.watching, name=f"Server offline")) 665 | await asyncio.sleep(20) 666 | 667 | @bot.event 668 | async def on_ready(): 669 | bot.loop.create_task(status_task()) 670 | 671 | print("Starting bot") 672 | bot.run(TOKEN) 673 | -------------------------------------------------------------------------------- /pzwatcher.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | # This program is free software: you can redistribute it and/or modify 4 | # it under the terms of the GNU General Public License as published by 5 | # the Free Software Foundation, version 3. 6 | # 7 | # This program is distributed in the hope that it will be useful, but 8 | # WITHOUT ANY WARRANTY; without even the implied warranty of 9 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 10 | # General Public License for more details. 11 | # 12 | # You should have received a copy of the GNU General Public License 13 | # along with this program. If not, see . 14 | # 15 | 16 | import os 17 | import socket 18 | import asyncio 19 | from concurrent.futures import ThreadPoolExecutor 20 | import discord 21 | from discord.ext import commands, tasks 22 | from dotenv import load_dotenv 23 | from subprocess import Popen 24 | import glob 25 | import subprocess 26 | from file_read_backwards import FileReadBackwards 27 | import datetime 28 | # Setup environment 29 | load_dotenv() 30 | TOKEN = os.getenv('DISCORD_TOKEN') 31 | GUILD = os.getenv('DISCORD_GUILD') 32 | LOG_PATH = os.getenv('LOG_PATH', "/home/steam/Zomboid/Logs") 33 | NOTIFICATION_CHANNEL = os.getenv('NOTIFICATION_CHANNEL') 34 | INGAME_CHANNEL = os.getenv('INGAME_CHANNEL') 35 | bot = commands.Bot(command_prefix='!') 36 | access_levels = ['admin', 'none', 'moderator'] 37 | intents = discord.Intents.default() 38 | intents.members = True 39 | client = discord.Client() 40 | 41 | 42 | import asyncio 43 | from watchgod import awatch 44 | 45 | 46 | async def logwatcher(): 47 | await client.wait_until_ready() 48 | counter = 0 49 | nchannel = client.get_channel(id=int(NOTIFICATION_CHANNEL)) 50 | ichannel = client.get_channel(id=int(INGAME_CHANNEL)) 51 | async for changes in awatch(LOG_PATH): 52 | found_files = list() 53 | for p in changes: 54 | found_files.append(p[1]) 55 | user_paths = list(filter(lambda x: "_user.txt" in x, found_files)) 56 | for x in user_paths: 57 | print(f"Checking {x} for deaths and player join") 58 | player_check = await PlayerCheck(x, nchannel) 59 | 60 | 61 | 62 | player_notif = {} 63 | async def PlayerCheck(lfile, channel): 64 | count = 0 65 | try: 66 | with FileReadBackwards(lfile) as frb: 67 | for l in frb: 68 | if "disconnected player" in l: 69 | if count == 0: 70 | count += 1 71 | continue 72 | ls = l.split() 73 | if "removed connection index" in l: 74 | user = ls[3].strip('"') 75 | player_notif[user] = 0 76 | await channel.send(f"{user} has disconnected!") 77 | break 78 | if "fully connected (" in l: 79 | user = ls[3].strip('"') 80 | if player_notif.get(user, 0) < 1: 81 | print("Send join") 82 | await channel.send(f"{user} has joined!") 83 | else: 84 | player_notif[user] -= 1 85 | 86 | break 87 | if "died at (" in l: 88 | if count > 0: 89 | break 90 | user = ls[3].strip('"') 91 | player_notif[user] = 1 92 | await channel.send(f"{user} has died!") 93 | break 94 | break 95 | except Exception as e: 96 | print(e) 97 | 98 | 99 | 100 | async def IsAdmin(ctx): 101 | is_present = [i for i in ctx.author.roles if i.name in ADMIN_ROLES] 102 | return is_present 103 | 104 | 105 | 106 | client.loop.create_task(logwatcher()) 107 | client.run(TOKEN) 108 | --------------------------------------------------------------------------------