nonebot-plugin-palworld-sync 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,327 @@
1
+ import shlex
2
+ import nonebot
3
+ from nonebot import on_command, on_message
4
+ from nonebot.adapters.onebot.v11 import Bot, GroupMessageEvent, Message, MessageEvent
5
+ from nonebot.params import CommandArg
6
+ from nonebot.permission import SUPERUSER
7
+ from nonebot.plugin import PluginMetadata
8
+
9
+ from .api import PalWorldAPI, PalWorldAPIError
10
+ from .config import Config
11
+
12
+ __plugin_meta__ = PluginMetadata(
13
+ name="Palworld",
14
+ description="幻兽帕鲁服务器 REST API 插件",
15
+ usage=(
16
+ "帕鲁服务器命令:\n"
17
+ "/pw status - 查看服务器状态\n"
18
+ "/pw players [目标] - 查看在线玩家/详细信息\n"
19
+ "/pw save - 保存世界存盘\n"
20
+ "/pw say <消息> - 广播全局消息\n"
21
+ "/pw shutdown [秒/force] [消息] - 关闭服务器\n"
22
+ "/pw kick <目标> [原因] - 踢出玩家\n"
23
+ "/pw ban <目标> [原因] - 封禁玩家\n"
24
+ "/pw unban <SteamID> - 解封玩家"
25
+ ),
26
+ type="application",
27
+ homepage="https://github.com/yuexps/nonebot-plugin-palworld-sync",
28
+ supported_adapters={"~onebot.v11"},
29
+ )
30
+
31
+ # 加载配置
32
+ try:
33
+ from nonebot import get_plugin_config
34
+ pal_config = get_plugin_config(Config)
35
+ except ImportError:
36
+ pal_config = Config.parse_obj(nonebot.get_driver().config)
37
+
38
+ # 初始化 API 客户端
39
+ api = PalWorldAPI(pal_config.palworld_api_url, pal_config.palworld_api_password)
40
+
41
+ pal_cmd = on_command("pw", priority=5, block=True)
42
+
43
+
44
+ async def check_admin(bot: Bot, event: MessageEvent) -> bool:
45
+ """检查用户是否具有管理员或超级用户权限"""
46
+ if await SUPERUSER(bot, event):
47
+ return True
48
+ if isinstance(event, GroupMessageEvent):
49
+ return event.sender.role in ("owner", "admin")
50
+ return False
51
+
52
+
53
+ def format_uptime(seconds: int) -> str:
54
+ """格式化运行时间为易读的中文字符串"""
55
+ days, remain = divmod(seconds, 86400)
56
+ hours, remain = divmod(remain, 3600)
57
+ minutes, secs = divmod(remain, 60)
58
+
59
+ parts = []
60
+ if days > 0:
61
+ parts.append(f"{days}天")
62
+ if hours > 0:
63
+ parts.append(f"{hours}小时")
64
+ if minutes > 0:
65
+ parts.append(f"{minutes}分钟")
66
+ if secs > 0 or not parts:
67
+ parts.append(f"{secs}秒")
68
+ return "".join(parts)
69
+
70
+
71
+ @pal_cmd.handle()
72
+ async def handle_pal(bot: Bot, event: MessageEvent, command_arg: Message = CommandArg()):
73
+ # 仅在配置的帕鲁群内响应命令
74
+ if not isinstance(event, GroupMessageEvent) or str(event.group_id) not in pal_config.palworld_qq_groups:
75
+ return
76
+
77
+ args_str = command_arg.extract_plain_text().strip()
78
+ if not args_str:
79
+ await pal_cmd.finish(__plugin_meta__.usage)
80
+
81
+ try:
82
+ args = shlex.split(args_str)
83
+ except ValueError as e:
84
+ await pal_cmd.finish(f"参数解析错误:{str(e)}(请检查双引号是否闭合)")
85
+
86
+ action = args[0].lower()
87
+
88
+ valid_actions = {
89
+ "status",
90
+ "players",
91
+ "say",
92
+ "kick",
93
+ "ban",
94
+ "unban",
95
+ "save",
96
+ "shutdown",
97
+ }
98
+ if action not in valid_actions:
99
+ await pal_cmd.finish(f"未知子命令:{action}\n{__plugin_meta__.usage}")
100
+
101
+ # 权限校验
102
+ admin_actions = {
103
+ "say",
104
+ "kick",
105
+ "ban",
106
+ "unban",
107
+ "save",
108
+ "shutdown",
109
+ }
110
+ if action in admin_actions:
111
+ if not await check_admin(bot, event):
112
+ await pal_cmd.finish("权限不足:仅超级用户、群主或管理员有权执行此操作。")
113
+
114
+ try:
115
+ if action == "status":
116
+ import asyncio
117
+ res = await asyncio.gather(
118
+ api.get_info(),
119
+ api.get_metrics(),
120
+ api.get_settings(),
121
+ return_exceptions=True
122
+ )
123
+
124
+ info = res[0] if not isinstance(res[0], Exception) else {}
125
+ metrics = res[1] if not isinstance(res[1], Exception) else {}
126
+ settings = res[2] if not isinstance(res[2], Exception) else {}
127
+
128
+ if isinstance(res[0], Exception) and isinstance(res[1], Exception):
129
+ raise PalWorldAPIError("获取服务器状态失败,请确认 API 服务在线。")
130
+
131
+ version = info.get("version", "Unknown")
132
+ s_name = info.get("servername", "Unknown")
133
+ guid = info.get("worldguid", "N/A")
134
+
135
+ fps = metrics.get("serverfps", "N/A")
136
+ frame_time = metrics.get("serverframetime", 0.0)
137
+ players_num = metrics.get("currentplayernum", 0)
138
+ max_players = metrics.get("maxplayernum", 0)
139
+ uptime = metrics.get("uptime", 0)
140
+ days = metrics.get("days", 0)
141
+
142
+ uptime_str = format_uptime(uptime) if isinstance(uptime, int) else "N/A"
143
+
144
+ difficulty = settings.get("Difficulty", "N/A")
145
+ penalty = settings.get("PlayerDeathPenalty", "N/A")
146
+ pvp = "启用" if settings.get("bIsPvP") is True else "禁用" if settings.get("bIsPvP") is False else "N/A"
147
+
148
+ reply = (
149
+ f"【服务器状态 - {s_name}】\n"
150
+ f"版本:{version}\n"
151
+ f"存档GUID:{guid}\n"
152
+ f"帧率 (FPS):{fps} ({frame_time:.2f} ms)\n"
153
+ f"运行时间:{uptime_str} (第 {days} 天)\n"
154
+ f"在线人数:{players_num} / {max_players}\n"
155
+ f"核心配置:难度 {difficulty} | PvP {pvp} | 死亡惩罚 {penalty}"
156
+ )
157
+ await pal_cmd.finish(reply)
158
+
159
+ elif action == "players":
160
+ players = await api.get_players()
161
+ if not players:
162
+ await pal_cmd.finish("当前没有玩家在线。")
163
+
164
+ # 若提供了目标参数,则进行模糊匹配并返回详细信息
165
+ if len(args) > 1:
166
+ target = args[1]
167
+ matched_player = None
168
+ for p in players:
169
+ p_name = p.get("name", "")
170
+ p_pid = p.get("playerId", "")
171
+ p_uid = p.get("userId", "")
172
+ if target.lower() in (p_name.lower(), p_pid.lower(), p_uid.lower()):
173
+ matched_player = p
174
+ break
175
+
176
+ if not matched_player:
177
+ await pal_cmd.finish(f"在线玩家中未找到匹配「{target}」的人。")
178
+
179
+ name = matched_player.get("name", "Unknown")
180
+ reply = (
181
+ f"【玩家详细信息 - {name}】\n"
182
+ f"等级:{matched_player.get('level', 0)}\n"
183
+ f"玩家ID:{matched_player.get('playerId', 'N/A')}\n"
184
+ f"SteamID:{matched_player.get('userId', 'N/A')}\n"
185
+ f"延迟:{matched_player.get('ping', 0.0):.1f}ms\n"
186
+ f"坐标:({matched_player.get('location_x', 0.0):.1f}, {matched_player.get('location_y', 0.0):.1f})\n"
187
+ f"建筑数:{matched_player.get('building_count', 0)}"
188
+ )
189
+ await pal_cmd.finish(reply)
190
+ else:
191
+ # 默认返回简要的玩家昵称、等级、延迟列表
192
+ lines = [f"【在线玩家 ({len(players)}人)】"]
193
+ for i, p in enumerate(players, 1):
194
+ lines.append(
195
+ f"{i}. {p.get('name', 'Unknown')} "
196
+ f"(等级: {p.get('level', 0)}) "
197
+ f"[延迟: {p.get('ping', 0.0):.1f}ms]"
198
+ )
199
+ await pal_cmd.finish("\n".join(lines))
200
+
201
+ elif action == "say":
202
+ if len(args) < 2:
203
+ await pal_cmd.finish("格式错误:/pw say <消息内容>")
204
+ message = " ".join(args[1:])
205
+ await api.announce(message)
206
+ await pal_cmd.finish(f"已广播公告:\n「{message}」")
207
+
208
+ elif action == "kick":
209
+ if len(args) < 2:
210
+ await pal_cmd.finish("格式错误:/pw kick <玩家名/PlayerID/SteamID> [原因]")
211
+ target = args[1]
212
+ reason = args[2] if len(args) > 2 else "Kicked by Admin"
213
+
214
+ players = await api.get_players()
215
+ userid = None
216
+ target_name = target
217
+ for p in players:
218
+ p_name = p.get("name", "")
219
+ p_pid = p.get("playerId", "")
220
+ p_uid = p.get("userId", "")
221
+ if target.lower() in (p_name.lower(), p_pid.lower(), p_uid.lower()):
222
+ userid = p_uid
223
+ target_name = p_name
224
+ break
225
+
226
+ if not userid:
227
+ if target.startswith("steam_") or target.isdigit():
228
+ userid = target
229
+ else:
230
+ await pal_cmd.finish(f"未在在线列表中找到玩家「{target}」,且其不符合 SteamID 格式。")
231
+
232
+ await api.kick(userid, reason)
233
+ await pal_cmd.finish(f"成功踢出玩家「{target_name}」(SteamID: {userid}),原因: {reason}")
234
+
235
+ elif action == "ban":
236
+ if len(args) < 2:
237
+ await pal_cmd.finish("格式错误:/pw ban <玩家名/PlayerID/SteamID> [原因]")
238
+ target = args[1]
239
+ reason = args[2] if len(args) > 2 else "Banned by Admin"
240
+
241
+ players = await api.get_players()
242
+ userid = None
243
+ target_name = target
244
+ for p in players:
245
+ p_name = p.get("name", "")
246
+ p_pid = p.get("playerId", "")
247
+ p_uid = p.get("userId", "")
248
+ if target.lower() in (p_name.lower(), p_pid.lower(), p_uid.lower()):
249
+ userid = p_uid
250
+ target_name = p_name
251
+ break
252
+
253
+ if not userid:
254
+ if target.startswith("steam_") or target.isdigit():
255
+ userid = target
256
+ else:
257
+ await pal_cmd.finish(f"未在在线列表中找到玩家「{target}」,且其不符合 SteamID 格式。")
258
+
259
+ await api.ban(userid, reason)
260
+ await pal_cmd.finish(f"成功封禁玩家「{target_name}」(SteamID: {userid}),原因: {reason}")
261
+
262
+ elif action == "unban":
263
+ if len(args) < 2:
264
+ await pal_cmd.finish("格式错误:/pw unban <SteamID>")
265
+ userid = args[1]
266
+ if not (userid.startswith("steam_") or userid.isdigit()):
267
+ await pal_cmd.finish("解封错误:解封必须提供有效的 SteamID (如 steam_00000000000000000)")
268
+ await api.unban(userid)
269
+ await pal_cmd.finish(f"成功解封玩家 (SteamID: {userid})。")
270
+
271
+ elif action == "save":
272
+ await api.save()
273
+ await pal_cmd.finish("世界存档保存成功。")
274
+
275
+ elif action == "shutdown":
276
+ waittime = 60
277
+ message = "Server is shutting down for maintenance."
278
+ is_force = False
279
+
280
+ if len(args) > 1:
281
+ first_arg = args[1].lower()
282
+ if first_arg in ("force", "0"):
283
+ is_force = True
284
+ else:
285
+ try:
286
+ waittime = int(first_arg)
287
+ except ValueError:
288
+ message = " ".join(args[1:])
289
+ else:
290
+ if len(args) > 2:
291
+ message = " ".join(args[2:])
292
+
293
+ if is_force:
294
+ await api.stop()
295
+ await pal_cmd.finish("已发送强制关闭指令。")
296
+ else:
297
+ await api.shutdown(waittime, message)
298
+ await pal_cmd.finish(f"已发送优雅关机指令:将在 {waittime} 秒后关闭,公告: 「{message}」")
299
+
300
+ except PalWorldAPIError as e:
301
+ await pal_cmd.finish(f"操作失败:{e}")
302
+
303
+
304
+ # 转发 QQ 群消息至帕鲁服务器
305
+ group_forward = on_message(priority=100, block=False)
306
+
307
+
308
+ @group_forward.handle()
309
+ async def handle_forward(bot: Bot, event: GroupMessageEvent):
310
+ if not pal_config.palworld_forward_group_msg:
311
+ return
312
+
313
+ # 仅在配置的帕鲁群内转发消息
314
+ if not isinstance(event, GroupMessageEvent) or str(event.group_id) not in pal_config.palworld_qq_groups:
315
+ return
316
+
317
+ text = event.get_plaintext().strip()
318
+ if not text or text.startswith(("/", "pw")):
319
+ return
320
+
321
+ sender_name = event.sender.card or event.sender.nickname or str(event.user_id)
322
+ forward_text = f"[QQ] {sender_name}: {text}"
323
+
324
+ try:
325
+ await api.announce(forward_text)
326
+ except Exception:
327
+ pass
@@ -0,0 +1,79 @@
1
+ import httpx
2
+ from typing import Any, Dict, List
3
+
4
+
5
+ class PalWorldAPIError(Exception):
6
+ """API 基础异常类"""
7
+ pass
8
+
9
+
10
+ class PalWorldAPI:
11
+ def __init__(self, base_url: str, password: str):
12
+ self.base_url = base_url.rstrip("/")
13
+ self.auth = httpx.BasicAuth("admin", password)
14
+ self.timeout = 10.0
15
+
16
+ async def _request(self, method: str, path: str, **kwargs) -> httpx.Response:
17
+ """发送 API 请求并进行统一异常封装"""
18
+ try:
19
+ async with httpx.AsyncClient(auth=self.auth, timeout=self.timeout) as client:
20
+ url = f"{self.base_url}{path}"
21
+ response = await client.request(method, url, **kwargs)
22
+ response.raise_for_status()
23
+ return response
24
+ except httpx.HTTPStatusError as e:
25
+ if e.response.status_code == 401:
26
+ raise PalWorldAPIError("认证失败:管理员密码错误")
27
+ raise PalWorldAPIError(f"HTTP 请求失败:状态码 {e.response.status_code}")
28
+ except httpx.RequestError as e:
29
+ raise PalWorldAPIError(f"网络连接错误:无法连接到帕鲁服务器 ({e})")
30
+ except Exception as e:
31
+ raise PalWorldAPIError(f"请求异常:{e}")
32
+
33
+ async def get_info(self) -> Dict[str, Any]:
34
+ """获取服务器信息"""
35
+ res = await self._request("GET", "/v1/api/info")
36
+ return res.json()
37
+
38
+ async def get_players(self) -> List[Dict[str, Any]]:
39
+ """获取在线玩家列表"""
40
+ res = await self._request("GET", "/v1/api/players")
41
+ return res.json().get("players", [])
42
+
43
+ async def get_settings(self) -> Dict[str, Any]:
44
+ """获取服务器设置"""
45
+ res = await self._request("GET", "/v1/api/settings")
46
+ return res.json()
47
+
48
+ async def get_metrics(self) -> Dict[str, Any]:
49
+ """获取服务器指标"""
50
+ res = await self._request("GET", "/v1/api/metrics")
51
+ return res.json()
52
+
53
+ async def announce(self, message: str) -> None:
54
+ """发送公告"""
55
+ await self._request("POST", "/v1/api/announce", json={"message": message})
56
+
57
+ async def kick(self, userid: str, message: str = "") -> None:
58
+ """踢出玩家"""
59
+ await self._request("POST", "/v1/api/kick", json={"userid": userid, "message": message})
60
+
61
+ async def ban(self, userid: str, message: str = "") -> None:
62
+ """封禁玩家"""
63
+ await self._request("POST", "/v1/api/ban", json={"userid": userid, "message": message})
64
+
65
+ async def unban(self, userid: str) -> None:
66
+ """解封玩家"""
67
+ await self._request("POST", "/v1/api/unban", json={"userid": userid})
68
+
69
+ async def save(self) -> None:
70
+ """保存存档"""
71
+ await self._request("POST", "/v1/api/save")
72
+
73
+ async def shutdown(self, waittime: int, message: str = "") -> None:
74
+ """优雅关闭服务器"""
75
+ await self._request("POST", "/v1/api/shutdown", json={"waittime": waittime, "message": message})
76
+
77
+ async def stop(self) -> None:
78
+ """强行关闭服务器"""
79
+ await self._request("POST", "/v1/api/stop")
@@ -0,0 +1,9 @@
1
+ from pydantic import BaseModel
2
+ from typing import List
3
+
4
+
5
+ class Config(BaseModel):
6
+ palworld_api_url: str = "http://127.0.0.1:8212"
7
+ palworld_api_password: str = ""
8
+ palworld_forward_group_msg: bool = False
9
+ palworld_qq_groups: List[str] = []
@@ -0,0 +1,92 @@
1
+ Metadata-Version: 2.4
2
+ Name: nonebot-plugin-palworld-sync
3
+ Version: 0.1.0
4
+ Summary: 基于NoneBot2的幻兽帕鲁 (Palworld) 服务器 REST API 插件
5
+ Keywords: nonebot,nonebot2,palworld,pal
6
+ Author: yuexps@qq.com
7
+ Requires-Python: >=3.9,<4.0
8
+ Classifier: License :: OSI Approved :: MIT License
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3.9
11
+ Classifier: Programming Language :: Python :: 3.10
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Programming Language :: Python :: 3.14
16
+ Requires-Dist: httpx (>=0.20.0,<1.0.0)
17
+ Requires-Dist: nonebot-adapter-onebot (>=2.4.0,<3.0.0)
18
+ Requires-Dist: nonebot2 (>=2.2.0,<3.0.0)
19
+ Project-URL: Homepage, https://github.com/yuexps/nonebot-plugin-palworld-sync
20
+ Project-URL: Repository, https://github.com/yuexps/nonebot-plugin-palworld-sync
21
+ Description-Content-Type: text/markdown
22
+
23
+ <p align="center">
24
+ <a href="https://adapter-onebot.netlify.app/"><img src="https://img.shields.io/badge/nonebot2-plugin-red.svg" alt="nonebot2"></a>
25
+ <a href="https://pypi.org/project/nonebot-plugin-palworld-sync"><img src="https://img.shields.io/pypi/v/nonebot-plugin-palworld-sync.svg" alt="pypi"></a>
26
+ <img src="https://img.shields.io/badge/python-3.9+-blue.svg" alt="python">
27
+ </p>
28
+
29
+ # nonebot-plugin-palworld-sync
30
+
31
+ 基于 Nonebot2 的幻兽帕鲁 (Palworld) 服务器 REST API 插件。
32
+
33
+ ## 介绍
34
+
35
+ 通过幻兽帕鲁官方 REST API 实现服务器状态监控与群内管理。支持群消息和游戏内互通、玩家踢出/封禁/解封、在线人数查询以及关机与存档等操作。
36
+
37
+ ## 安装
38
+
39
+ 推荐使用 `nb-cli` 安装:
40
+ ```bash
41
+ nb plugin install nonebot-plugin-palworld-sync --upgrade
42
+ ```
43
+
44
+ <details>
45
+ <summary>或使用包管理器</summary>
46
+
47
+ ```bash
48
+ pip install nonebot-plugin-palworld-sync --upgrade
49
+ ```
50
+ 并在 `pyproject.toml` 或 `bot.py` 中加载插件:
51
+ ```toml
52
+ plugins = ["nonebot_plugin_palworld_sync"]
53
+ ```
54
+ </details>
55
+
56
+ ## 1. 插件配置
57
+
58
+ ### 幻兽帕鲁服务端配置 (PalWorldSettings.ini)
59
+ ```ini
60
+ RESTAPIEnabled=True,RESTAPIPort=8212,AdminPassword="您的管理员密码"
61
+ ```
62
+
63
+ ### Nonebot 环境变量配置 (.env)
64
+ ```env
65
+ PALWORLD_API_URL="http://<服务器IP>:8212"
66
+ PALWORLD_API_PASSWORD="您的管理员密码"
67
+ PALWORLD_QQ_GROUPS=["123456", "789012"] # 必填:本插件生效的 QQ 群号列表(JSON 数组格式),非列表内的群不响应任何指令
68
+ PALWORLD_FORWARD_GROUP_MSG=True # 可选:设置为 True 即可开启 QQ 群消息互通转发至游戏内,默认 False
69
+ ```
70
+
71
+ ---
72
+
73
+ ## 2. 指令一览
74
+
75
+ ### 公共指令
76
+
77
+ | 指令 | 参数要求 | 功能描述 |
78
+ | :--- | :--- | :--- |
79
+ | /pw status | 无 | 查看服务器状态 (版本、FPS、在线人数、核心设置等) |
80
+ | /pw players [目标] | [目标]: 选填 (名字/PlayerID/SteamID) | 查看在线玩家简表,若指定目标则返回其详细状态 |
81
+
82
+ ### 管理员指令 (限超级用户、群主、群管理员)
83
+
84
+ | 指令 | 参数要求 | 功能描述 |
85
+ | :--- | :--- | :--- |
86
+ | /pw say <msg> | <msg>: 消息内容 | 发送全局广播消息 |
87
+ | /pw kick <target> [reason] | <target>: 名字/PlayerID/SteamID<br>[reason]: 可空 | 踢出指定玩家 (支持在线玩家名字模糊匹配) |
88
+ | /pw ban <target> [reason] | <target>: 名字/PlayerID/SteamID<br>[reason]: 可空 | 封禁指定玩家 (支持在线玩家名字模糊匹配) |
89
+ | /pw unban <steam_id> | <steam_id>: SteamID | 解封指定的玩家 |
90
+ | /pw save | 无 | 保存当前游戏世界的存盘 |
91
+ | /pw shutdown [秒/force] [msg] | [秒/force]: 秒数或 force 强制关闭<br>[msg]: 可空 | 关闭服务器 (优雅关机或强关合并) |
92
+
@@ -0,0 +1,6 @@
1
+ nonebot_plugin_palworld_sync/__init__.py,sha256=UFI6Q3gEoyFb9Qaqvtgr92kqokpv11DzpZbKLag-kQ8,12491
2
+ nonebot_plugin_palworld_sync/api.py,sha256=nocXC78-eMnBa0esK0LALm6CUA41NBu8lHYPjsptTUE,3122
3
+ nonebot_plugin_palworld_sync/config.py,sha256=iHf_5mTThCbklzu7YBQEf8s5LHJfoDkAkaIvY7UK-yU,254
4
+ nonebot_plugin_palworld_sync-0.1.0.dist-info/METADATA,sha256=z5o2vPOdUDjxb5IWX4Obh85G6wPYxi5QORmIfHQtOI4,3707
5
+ nonebot_plugin_palworld_sync-0.1.0.dist-info/WHEEL,sha256=EGEvSphFYqXKs23-kQBeyNoJP1nrT8ZJKQoi5p5DYL8,88
6
+ nonebot_plugin_palworld_sync-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 2.4.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any