├── .gitattributes ├── .gitignore ├── LICENSE ├── README.md ├── README_EN.md ├── data └── img-01.png ├── requirements.txt ├── src ├── UE4SS-PalServerInject.zip ├── __init__.py ├── backup.py ├── config.ini ├── pyinstaller.py ├── read_conf.py ├── task_scheduler.py └── utils │ ├── __init__.py │ └── log_control.py └── tests ├── __init__.py └── test_rcon.py /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .conda 3 | .vscode 4 | Backup 5 | logs 6 | palworld-python-script.zip 7 | # Byte-compiled / optimized / DLL files 8 | __pycache__/ 9 | *.py[cod] 10 | *$py.class 11 | 12 | # C extensions 13 | *.so 14 | 15 | # Distribution / packaging 16 | .Python 17 | build/ 18 | develop-eggs/ 19 | dist/ 20 | downloads/ 21 | eggs/ 22 | .eggs/ 23 | lib/ 24 | lib64/ 25 | parts/ 26 | sdist/ 27 | var/ 28 | wheels/ 29 | share/python-wheels/ 30 | *.egg-info/ 31 | .installed.cfg 32 | *.egg 33 | MANIFEST 34 | 35 | # PyInstaller 36 | # Usually these files are written by a python script from a template 37 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 38 | *.manifest 39 | *.spec 40 | 41 | # Installer logs 42 | pip-log.txt 43 | pip-delete-this-directory.txt 44 | 45 | # Unit test / coverage reports 46 | htmlcov/ 47 | .tox/ 48 | .nox/ 49 | .coverage 50 | .coverage.* 51 | .cache 52 | nosetests.xml 53 | coverage.xml 54 | *.cover 55 | *.py,cover 56 | .hypothesis/ 57 | .pytest_cache/ 58 | cover/ 59 | 60 | # Translations 61 | *.mo 62 | *.pot 63 | 64 | # Django stuff: 65 | *.log 66 | local_settings.py 67 | db.sqlite3 68 | db.sqlite3-journal 69 | 70 | # Flask stuff: 71 | instance/ 72 | .webassets-cache 73 | 74 | # Scrapy stuff: 75 | .scrapy 76 | 77 | # Sphinx documentation 78 | docs/_build/ 79 | 80 | # PyBuilder 81 | .pybuilder/ 82 | target/ 83 | 84 | # Jupyter Notebook 85 | .ipynb_checkpoints 86 | 87 | # IPython 88 | profile_default/ 89 | ipython_config.py 90 | 91 | # pyenv 92 | # For a library or package, you might want to ignore these files since the code is 93 | # intended to run in multiple environments; otherwise, check them in: 94 | # .python-version 95 | 96 | # pipenv 97 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 98 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 99 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 100 | # install all needed dependencies. 101 | #Pipfile.lock 102 | 103 | # poetry 104 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 105 | # This is especially recommended for binary packages to ensure reproducibility, and is more 106 | # commonly ignored for libraries. 107 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 108 | #poetry.lock 109 | 110 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 111 | __pypackages__/ 112 | 113 | # Celery stuff 114 | celerybeat-schedule 115 | celerybeat.pid 116 | 117 | # SageMath parsed files 118 | *.sage.py 119 | 120 | # Environments 121 | .env 122 | .venv 123 | env/ 124 | venv/ 125 | ENV/ 126 | env.bak/ 127 | venv.bak/ 128 | 129 | # Spyder project settings 130 | .spyderproject 131 | .spyproject 132 | 133 | # Rope project settings 134 | .ropeproject 135 | 136 | # mkdocs documentation 137 | /site 138 | 139 | # mypy 140 | .mypy_cache/ 141 | .dmypy.json 142 | dmypy.json 143 | 144 | # Pyre type checker 145 | .pyre/ 146 | 147 | # pytype static type analyzer 148 | .pytype/ 149 | 150 | # Cython debug symbols 151 | cython_debug/ 152 | 153 | # PyCharm 154 | # JetBrains specific template is maintainted in a separate JetBrains.gitignore that can 155 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 156 | # and can be added to the global gitignore or merged into this file. For a more nuclear 157 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 158 | #.idea/ 159 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Cassianvale 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # palworld-python-script 2 | 3 | _✨ 适用于palworld windows轮询自动重启服务端自动发送关服通知 ✨_ 4 | 5 | 简体中文 / [English](./README_EN.md) 6 | 7 | ## 特别鸣谢 8 | https://github.com/VeroFess/PalWorld-Server-Unoffical-Api 9 | 本项目使用了VeroFess大佬的dll注入,得以使用RCON发送中文消息,感谢VeroFess的无私分享 10 | 11 | ## 主要功能 12 | 13 | 1.轮询任务重启服务端 14 | 2.重启服务端前通过RCON指令发送关服倒计时 15 | 3.自定义存档备份时间 16 | 4.自定义轮询发送RCON中文公告 17 | 18 | ## 开发计划 19 | 20 | - [x] 增加自定义启动参数 21 | - [x] 自定义关服倒计时通知内容 22 | - [x] 增加守护进程 23 | - [x] rcon-cli客户端转为第三方rcon库 24 | - [x] 内存使用百分比检测 25 | - [x] 内存到达阈值重启倒计时通知 26 | 27 | ## 使用说明 28 | 29 | 1.下载 Releases 中最新的安装包 30 | https://github.com/Cassianvale/palworld-python-script/releases 31 | 2.有两个exe程序,一个是`task_scheduler.exe`轮询重启守护进程等,一个是`backup.exe`独立的定时备份存档,必须与`config.ini`配置文件放在同一目录下运行 32 | 3.修改`config.ini`配置文件,配置文件中有详细的说明 33 | ![img-01.png](data/img-01.png) 34 | 35 | ## 二次开发 36 | 37 | 1.确保你安装了 Python 环境版本 3.10 或更高版本 38 | 2.执行命令安装依赖 `pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple` 39 | 3.控制台运行python脚本 `python pyinstaller.py` 打包 `backup.exe`, `task_scheduler.exe`, `config.ini` 40 | 2.配置 `config.ini` 与exe程序同目录运行 41 | 42 | 具体使用请参考飞书文档 43 | https://cxqzok4p36.feishu.cn/docx/YxPtdYoqCo5PdfxSyNgcDfIwnwe 44 | 45 | -------------------------------------------------------------------------------- /README_EN.md: -------------------------------------------------------------------------------- 1 | # palworld-python-script 2 | 3 | _✨ Suitable for palworld windows polling auto-restart server and auto-send shutdown notifications ✨_ 4 | 5 | [简体中文](./README.md) / English 6 | 7 | ## Special Thanks 8 | https://github.com/VeroFess/PalWorld-Server-Unoffical-Api 9 | This project uses the DLL injection provided by VeroFess, which allows us to send Chinese messages via RCON. We appreciate VeroFess's selfless sharing. 10 | 11 | ## Main Features 12 | 13 | 1. Polling task to restart the server 14 | 2. Send shutdown countdown via RCON commands before server restart 15 | 3. Customize the backup time of the archive 16 | 4. Custom Polling for Sending Chinese RCON Announcements 17 | 5. 18 | ## Development Plan 19 | 20 | - [x] Added custom startup parameters 21 | - [x] Customized shutdown countdown notification content 22 | - [x] Added daemon process 23 | - [x] Switched rcon-cli client to third-party rcon library 24 | - [x] Memory usage percentage detection 25 | - [x] Countdown notification for restart when memory reaches threshold 26 | 27 | ## Instructions for Use 28 | 29 | 1. Download the latest installation package from Releases 30 | https://github.com/Cassianvale/palworld-python-script/releases 31 | 2. There are two exe programs, one is `task_scheduler.exe` for polling, restarting daemon processes, etc., and the other is `backup.exe` for independent timed backup of archives. Both must be run in the same directory as the `config.ini` configuration file. 32 | 3. Modify the `config.ini` configuration file. Detailed instructions are provided in the configuration file. 33 | ![img-01.png](data/img-01.png) 34 | 35 | ## Usage 36 | 37 | 1. Ensure that you have Python environment version 3.10 or higher installed. 38 | 2. Run the command `pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple` to install the dependencies. 39 | 3. Run the Python script from the console `python pyinstaller.py` to package `backup.exe`, `task_scheduler.exe`, and `config.ini`. 40 | 4. Configure `config.ini` and run it in the same directory as the exe program. 41 | For specific usage, please refer to the Feishu document 42 | https://cxqzok4p36.feishu.cn/docx/YxPtdYoqCo5PdfxSyNgcDfIwnwe 43 | 44 | ## Thanks 45 | rcon 46 | https://github.com/conqp/rcon -------------------------------------------------------------------------------- /data/img-01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cassianvale/palworld-python-script/5826adb56637546496360769f98bcfb88fff7f8a/data/img-01.png -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cassianvale/palworld-python-script/5826adb56637546496360769f98bcfb88fff7f8a/requirements.txt -------------------------------------------------------------------------------- /src/UE4SS-PalServerInject.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cassianvale/palworld-python-script/5826adb56637546496360769f98bcfb88fff7f8a/src/UE4SS-PalServerInject.zip -------------------------------------------------------------------------------- /src/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- encoding: utf-8 -*- 3 | -------------------------------------------------------------------------------- /src/backup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- encoding: utf-8 -*- 3 | 4 | import shutil 5 | import time 6 | import datetime 7 | import os 8 | from src import read_conf 9 | from src.utils.log_control import INFO 10 | 11 | 12 | class Backup: 13 | 14 | def __init__(self): 15 | self.conf = read_conf.read_config() 16 | self.appName = 'PalServer-Win64-Test-Cmd.exe' 17 | self.backup_source = os.path.join(self.conf['main_directory'], 'Pal', 'Saved') 18 | 19 | # 备份任务 20 | def backup_task(self): 21 | # backup_dir为空时,设置当前目录下的Backup文件夹为备份目录 22 | if self.conf['backup_dir'] == '': 23 | backup_dir = os.path.join(os.getcwd(), 'Backup') 24 | if not os.path.exists(backup_dir): 25 | os.makedirs(backup_dir) 26 | self.conf['backup_dir'] = backup_dir 27 | else: 28 | # 使用配置文件中的备份目录 29 | backup_dir = self.conf['backup_dir'] 30 | if not os.path.exists(backup_dir): 31 | os.makedirs(backup_dir) 32 | 33 | # 如果备份间隔不为空,则执行备份 34 | if self.conf['backup_interval']: 35 | 36 | INFO.logger.info("自动备份已开启,正在进行备份......") 37 | print("\n自动备份已开启,正在进行备份......") 38 | 39 | while True: 40 | try: 41 | datetime_now = datetime.datetime.now().strftime('%Y-%m-%d_%H-%M-%S') 42 | 43 | # 备份文件 44 | shutil.copytree(self.backup_source, os.path.join(backup_dir, f"Saved_{datetime_now}")) 45 | 46 | time.sleep(1) 47 | INFO.logger.info("备份成功,文件名为:Saved_" + datetime_now) 48 | print("\r备份成功,文件名为:Saved_" + datetime_now) 49 | # 存档备份位置 50 | backup_path = os.path.join(os.getcwd(), backup_dir) 51 | print(f"\r存档备份位置:{backup_path}") 52 | # 在备份执行前删除旧的备份 53 | self.delete_old_backups(int(self.conf['del_old_backup_days'])) 54 | # 显示倒计时并等待指定的备份间隔 55 | for i in range(int(self.conf['backup_interval']), 0, -1): 56 | print(f'\r下一次备份将在 {i} 秒后开始...', end='') 57 | time.sleep(1) 58 | except FileNotFoundError: 59 | INFO.logger.error(f"备份失败,请检查 config.ini 中 main_directory 游戏主目录配置是否正确") 60 | print(f"\r备份失败,请检查 config.ini 中 main_directory 游戏主目录配置是否正确") 61 | time.sleep(3) 62 | exit(0) 63 | 64 | # 备份时间必须大于等于60秒 65 | elif int(self.conf['backup_interval']) < 60: 66 | INFO.logger.error("备份时间 backup_interval 必须大于等于1分钟,请重新设置!") 67 | print("\r备份时间 backup_interval 备份时间必须大于等于1分钟,请重新设置!") 68 | time.sleep(3) 69 | exit(0) 70 | 71 | # 如果为空,则不执行备份 72 | else: 73 | INFO.logger.info("自动备份已关闭,不执行备份任务!") 74 | print("\r自动备份已关闭,不执行备份任务!") 75 | 76 | def delete_old_backups(self, days): 77 | """ 78 | 删除指定天数之前的备份文件 79 | :param days: 指定的天数 80 | """ 81 | if self.conf['del_old_backup_days']: 82 | INFO.logger.info(f"已开启删除备份文件,每隔{self.conf['backup_interval']}秒执行一次") 83 | print(f"\n已开启删除备份文件,每隔{self.conf['backup_interval']}秒执行一次") 84 | cutoff = datetime.datetime.now() - datetime.timedelta(days=days) 85 | 86 | files = os.listdir(self.conf['backup_dir']) 87 | 88 | for file in files: 89 | file_path = os.path.join(self.conf['backup_dir'], file) 90 | # 检查文件是否是一个备份文件,可以根据你的需要修改这个条件 91 | if os.path.isdir(file_path) and 'Saved_' in file: 92 | # 从文件名解析日期和时间 93 | date_str = file.split('_')[1] # 获取日期字符串 94 | time_str = file.split('_')[2] # 获取时间字符串 95 | file_datetime = datetime.datetime.strptime(date_str + ' ' + time_str, '%Y-%m-%d %H-%M-%S') 96 | 97 | # 如果文件的创建时间早于cutoff,删除文件 98 | if file_datetime < cutoff: 99 | try: 100 | shutil.rmtree(file_path) # 删除目录 101 | INFO.logger.info(f"开始删除旧的备份文件,已成功删除备份文件:{file}") 102 | print(f"开始删除旧的备份文件,已成功删除备份文件:{file}") 103 | except OSError as e: 104 | INFO.logger.error(f"删除备份文件失败:{file}, 原因:{e}") 105 | print(f"删除备份文件失败:{file}, 原因:{e}") 106 | else: 107 | INFO.logger.info("未设置备份文件的自动删除天数,不执行删除任务!") 108 | print("未设置备份文件的自动删除天数,不执行删除任务!") 109 | 110 | 111 | if __name__ == '__main__': 112 | backup = Backup() 113 | backup.backup_task() 114 | 115 | -------------------------------------------------------------------------------- /src/config.ini: -------------------------------------------------------------------------------- 1 | [Settings] 2 | # 游戏主目录 3 | main_directory = D:\Steam\steamapps\common\PalServer 4 | 5 | # 启动参数可以为空(自定义端口和玩家数量,例如: arguments = -port=21424 -players=32) 6 | arguments = 7 | 8 | # 自定义服务器重启间隔(小时、分钟) 9 | restart_interval_hours = 24 10 | restart_interval_minutes = 0 11 | 12 | # 是否开启多核(True/False) 13 | use_multicore_options = True 14 | 15 | # 守护进程是否开启(True/False)和轮询监控的时间(秒) 16 | daemon_enabled = False 17 | daemon_time = 5 18 | 19 | [RCON] 20 | # RCON指令开关(仅发送关服倒计时) 21 | rcon_enabled = False 22 | # 如果要用RCON指令的话防火墙必须打开25575端口,游戏配置文件内必须打开RCON并设置AdminPassword 23 | # 你的公网ip 和 RCON端口号,AdminPassword输入你游戏配置文件内设置的管理员密码 24 | HOST = 127.0.0.1 25 | PORT = 25575 26 | AdminPassword = 1234 27 | COMMAND = Broadcast 28 | 29 | # 自定义关服消息通知,格式为:关服倒计时:消息内容;关服倒计时:消息内容,以;分隔 30 | # Tis: palserver服务器消息有限制非脚本问题,每段字符数上限大概50个字符左右 31 | # 而且会忽略空格后的内容且无法输入中文,无法连续发送消息 32 | shutdown_notices = 30:Service_restarts_for_30_seconds;20:Service_restarts_for_20_seconds;10:Service_restarts_for_10_seconds 33 | shutdown_notices_cn = 30:[公告]服务器将在 30 秒后重启!;20:[公告]服务器将在 20 秒后重启!;10:[公告]服务器将在 10 秒后重启!;5:[公告]服务器将在 5 秒后重启! 34 | 35 | [Messages] 36 | # 自动通过注入模式发送公告,可增加最多10个公告信息,按顺序填写,多余的公告删除即可 37 | # Tis: 试验性功能,并不保证所有人能用,且有部分机器启动会崩溃,且有性能损失 38 | # 默认自动解压"UE4SS-PalServerInject.zip"文件到"~\PalServer\Pal\Binaries\Win64"目录下 39 | PalInject_enabled = False 40 | # 是否开启自动公告(True/False),该配置只有在PalInject_enabled = True时有效,可注入支持中文rcon 41 | announcement_enabled = False 42 | # 公告间隔时间尽量大于30秒,时间尽量不要太短,游戏内聊天框无法向下滚动。 43 | announcement_time = 30 44 | announcement_messages_1 = 公告1 45 | announcement_messages_2 = 公告2 46 | announcement_messages_3 = 公告3 47 | announcement_messages_4 = 公告4 48 | announcement_messages_5 = 公告5 49 | 50 | [Memory] 51 | # 已使用内存百分比达到阈值进行重启 52 | # 是否开启内存监控(True/False) 53 | memory_monitor_enabled = False 54 | # 轮询间隔必须大于5秒 55 | polling_interval_seconds = 6 56 | # 内存使用百分比阈值(百分比) 57 | memory_usage_threshold = 90 58 | 59 | [Backup] 60 | # 需要备份到的目录(为空的话则会默认在当前目录创建Backup存档目录) 61 | backup_dir = 62 | # 自动删除备份多少天前的存档(0不开启删除功能) 63 | del_old_backup_days = 1 64 | # 自定义服务器备份间隔必须 ≥ 1分钟 65 | backup_interval_hours = 3 66 | backup_interval_minutes = 0 -------------------------------------------------------------------------------- /src/pyinstaller.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- encoding: utf-8 -*- 3 | 4 | import subprocess 5 | import shutil 6 | import os 7 | 8 | """小心别把自己的config.ini打包进去""" 9 | 10 | 11 | def remove_ds_store(dir): 12 | # 删除DS_Store 13 | for root, dirs, files in os.walk(dir): 14 | for file in files: 15 | if file == '.DS_Store': 16 | os.remove(os.path.join(root, file)) 17 | 18 | 19 | def package_scripts(): 20 | 21 | if os.path.exists('dist/palworld-python-script.zip'): 22 | os.remove('dist/palworld-python-script.zip') 23 | print('已删除原dist/palworld-python-script.zip') 24 | if os.path.exists('palworld-python-script.zip'): 25 | os.remove('palworld-python-script.zip') 26 | print('已删除原palworld-python-script.zip') 27 | 28 | scripts = ['task_scheduler.py', 'backup.py'] 29 | print('正在打包:', scripts) 30 | 31 | for script in scripts: 32 | 33 | command = 'pyinstaller', '--onefile', script 34 | subprocess.run(command) 35 | 36 | shutil.copy('config.ini', 'dist') 37 | 38 | shutil.make_archive('palworld-python-script', 'zip', 'dist') 39 | print("palworld-python-script 打包成功!") 40 | 41 | shutil.move('palworld-python-script.zip', 'dist') 42 | print("打包后的palworld-python-script.zip已放入dist文件夹!") 43 | 44 | 45 | if __name__ == '__main__': 46 | remove_ds_store('dist') 47 | package_scripts() 48 | 49 | -------------------------------------------------------------------------------- /src/read_conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- encoding: utf-8 -*- 3 | 4 | import codecs 5 | import configparser 6 | import json 7 | import os 8 | 9 | 10 | def read_config(): 11 | # 读取配置文件 12 | config = configparser.ConfigParser() 13 | with codecs.open('config.ini', 'r', encoding='utf-8-sig') as f: 14 | config.read_file(f) 15 | main_directory = config.get('Settings', 'main_directory') 16 | arguments = config.get('Settings', 'arguments') 17 | palinject_enabled = config.getboolean('Messages', 'PalInject_enabled') 18 | use_multicore_options = config.getboolean('Settings', 'use_multicore_options') 19 | restart_interval_hours = config.getint('Settings', 'restart_interval_hours') 20 | restart_interval_minutes = config.getint('Settings', 'restart_interval_minutes') 21 | daemon_enabled = config.getboolean('Settings', 'daemon_enabled') 22 | daemon_time = config.get('Settings', 'daemon_time') 23 | memory_monitor_enabled = config.getboolean('Memory', 'memory_monitor_enabled') 24 | polling_interval_seconds = config.getint('Memory', 'polling_interval_seconds') 25 | memory_usage_threshold = config.getint('Memory', 'memory_usage_threshold') 26 | shutdown_notices = dict(item.split(':') for item in config.get('RCON', 'shutdown_notices', raw=True).split(';')) 27 | shutdown_notices_cn = dict(item.split(':') for item in config.get('RCON', 'shutdown_notices_cn', raw=True).split(';')) 28 | rcon_enabled = config.getboolean('RCON', 'rcon_enabled') 29 | rcon_host = config.get('RCON', 'HOST') 30 | rcon_port = config.getint('RCON', 'PORT') 31 | rcon_password = config.get('RCON', 'AdminPassword', raw=True) 32 | rcon_command = config.get('RCON', 'COMMAND') 33 | 34 | announcement_enabled = config.getboolean('Messages', 'announcement_enabled') 35 | announcement_time = config.getint('Messages', 'announcement_time') 36 | 37 | backup_dir = config.get('Backup', 'backup_dir') 38 | del_old_backup_days = config.getint('Backup', 'del_old_backup_days') 39 | backup_interval_hours = config.getint('Backup', 'backup_interval_hours') 40 | backup_interval_minutes = config.getint('Backup', 'backup_interval_minutes') 41 | 42 | # 获取公告消息 43 | announcement_messages = [] 44 | for i in range(1, 11): # 10个公告消息 45 | key = f'announcement_messages_{i}' 46 | if config.has_option('Messages', key): 47 | message = config.get('Messages', key, raw=True) 48 | announcement_messages.append(message) 49 | else: 50 | break 51 | 52 | # 将小时和分钟转换为秒 53 | restart_interval = (restart_interval_hours * 60 + restart_interval_minutes) * 60 54 | backup_interval = (backup_interval_hours * 60 + backup_interval_minutes) * 60 55 | 56 | return { 57 | 'main_directory': main_directory, 58 | 'arguments': arguments, 59 | 'palinject_enabled': palinject_enabled, 60 | 'use_multicore_options': use_multicore_options, 61 | 'rcon_enabled': rcon_enabled, 62 | 'rcon_host': rcon_host, 63 | 'rcon_port': rcon_port, 64 | 'rcon_password': rcon_password, 65 | 'rcon_command': rcon_command, 66 | 'backup_interval': backup_interval, 67 | 'restart_interval': restart_interval, 68 | 'shutdown_notices': shutdown_notices, 69 | 'shutdown_notices_cn': shutdown_notices_cn, 70 | 'daemon_enabled': daemon_enabled, 71 | 'daemon_time': daemon_time, 72 | 'memory_monitor_enabled': memory_monitor_enabled, 73 | 'polling_interval_seconds': polling_interval_seconds, 74 | 'memory_usage_threshold': memory_usage_threshold, 75 | 'announcement_enabled': announcement_enabled, 76 | 'announcement_time': announcement_time, 77 | 'announcement_messages': announcement_messages, 78 | 'backup_dir': backup_dir, 79 | 'del_old_backup_days': del_old_backup_days, 80 | } 81 | 82 | 83 | if __name__ == '__main__': 84 | config = read_config() 85 | print(json.dumps(config, indent=4)) 86 | 87 | -------------------------------------------------------------------------------- /src/task_scheduler.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- encoding: utf-8 -*- 3 | 4 | 5 | import codecs 6 | import os 7 | import re 8 | import subprocess 9 | import sys 10 | import threading 11 | import time 12 | import zipfile 13 | 14 | import psutil 15 | import urllib.request 16 | import urllib.error 17 | 18 | from src import read_conf 19 | from src.utils.log_control import INFO 20 | from rcon.source import Client 21 | from rcon.source.proto import Packet 22 | from rcon.exceptions import WrongPassword 23 | 24 | 25 | class TaskScheduler: 26 | def __init__(self): 27 | self.conf = read_conf.read_config() 28 | self.appName = 'PalServer-Win64-Test-Cmd.exe' 29 | self.main_dir = self.conf['main_directory'] 30 | self.palworldsetting_dir = os.path.join(self.main_dir, 'Pal\\Saved\\Config\\WindowsServer') 31 | self.program_path = os.path.join(self.main_dir, 'PalServer.exe') 32 | self.palinject_dir = os.path.join(self.main_dir, 'Pal\\Binaries\\Win64') 33 | self.palinject_path = os.path.join(self.palinject_dir, 'PalServerInject.exe') 34 | self.appName_path = os.path.join(self.palinject_dir, 'PalServer-Win64-Test-Cmd.exe') 35 | self.rcon_enabled = self.conf['rcon_enabled'] 36 | self.host = self.conf['rcon_host'] 37 | self.port = self.conf['rcon_port'] 38 | self.passwd = self.conf['rcon_password'] 39 | self.rcon_command = self.conf['rcon_command'] 40 | self.restart_interval = self.conf['restart_interval'] 41 | self.shutdown_notice = self.conf['shutdown_notices'] 42 | self.shutdown_notice_cn = self.conf['shutdown_notices_cn'] 43 | self.memory_monitor_enabled = self.conf['memory_monitor_enabled'] 44 | self.polling_interval_seconds = self.conf['polling_interval_seconds'] 45 | self.memory_usage_threshold = self.conf['memory_usage_threshold'] 46 | self.daemon_time = self.conf['daemon_time'] 47 | self.announcement_enabled = self.conf['announcement_enabled'] 48 | self.announcement_time = self.conf['announcement_time'] 49 | self.announcement_messages = self.conf['announcement_messages'] 50 | self.arguments = self.conf.get('arguments', '').split() 51 | self.palinject_enabled = self.conf['palinject_enabled'] 52 | self.use_multicore_options = self.conf['use_multicore_options'] 53 | self.is_first_run = True 54 | self.is_restarting = False 55 | self.current_announcement_index = 0 56 | self.script_dir = os.path.dirname(sys.argv[0]) 57 | self.zip_file_path = os.path.join(self.script_dir, "UE4SS-PalServerInject.zip") 58 | self.files_to_override = ["pal-plugin-loader.dll", "PalServerInject.exe", "UE4SS.dll", "palinject_version.txt"] 59 | 60 | # 修改rcon源代码,忽略SessionTimeout异常 61 | def patched_run(self, command: str, *args: str, encoding: str = "utf-8") -> str: 62 | """Patched run method that ignores SessionTimeout exceptions.""" 63 | request = Packet.make_command(command, *args, encoding=encoding) 64 | response = self.communicate(request) 65 | 66 | return response.payload.decode(encoding) 67 | 68 | # Apply the monkey patch 69 | Client.run = patched_run 70 | 71 | def send_rcon_command(self, command): 72 | try: 73 | with Client( 74 | host=self.host, 75 | port=self.port, 76 | passwd=self.passwd, 77 | timeout=1) as client: 78 | response = client.run(command) 79 | return True, response 80 | 81 | except TimeoutError: 82 | return False, "连接超时,请检查服务端" 83 | except ConnectionResetError: 84 | return False, "远程主机强迫关闭了一个现有的连接,请重连RCON" 85 | except: 86 | return False, "未知错误" 87 | 88 | def check_rcon(self): 89 | while True: 90 | try: 91 | with Client( 92 | host=self.host, 93 | port=self.port, 94 | passwd=self.passwd, 95 | timeout=1): 96 | INFO.logger.info("[ RCON ] RCON连接正常") 97 | print("\r[ RCON ] RCON连接正常\n", end='', flush=True) 98 | time.sleep(1) 99 | return True 100 | 101 | except TimeoutError: 102 | INFO.logger.error("[ RCON ] 正在检测RCON连接,请不要关闭......") 103 | print("[ RCON ] 正在检测RCON连接,请不要关闭......") 104 | if self.is_first_run: 105 | time.sleep(1) 106 | else: 107 | return False 108 | except WrongPassword: 109 | INFO.logger.error("[ RCON ] RCON密码错误,请检查相关设置") 110 | print("[ RCON ] RCON密码错误,请检查相关设置") 111 | time.sleep(2) 112 | self.close_process() 113 | sys.exit(0) 114 | 115 | # 读取PalWorldSettings.ini中的ServerName 116 | def read_server_name(self): 117 | settings_file_path = os.path.join(self.palworldsetting_dir, 'PalWorldSettings.ini') 118 | server_name = "PalServer" # 默认标题 119 | try: 120 | with open(settings_file_path, 'r', encoding='utf-8') as f: 121 | for line in f: 122 | if 'ServerName=' in line: 123 | # 使用正则表达式匹配 ServerName 后的内容,直到遇到双引号为止 124 | match = re.search(r'ServerName="([^"]*)"', line) 125 | if match: 126 | server_name = match.group(1) # 提取 ServerName 127 | break 128 | INFO.logger.info(f"[ 配置读取 ] 读取 ServerName 成功: {server_name}") 129 | print(f"[ 配置读取 ] 读取 ServerName 成功: {server_name}") 130 | except FileNotFoundError: 131 | INFO.logger.error(f"[ 配置读取 ] 找不到文件 {settings_file_path}") 132 | print(f"[ 配置读取 ] 找不到文件 {settings_file_path}") 133 | except Exception as e: 134 | INFO.logger.error(f"[ 配置读取 ] 读取 ServerName 出错: {e}") 135 | print(f"[ 配置读取 ] 读取 ServerName 出错: {e}") 136 | return server_name 137 | 138 | # 进程检查并判断路径 139 | def is_palserver_running(self): 140 | INFO.logger.info("[ 配置读取 ] 当前配置文件指定路径为" + self.appName_path) 141 | matching_processes = [] 142 | for proc in psutil.process_iter(['name', 'exe']): 143 | try: 144 | if self.appName.lower() in proc.info['name'].lower(): 145 | proc_path = proc.info['exe'] if proc.info['exe'] else '' 146 | INFO.logger.info(f"检查到{proc.info['name']}进程 - 当前路径为:{proc_path}") 147 | matching_processes.append(proc_path) 148 | except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess): 149 | pass 150 | 151 | for path in matching_processes: 152 | if os.path.normpath(self.appName_path) == os.path.normpath(path): 153 | return True 154 | return False 155 | 156 | # 关闭路径匹配成功的进程 157 | def close_process(self): 158 | for proc in psutil.process_iter(['name', 'exe']): 159 | try: 160 | if self.appName.lower() in proc.info['name'].lower(): 161 | if os.path.normpath(self.appName_path) == os.path.normpath(proc.info['exe']): 162 | proc.kill() 163 | print(f"[ 进程关闭 ] 已关闭位于{proc.info['exe']} 的 {self.appName}") 164 | return 165 | except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess): 166 | pass 167 | 168 | # 解压文件 169 | def unzip_files(self): 170 | try: 171 | with zipfile.ZipFile(self.zip_file_path, 'r') as zip_ref: 172 | for member in zip_ref.namelist(): 173 | target_path = os.path.join(self.palinject_dir, member) 174 | # 如果文件在 files_to_override 列表中,则直接覆盖 175 | if any(member.endswith(pf) for pf in self.files_to_override): 176 | zip_ref.extract(member, self.palinject_dir) 177 | else: 178 | if not os.path.exists(target_path): 179 | zip_ref.extract(member, self.palinject_dir) 180 | time.sleep(5) 181 | settings_file_path = os.path.join(self.palinject_dir, "UE4SS-settings.ini") 182 | self.modify_ue4ss_settings(settings_file_path) 183 | INFO.logger.info("[ 解压更新 ] 解压操作完成") 184 | print("[ 解压更新 ] 解压操作完成") 185 | except Exception as e: 186 | INFO.logger.error(f"[ 解压更新 ] 解压操作失败: {str(e)}") 187 | print(f"[ 解压更新 ] 解压操作失败: {str(e)}") 188 | 189 | # 简化解压逻辑 190 | def check_and_extract(self): 191 | palinject_version_file_path = os.path.join(self.palinject_dir, "palinject_version.txt") 192 | # 获取版本号,暂定默认跟随PALWORLD官方更新版本号 193 | with zipfile.ZipFile(self.zip_file_path, 'r') as zip_ref: 194 | with zip_ref.open("palinject_version.txt") as f: 195 | zip_palplugin_version = f.readline().decode('utf-8').strip() 196 | # 检查palinject_version.txt版本号 197 | if os.path.isfile(palinject_version_file_path): 198 | with open(palinject_version_file_path, 'r', encoding='utf-8') as f: 199 | installed_palplugin_version = f.readline().strip() 200 | if installed_palplugin_version >= zip_palplugin_version: 201 | print(f"[ 启动检测 ] 当前插件版本{installed_palplugin_version},无需解压") 202 | return 203 | print(f"[ 启动检测 ] 当前插件版本{zip_palplugin_version},\n准备进行解压") 204 | self.unzip_files() 205 | 206 | # 修改 UE4SS-settings 防止有人默认不是dx11 207 | # 解压文件覆盖虽然一劳永逸 但是会影响已存在的配置 208 | @staticmethod 209 | def modify_ue4ss_settings(settings_file_path): 210 | lines = [] 211 | section_found = False 212 | key_found = False 213 | 214 | with codecs.open(settings_file_path, 'r', encoding='utf-8-sig') as f: 215 | lines = f.readlines() 216 | 217 | for i, line in enumerate(lines): 218 | if '[Debug]' in line: 219 | section_found = True 220 | elif section_found and 'GraphicsAPI' in line: 221 | lines[i] = 'GraphicsAPI = dx11\n' 222 | key_found = True 223 | break 224 | 225 | if section_found and key_found: 226 | with codecs.open(settings_file_path, 'w', encoding='utf-8-sig') as f: 227 | f.writelines(lines) 228 | 229 | INFO.logger.info("[ 配置更新 ] GraphicsAPI 设置为 dx11") 230 | print("[ 配置更新 ] GraphicsAPI 设置为 dx11") 231 | else: 232 | INFO.logger.error("[ 配置错误 ] 无法找到 Debug 或 GraphicsAPI ") 233 | print("[ 配置错误 ] 无法找到 Debug 或 GraphicsAPI ") 234 | 235 | # 定时公告 236 | def send_notice(self): 237 | if not self.announcement_enabled: 238 | return 239 | if not self.announcement_time >= 30: 240 | print("[ 定时公告 ] 时间尽量大于30秒,请重新设置!") 241 | return 242 | 243 | # 初始化request_URL 244 | request_URL = None 245 | 246 | # 获取公告内容 247 | current_announcement = self.announcement_messages[self.current_announcement_index] 248 | 249 | if self.palinject_enabled: 250 | if current_announcement: 251 | base = "http://127.0.0.1:53000/rcon?text=" 252 | command = f"broadcast {current_announcement}" 253 | messageText = urllib.parse.quote(command) 254 | request_URL = base + messageText 255 | 256 | # 发送HTTP请求 257 | try: 258 | if request_URL: 259 | resp = urllib.request.urlopen(request_URL) 260 | if resp.status == 200: 261 | INFO.logger.info('[ 定时公告 ] {0}'.format(resp.read().decode('utf-8'))) 262 | else: 263 | INFO.logger.error('[ 请求错误 ] HTTP状态码: {0}'.format(resp.status)) 264 | print('\r[ 请求错误 ] HTTP状态码:', resp.status) 265 | except urllib.error.URLError as e: 266 | INFO.logger.error('[ 请求错误 ] {0}'.format(e)) 267 | print('\r[ 请求错误 ]', e) 268 | else: 269 | return 270 | 271 | # 更新当前要发送的公告索引 272 | self.current_announcement_index = (self.current_announcement_index + 1) % len(self.announcement_messages) 273 | 274 | # 创建定时器,延时发送下一条公告 275 | timer = threading.Timer(self.announcement_time, self.send_notice) 276 | timer.start() 277 | 278 | # 启动服务端 279 | def start_program(self): 280 | server_name = self.read_server_name() 281 | safe_server_name = server_name.replace('|', '^|').replace(':', '^:') 282 | INFO.logger.info(safe_server_name) 283 | os.system(f'title {safe_server_name}') # 设置标题 284 | INFO.logger.info("[ 启动任务 ] 正在启动程序......") 285 | print("[ 启动任务 ] 正在启动程序......") 286 | 287 | if self.palinject_enabled: 288 | program_args = [self.palinject_path] 289 | else: 290 | program_args = [self.program_path] 291 | 292 | if self.arguments: 293 | INFO.logger.info("[ 启动任务 ] 已配置额外参数") 294 | print("[ 启动任务 ] 已配置额外参数") 295 | program_args.extend(self.arguments) 296 | if self.use_multicore_options: 297 | INFO.logger.info("[ 启动任务 ] 已开启多核选项") 298 | print("[ 启动任务 ] 已开启多核选项") 299 | program_args.extend(["-useperfthreads", "-NoAsyncLoadingThread", "-UseMultithreadForDS"]) 300 | INFO.logger.info(f"[ 启动任务 ] 启动参数:{program_args}") 301 | 302 | try: 303 | program_args_str = ' '.join(f'"{arg}"' for arg in program_args) 304 | cmd = f'cmd.exe /c & start "{server_name}" {program_args_str}' 305 | INFO.logger.info(cmd) 306 | print(cmd) 307 | subprocess.Popen(cmd, creationflags=subprocess.CREATE_NEW_CONSOLE, shell=True) 308 | 309 | except FileNotFoundError: 310 | INFO.logger.error(f"[ 启动任务 ] 启动失败,请检查config.ini中main_directory路径配置") 311 | print(f"\r[ 启动任务 ] 启动失败,请检查config.ini中main_directory路径配置") 312 | time.sleep(3) 313 | exit(1) 314 | 315 | # 尝试连接 316 | if not self.palinject_enabled: 317 | if self.rcon_enabled: 318 | if self.is_first_run: # 只有在首次运行时才检查RCON连接 319 | INFO.logger.info("[ RCON ] 已开启RCON功能") 320 | print("[ RCON ] 已开启RCON功能") 321 | INFO.logger.info("[ RCON ] 正在检查RCON连接,请等待最多60秒......") 322 | print("[ RCON ] 正在检查RCON连接,请等待最多60秒......") 323 | 324 | start_time = time.time() 325 | while time.time() - start_time < 60: 326 | if self.check_rcon(): 327 | break 328 | time.sleep(1) 329 | 330 | else: 331 | INFO.logger.error("[ RCON ] 无法在60秒内建立RCON连接") 332 | print("[ RCON ] 无法在60秒内建立RCON连接") 333 | sys.exit(0) 334 | 335 | self.is_first_run = False 336 | 337 | else: 338 | INFO.logger.info("[ RCON ] 未开启RCON功能") 339 | print("[ RCON ] 未开启RCON功能") 340 | 341 | # 轮询任务(固定延迟执行) 342 | def polling_task(self): 343 | while True: 344 | # 经测试等于1分钟时原版RCON会出现timeout 345 | if self.restart_interval < 60: 346 | INFO.logger.error("[ 轮询任务 ] 服务器重启时间 restart_interval 必须大于1分钟,请重新设置!") 347 | print("[ 轮询任务 ] 服务器重启时间 restart_interval 必须大于1分钟,请重新设置!") 348 | time.sleep(2) 349 | sys.exit(0) 350 | 351 | # 启动程序前检查, 如果存在服务端则不再进行启动操作,改为每次循环结尾关闭进程 352 | is_running = self.is_palserver_running() 353 | INFO.logger.info(f"[ 轮询任务 ] 当前 PalServer 进程运行状态:{is_running}") 354 | if not is_running and (not self.is_restarting): 355 | INFO.logger.info("[ 前置检查 ] 未检测到 PalServer 服务,正在启动......") 356 | print("[ 前置检查 ] 未检测到 PalServer 服务,正在启动......") 357 | self.start_program() 358 | 359 | INFO.logger.info(f'[ 轮询任务 ] 服务器将进入重启倒计时,设置时长为 {self.conf["restart_interval"]} 秒......') 360 | print(f'\r[ 轮询任务 ] 服务器将进入重启倒计时,设置时长为 {self.conf["restart_interval"]} 秒......') 361 | 362 | # 服务器持续运行时间(重启间隔) 363 | for i in range(int(self.restart_interval), 0, -1): 364 | print("\r\033[K", end='') # 清除当前行 365 | print(f'\r[ 轮询任务 ] 服务器将在 {i} 秒后重启......', end='') 366 | time.sleep(1) 367 | # 检查内存使用情况 368 | if self.memory_monitor_enabled: 369 | if self.polling_interval_seconds > 5: 370 | mem_info = psutil.virtual_memory() 371 | mem_usage = mem_info.percent # 获取内存使用百分比 372 | 373 | # 内存超限关服消息提醒 374 | if mem_usage > self.memory_usage_threshold: 375 | self.is_restarting = True # 开始重启 376 | max_notice_time = max(map(int, self.shutdown_notice.keys())) # 获取最大关服通知时间 377 | INFO.logger.error( 378 | f"[ 内存监控 ] 内存使用超过{self.memory_usage_threshold}%,正在重启程序......") 379 | print(f"\r[ 内存监控 ] 内存使用超过{self.memory_usage_threshold}%,正在重启程序......") 380 | time.sleep(1) 381 | # 倒计时关闭服务端 382 | for j in range(max_notice_time, 0, -1): 383 | time.sleep(1) 384 | self.send_shutdown_notice(j) 385 | # 关服之前执行一次SAVE 386 | if self.rcon_enabled: 387 | success, response = self.send_rcon_command("Save") 388 | if success: 389 | INFO.logger.info('[ RCON ] 存档已保存 {0}'.format(response)) 390 | print('\r[ RCON ] 存档已保存', response) 391 | time.sleep(3) 392 | else: 393 | INFO.logger.error('[ RCON ] 存档保存失败: {0}'.format(response)) 394 | self.close_process() 395 | self.is_first_run = True 396 | time.sleep(5) 397 | self.start_program() 398 | self.is_restarting = False # 重启完成 399 | 400 | else: 401 | INFO.logger.error("[ 内存监控 ] 轮询间隔 polling_interval_seconds 必须大于等于5秒,请重新设置!") 402 | print("[ 内存监控 ] 轮询间隔 polling_interval_seconds 必须大于5秒,请重新设置!") 403 | time.sleep(2) 404 | sys.exit(0) 405 | 406 | # 还剩 x 秒的时候发送rcon关服消息提醒 407 | self.send_shutdown_notice(i) 408 | 409 | # 关服之前执行一次SAVE 410 | if self.rcon_enabled: 411 | success, response = self.send_rcon_command("Save") 412 | if success: 413 | INFO.logger.info('[ RCON ] 存档已保存 {0}'.format(response)) 414 | print('\r[ RCON ] 存档已保存', response) 415 | time.sleep(3) 416 | 417 | # 关闭服务端 418 | INFO.logger.info("[ 轮询任务 ] 正在关闭配置文件指定运行的 PalServer 服务......") 419 | print("\r\033[K", end='') 420 | # 清空屏幕信息 421 | os.system('cls' if os.name == 'nt' else 'clear') 422 | print("[ 轮询任务 ] 正在关闭配置文件指定运行的 PalServer 服务......") 423 | self.close_process() 424 | 425 | # 重启程序 426 | self.start_program() 427 | 428 | def start_daemon(self): 429 | # 守护进程 430 | while True: 431 | try: 432 | # tasklist检测列表会因为进程名过长截断尾部 433 | is_running = self.is_palserver_running() 434 | INFO.logger.info(f"[ 守护进程 ] 当前 PalServer 进程运行状态:{is_running}") 435 | 436 | if not is_running and (not self.is_restarting): 437 | INFO.logger.info("[ 守护进程 ] 监控到 PalServer 已停止,正在重新启动......") 438 | print("\r\033[K", end='') 439 | print('[ 守护进程 ] 监控到 PalServer 已停止,正在重新启动......') 440 | 441 | # 启动程序 442 | self.start_program() 443 | 444 | # 倒计时 445 | for i in range(int(self.daemon_time), 0, -1): 446 | time.sleep(1) 447 | 448 | # 只有异常退出才会触发,手动关闭进程不会触发 449 | except Exception as e: 450 | INFO.logger.error(f"[ 守护进程 ] 程序异常终止,错误信息:{e}\n正在尝试重启程序......") 451 | print("\r\033[K", end='') 452 | print(f"[ 守护进程 ] 程序异常终止,错误信息:{e}\n正在尝试重启程序......") 453 | continue 454 | 455 | def send_shutdown_notice(self, countdown): 456 | """Send a shutdown notice through RCON.""" 457 | 458 | countdown_str = str(countdown) 459 | if self.palinject_enabled: 460 | if countdown_str in self.shutdown_notice_cn: 461 | message = self.shutdown_notice_cn[countdown_str] 462 | # api似乎只支持broadcast,避免改动config.ini 463 | command = f"broadcast {message}" 464 | base = "http://127.0.0.1:53000/rcon?text=" 465 | messageText = urllib.parse.quote(command) 466 | request_URL = base + messageText 467 | 468 | # 发送HTTP请求 469 | try: 470 | resp = urllib.request.urlopen(request_URL) 471 | if resp.status == 200: 472 | INFO.logger.info('[ 重启通知 ] {0}'.format(resp.read().decode('utf-8'))) 473 | else: 474 | INFO.logger.error('[ 请求错误 ] HTTP状态码: {0}'.format(resp.status)) 475 | except urllib.error.URLError as e: 476 | INFO.logger.error('[ 请求错误 ] {0}'.format(e)) 477 | else: 478 | if self.rcon_enabled and self.rcon_command and countdown_str in self.shutdown_notice: 479 | message = self.shutdown_notice[countdown_str] 480 | success, response = self.send_rcon_command(f"{self.rcon_command} {message}") 481 | if success: 482 | INFO.logger.info('[ 指令发送 ] {0}'.format(response)) 483 | print('\r[ 指令发送 ]', response) 484 | else: 485 | INFO.logger.error('[ 指令发送 ] 失败: {0}'.format(response)) 486 | 487 | 488 | def main(): 489 | Task = TaskScheduler() 490 | if Task.conf['palinject_enabled']: 491 | INFO.logger.info("[ 启动检测 ] 检测已开启注入模式") 492 | print("[ 启动检测 ] 检测已开启注入模式") 493 | Task.check_and_extract() 494 | 495 | polling_thread = threading.Thread(target=Task.polling_task) 496 | INFO.logger.info("[ 轮询任务 ] 已启动,每隔{0}秒重启 PalServer 进程......".format(Task.conf['restart_interval'])) 497 | print("[ 轮询任务 ] 已启动,每隔{0}秒重启 PalServer 进程......".format(Task.conf['restart_interval'])) 498 | polling_thread.start() 499 | time.sleep(1) 500 | 501 | # [ 轮询任务 ] 必须在最初启动 防止[ 轮询任务 ] kill掉[ 守护进程 ] 刚启动的服务端 502 | if Task.conf['daemon_enabled']: 503 | print("\r\033[K", end='') 504 | INFO.logger.info("[ 守护进程 ] 守护进程已开启,延迟5秒启动避免双端开启,每隔{0}秒检查一次......".format( 505 | Task.conf['daemon_time'])) 506 | print("[ 守护进程 ] 守护进程已开启,延迟5秒启动避免双端开启,每隔{0}秒检查一次......".format( 507 | Task.conf['daemon_time'])) 508 | time.sleep(5) # 延迟5秒避免双开服务端 509 | daemon_thread = threading.Thread(target=Task.start_daemon) 510 | daemon_thread.start() 511 | 512 | if Task.conf['memory_monitor_enabled']: 513 | INFO.logger.info("[ 内存监控 ] 已开启内存监控,每{0}秒检查一次,将在内存使用超过{1}%时重启程序".format( 514 | Task.conf['polling_interval_seconds'], Task.conf['memory_usage_threshold'])) 515 | print("[ 内存监控 ] 已开启内存监控,每{0}秒检查一次,将在内存使用超过{1}%时重启程序".format( 516 | Task.conf['polling_interval_seconds'], Task.conf['memory_usage_threshold'])) 517 | 518 | if Task.conf['palinject_enabled']: 519 | if Task.conf['announcement_enabled']: 520 | INFO.logger.info("[ 定时公告 ] 已启动,每隔{0}秒发送公告信息......".format(Task.conf['announcement_time'])) 521 | print("\r[ 定时公告 ] 已启动,每隔{0}秒发送公告信息......".format(Task.conf['announcement_time'])) 522 | time.sleep(30) 523 | Task.send_notice() # 公告 524 | else: 525 | INFO.logger.info("[ 定时公告 ] 未启动,announcement_enabled开关为False") 526 | print("\r[ 定时公告 ] 未启动,announcement_enabled开关为False") 527 | else: 528 | INFO.logger.info("[ 定时公告 ] 未启动,palinject_enabled开关为False") 529 | print("[ 定时公告 ] 未启动,palinject_enabled开关为False") 530 | 531 | polling_thread.join() 532 | if Task.conf['daemon_enabled']: 533 | daemon_thread.join() 534 | 535 | 536 | if __name__ == '__main__': 537 | main() 538 | -------------------------------------------------------------------------------- /src/utils/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- encoding: utf-8 -*- 3 | -------------------------------------------------------------------------------- /src/utils/log_control.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """日志封装,可设置不同等级的日志颜色""" 5 | 6 | import logging 7 | from logging import handlers 8 | from typing import Text 9 | import colorlog 10 | import time 11 | from pathlib import Path 12 | import sys 13 | import os 14 | 15 | 16 | class LogHandler: 17 | """ 日志打印封装""" 18 | # 日志级别关系映射 19 | level_relations = { 20 | 'debug': logging.DEBUG, 21 | 'info': logging.INFO, 22 | 'warning': logging.WARNING, 23 | 'error': logging.ERROR, 24 | 'critical': logging.CRITICAL 25 | } 26 | 27 | def __init__( 28 | self, 29 | filename: Text, 30 | level: Text = "info", 31 | when: Text = "D", 32 | ): 33 | self.logger = logging.getLogger(filename) 34 | 35 | formatter = self.log_color() 36 | 37 | # 设置日志格式 38 | format_str = logging.Formatter( 39 | fmt="%(levelname)-8s%(asctime)s %(funcName)s py:%(lineno)d %(message)s", 40 | datefmt="%Y-%m-%d %H:%M:%S" 41 | ) 42 | # 设置日志级别 43 | self.logger.setLevel(self.level_relations.get(level)) 44 | 45 | # 往屏幕上输出 46 | screen_output = logging.StreamHandler() 47 | # 设置屏幕上显示的格式 48 | screen_output.setFormatter(formatter) 49 | 50 | # 往文件里写入#指定间隔时间自动生成文件的处理器 51 | time_rotating = handlers.TimedRotatingFileHandler( 52 | filename=filename, 53 | when=when, 54 | backupCount=3, 55 | encoding='utf-8' 56 | ) 57 | # 设置文件里写入的格式 58 | time_rotating.setFormatter(format_str) 59 | 60 | # 把对象加到logger 61 | # self.logger.addHandler(screen_output) # 如果不需要屏幕到终端,注释掉这行 62 | self.logger.addHandler(time_rotating) 63 | 64 | @classmethod 65 | def log_color(cls): 66 | """ 设置日志颜色 """ 67 | log_colors_config = { 68 | 'DEBUG': 'cyan', 69 | 'INFO': 'green', 70 | 'WARNING': 'yellow', 71 | 'ERROR': 'red', 72 | 'CRITICAL': 'red', 73 | } 74 | 75 | formatter = colorlog.ColoredFormatter( 76 | '%(log_color)s[%(asctime)s] [%(funcName)s] [%(lineno)d] [%(levelname)s]: %(message)s', 77 | datefmt='%Y-%m-%d %H:%M:%S', # 修改日期和时间的格式 78 | log_colors=log_colors_config 79 | ) 80 | return formatter 81 | 82 | 83 | # 获取当前脚本运行的绝对路径 84 | if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'): 85 | # 当脚本被 PyInstaller 打包为可执行文件时 86 | current_directory = os.path.dirname(sys.executable) 87 | else: 88 | # 当脚本以常规方式运行时 89 | current_directory = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 90 | 91 | now_time_day = time.strftime("%Y-%m-%d", time.localtime()) 92 | 93 | logs_dir = Path(os.path.join(current_directory, "logs")) 94 | logs_dir.mkdir(parents=True, exist_ok=True) 95 | 96 | 97 | def add_symbol(record, level, symbol): 98 | """ 在特定级别的日志消息前添加符号 """ 99 | if record.levelname == level: 100 | record.msg = f"{symbol} {record.msg}" 101 | return True 102 | 103 | 104 | INFO = LogHandler(os.path.join(current_directory, f"logs/info-{now_time_day}.log"), level='info') 105 | INFO.logger.addFilter(lambda record: add_symbol(record, "INFO", "✅")) 106 | INFO.logger.addFilter(lambda record: add_symbol(record, "ERROR", "❌")) 107 | INFO.logger.addFilter(lambda record: add_symbol(record, "WARNING", "⚠️")) 108 | 109 | # ERROR = LogHandler(os.path.join(current_directory, f"logs/error-{now_time_day}.log"), level='error') 110 | # ERROR.logger.addFilter(lambda record: LogHandler.add_symbol(record, "❌")) 111 | # WARNING = LogHandler(os.path.join(current_directory, f'logs/warning-{now_time_day}.log'), level='warning') 112 | # WARNING.logger.addFilter(lambda record: LogHandler.add_symbol(record, "⚠️")) 113 | 114 | 115 | if __name__ == '__main__': 116 | print(os.path.join(current_directory, f"logs/info-{now_time_day}.log")) 117 | INFO.logger.info("success") 118 | INFO.logger.error("error") 119 | INFO.logger.warning("warning") 120 | # ERROR.logger.error("error") 121 | # WARNING.logger.warning("warning") 122 | input("Press Enter to exit...\n") 123 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- encoding: utf-8 -*- 3 | 4 | -------------------------------------------------------------------------------- /tests/test_rcon.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- encoding: utf-8 -*- 3 | 4 | from rcon.source.proto import Packet 5 | from rcon.source import Client 6 | from src.utils.log_control import INFO 7 | from rcon.exceptions import WrongPassword, EmptyResponse, UserAbort 8 | 9 | 10 | class TestRcon: 11 | def __init__(self, rcon_host, rcon_port, rcon_passwd): 12 | self.rcon_host = rcon_host 13 | self.rcon_port = rcon_port 14 | self.rcon_passwd = rcon_passwd 15 | 16 | def patched_run(self, command: str, *args: str, encoding: str = "utf-8") -> str: 17 | """Patched run method that ignores SessionTimeout exceptions.""" 18 | request = Packet.make_command(command, *args, encoding=encoding) 19 | response = self.communicate(request) 20 | 21 | return response.payload.decode(encoding) 22 | 23 | Client.run = patched_run 24 | 25 | def send_command(self, command): 26 | try: 27 | with Client(host=self.rcon_host, 28 | port=self.rcon_port, 29 | passwd=self.rcon_passwd, 30 | timeout=1) as client: 31 | response = client.run(command) 32 | return True, response 33 | except WrongPassword: 34 | INFO.logger.warning("[ RCON ] RCON密码错误,请检查相关设置") 35 | return False, "[ RCON ] RCON密码错误,请检查相关设置" 36 | except EmptyResponse: 37 | INFO.logger.warning("[ RCON ] 服务器响应为空") 38 | return False, "[ RCON ] 服务器响应为空" 39 | except UserAbort: 40 | INFO.logger.warning("[RCON] 用户中断") 41 | return False, "[RCON] 用户中断" 42 | except TimeoutError: 43 | INFO.logger.warning("[ RCON ] RCON连接超时") 44 | return False, "[ RCON ] RCON连接超时" 45 | except ConnectionResetError: 46 | INFO.logger.warning("[ RCON ] 连接已被远程主机关闭,请重新连接RCON") 47 | return False, "[ RCON ] 连接已被远程主机关闭,请重新连接RCON" 48 | except ConnectionRefusedError: 49 | INFO.logger.warning("[ RCON ] 连接已被远程主机拒绝") 50 | return False, "[ RCON ] 连接已被远程主机拒绝" 51 | except Exception as e: 52 | INFO.logger.warning(f"[ RCON ] 未知错误: {str(e)}") 53 | return False, f"[ RCON ] 未知错误: {str(e)}" 54 | 55 | 56 | if __name__ == '__main__': 57 | rcon = TestRcon( 58 | rcon_host="127.0.0.1", 59 | rcon_port=25575, 60 | rcon_passwd="qr14" 61 | ) 62 | result, response = rcon.send_command("ShowPlayers") 63 | print(result, response) 64 | --------------------------------------------------------------------------------