nonebot-plugin-mcserver-status-check 0.1.2__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,102 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import io
5
+ from typing import Annotated, Set
6
+
7
+ from nonebot import get_plugin_config, on_command
8
+ from nonebot.adapters.onebot.v11 import Message, MessageSegment
9
+ from nonebot.exception import FinishedException
10
+ from nonebot.params import CommandArg
11
+ from nonebot.plugin import PluginMetadata
12
+ from nonebot.matcher import Matcher
13
+
14
+ from .config import Config
15
+ from .checker import generate_mcmotd_image, generate_single_server_image
16
+
17
+ __plugin_meta__ = PluginMetadata(
18
+ name="Minecraft Server Status",
19
+ description="一款原版风格、美观高效的 Minecraft 服务器状态查询插件",
20
+ usage="\n".join([
21
+ "指令:",
22
+ "查服",
23
+ "查服 [IP/别名]",
24
+ ]),
25
+ type="application",
26
+ homepage="https://github.com/leiuary/nonebot-plugin-mcserver-status-check",
27
+ supported_adapters={"nonebot.adapters.onebot.v11"},
28
+ config=Config,
29
+ )
30
+
31
+ # Load configuration
32
+ plugin_config = get_plugin_config(Config)
33
+
34
+ # Determine command name and aliases
35
+ triggers = plugin_config.msc_command_triggers
36
+ cmd_name = triggers[0] if triggers else "查服"
37
+ cmd_aliases: Set[str | tuple[str, ...]] = set(triggers[1:]) if len(triggers) > 1 else set()
38
+
39
+ # Define command
40
+ mcmotd: Matcher = on_command(cmd_name, aliases=cmd_aliases, priority=5, block=True)
41
+
42
+ @mcmotd.handle()
43
+ async def handle_mcmotd(args: Annotated[Message, CommandArg()]):
44
+ arg_text = args.extract_plain_text().strip()
45
+
46
+ if arg_text:
47
+ # Single server query
48
+ target_address = arg_text
49
+ # Check if it matches an alias
50
+ for server in plugin_config.msc_server_list:
51
+ if server.alias == arg_text:
52
+ target_address = server.address
53
+ break
54
+
55
+ await mcmotd.send(f"正在查询服务器 {target_address},请稍候...")
56
+
57
+ try:
58
+ image = await asyncio.to_thread(generate_single_server_image, target_address, plugin_config)
59
+
60
+ if image:
61
+ img_byte_arr = io.BytesIO()
62
+ image.save(img_byte_arr, format='PNG')
63
+ img_bytes = img_byte_arr.getvalue()
64
+ await mcmotd.finish(MessageSegment.image(img_bytes))
65
+ else:
66
+ await mcmotd.finish("查询失败,未能生成状态图片。")
67
+
68
+ except FinishedException:
69
+ raise
70
+ except Exception as e:
71
+ await mcmotd.finish(f"发生错误: {str(e)}")
72
+
73
+ else:
74
+ # All servers query
75
+ await mcmotd.send("正在查询服务器状态,请稍候...")
76
+
77
+ try:
78
+ image = await asyncio.to_thread(generate_mcmotd_image, plugin_config)
79
+
80
+ if image:
81
+ img_byte_arr = io.BytesIO()
82
+ image.save(img_byte_arr, format='PNG')
83
+ img_bytes = img_byte_arr.getvalue()
84
+ await mcmotd.finish(MessageSegment.image(img_bytes))
85
+ else:
86
+ await mcmotd.finish("查询失败,未能生成状态图片。")
87
+
88
+ except FinishedException:
89
+ raise
90
+ except Exception as e:
91
+ await mcmotd.finish(f"发生错误: {str(e)}")
92
+
93
+ # List command
94
+ cmd_list = on_command("查服列表", priority=5, block=True)
95
+
96
+ @cmd_list.handle()
97
+ async def handle_list():
98
+ msg = "已保存的服务器列表:\n"
99
+ for server in plugin_config.msc_server_list:
100
+ alias_str = f" (别名: {server.alias})" if server.alias else ""
101
+ msg += f"- {server.address}{alias_str}\n"
102
+ await cmd_list.finish(msg.strip())
@@ -0,0 +1,536 @@
1
+ from __future__ import annotations
2
+
3
+ import concurrent.futures
4
+ import json
5
+ import socket
6
+ import struct
7
+ import time
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from typing import Any, Optional
11
+
12
+ from PIL import Image, ImageDraw, ImageFont
13
+ from mcstatus import JavaServer
14
+
15
+ from .config import Config
16
+ from .mc_renderer import calculate_required_width, generate_server_card, parse_inner_legacy
17
+
18
+ # Image Settings
19
+ TITLE_TEXT = "Minecraft Server Status"
20
+ FOOTER_TEXT_TEMPLATE = "{time} | Made by leiuary"
21
+ BG_COLOR = (30, 30, 30)
22
+ TEXT_COLOR = (255, 255, 255)
23
+ FOOTER_COLOR = (150, 150, 150)
24
+ ROW_GAP = 10
25
+
26
+ # --- 辅助函数 ---
27
+
28
+ def get_font_path(config: Config) -> str:
29
+ """解析字体路径,如果未找到则检查本地目录。"""
30
+ path = Path(config.msc_font_path)
31
+ if path.exists():
32
+ return str(path)
33
+
34
+ # 尝试在与此文件相同的目录中查找
35
+ local_path = Path(__file__).parent / config.msc_font_path
36
+ if local_path.exists():
37
+ return str(local_path)
38
+
39
+ return config.msc_font_path
40
+
41
+ def pack_varint(d):
42
+ """将整数打包为 VarInt (Minecraft 协议)。"""
43
+ o = b''
44
+ while True:
45
+ b = d & 0x7F
46
+ d >>= 7
47
+ o += struct.pack("B", b | (0x80 if d > 0 else 0))
48
+ if d == 0: break
49
+ return o
50
+
51
+ def unpack_varint(sock):
52
+ """从 socket 解包 VarInt。"""
53
+ d = 0
54
+ for i in range(5):
55
+ b = sock.recv(1)
56
+ if not b: return 0
57
+ b = ord(b)
58
+ d |= (b & 0x7F) << 7 * i
59
+ if not b & 0x80: return d
60
+ return 0
61
+
62
+ def get_rgb_json(hostname, port):
63
+ """
64
+ 使用原始协议连接到 Minecraft 服务器以检索完整的 JSON 状态。
65
+ 这样做是因为 mcstatus 有时会简化 JSON 结构。
66
+ """
67
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
68
+ sock.settimeout(5)
69
+ try:
70
+ sock.connect((hostname, port))
71
+ protocol = 763 # MC 1.20.4 协议版本 (近似值)
72
+ host_bytes = hostname.encode('utf-8')
73
+
74
+ # 握手包
75
+ data = (b'\x00' + pack_varint(protocol) + pack_varint(len(host_bytes)) +
76
+ host_bytes + struct.pack('!H', port) + pack_varint(1))
77
+ sock.send(pack_varint(len(data)) + data)
78
+
79
+ # 请求包
80
+ sock.send(b'\x01\x00')
81
+ time.sleep(0.1)
82
+ sock.send(b'\x01\x00') # 发送两次以确保在某些情况下能收到响应
83
+
84
+ _ = unpack_varint(sock) # 包长度
85
+ packet_id = unpack_varint(sock)
86
+
87
+ if packet_id != 0:
88
+ raise Exception("Packet ID mismatch")
89
+
90
+ json_len = unpack_varint(sock)
91
+ buffer = b''
92
+ while len(buffer) < json_len:
93
+ chunk = sock.recv(min(4096, json_len - len(buffer)))
94
+ if not chunk: break
95
+ buffer += chunk
96
+
97
+ return json.loads(buffer)
98
+ finally:
99
+ sock.close()
100
+
101
+ class MixedStatus:
102
+ """
103
+ 结合了原始 JSON 数据和 mcstatus 延迟的混合状态对象。
104
+ 模仿渲染器预期的结构。
105
+ """
106
+ @dataclass
107
+ class Players:
108
+ online: int
109
+ max: int
110
+ sample: list[dict[str, Any]]
111
+
112
+ def __init__(self, raw_json: dict[str, Any], mcstatus_latency: float, fail_count: int = 0):
113
+ self.raw = raw_json
114
+ self.latency = mcstatus_latency
115
+ self.fail_count = fail_count
116
+ self.favicon = raw_json.get("favicon")
117
+ p = raw_json.get("players", {})
118
+ self.players = self.Players(
119
+ online=p.get("online", 0),
120
+ max=p.get("max", 0),
121
+ sample=p.get("sample", []),
122
+ )
123
+
124
+ class OfflineStatus:
125
+ """
126
+ 表示服务器连接失败。
127
+ """
128
+ def __init__(self, error_msg: str):
129
+ self.raw = {
130
+ "description": {"text": f"§c无法连接到服务器\n§7{error_msg}"},
131
+ "players": {"online": 0, "max": 0},
132
+ "favicon": None, "version": {"name": "Unknown"}
133
+ }
134
+ self.latency = -1
135
+ self.players = MixedStatus.Players(online=0, max=0, sample=[])
136
+
137
+
138
+ # --- 核心逻辑 ---
139
+
140
+ def query_one_server(index, address, config: Config):
141
+ """
142
+ 查询单个服务器的状态。
143
+ 执行预热 Ping,多次测试 Ping,并计算平均延迟。
144
+ """
145
+ print(f"⏳ [{index+1}] 查询: {address}")
146
+ t_start = time.time()
147
+ detailed_logs = []
148
+
149
+ try:
150
+ # --- 延迟测试 ---
151
+ latencies = []
152
+ last_std_status = None
153
+
154
+ t_warmup_start = time.time()
155
+
156
+ # 1. 预热
157
+ if config.msc_latency_warmup > 0:
158
+ for w_i in range(config.msc_latency_warmup):
159
+ try:
160
+ t0 = time.time()
161
+ temp_server = JavaServer.lookup(address, timeout=5)
162
+ t1 = time.time()
163
+ temp_server.status()
164
+ t2 = time.time()
165
+ detailed_logs.append(f"预热#{w_i+1}: DNS={(t1-t0)*1000:.1f}ms, Query={(t2-t1)*1000:.1f}ms")
166
+ time.sleep(config.msc_latency_interval)
167
+ except Exception:
168
+ detailed_logs.append(f"预热#{w_i+1}: 失败")
169
+ t_warmup_end = time.time()
170
+
171
+ # 2. 实际测试
172
+ target_count = config.msc_latency_count + (2 if config.msc_latency_trim else 0)
173
+ t_test_start = time.time()
174
+
175
+ fail_count = 0
176
+ for i in range(target_count):
177
+ try:
178
+ t0 = time.time()
179
+ # 每次都重新 lookup 以避免连接复用问题
180
+ server = JavaServer.lookup(address, timeout=5)
181
+ t1 = time.time()
182
+ st = server.status()
183
+ t2 = time.time()
184
+
185
+ dns_ms = (t1 - t0) * 1000
186
+ query_ms = (t2 - t1) * 1000
187
+ ping_ms = st.latency
188
+
189
+ latencies.append(ping_ms)
190
+ last_std_status = st
191
+ detailed_logs.append(f"测试#{i+1}: DNS={dns_ms:.1f}ms, Query={query_ms:.1f}ms, Ping={ping_ms:.1f}ms")
192
+ except Exception:
193
+ fail_count += 1
194
+ detailed_logs.append(f"测试#{i+1}: 失败")
195
+
196
+ if i < target_count - 1:
197
+ time.sleep(config.msc_latency_interval)
198
+ t_test_end = time.time()
199
+
200
+ if not latencies:
201
+ raise Exception("无法连接到服务器 (所有尝试均失败)")
202
+
203
+ # 3. 计算统计数据
204
+ is_trimmed = False
205
+ raw_latencies = list(latencies)
206
+
207
+ # 方差始终基于原始数据计算
208
+ raw_avg = sum(latencies) / len(latencies)
209
+ variance = sum((x - raw_avg) ** 2 for x in latencies) / len(latencies)
210
+
211
+ if config.msc_latency_trim and len(latencies) >= 3:
212
+ sorted_latencies = sorted(latencies)
213
+ valid_latencies = sorted_latencies[1:-1]
214
+ avg_latency = sum(valid_latencies) / len(valid_latencies)
215
+ is_trimmed = True
216
+ else:
217
+ avg_latency = raw_avg
218
+
219
+ # 记录结果
220
+ latency_str = ", ".join([f"{l:.2f}" for l in raw_latencies])
221
+ avg_note = " (已去极值)" if is_trimmed else ""
222
+
223
+ t_total = time.time() - t_start
224
+ t_warmup = t_warmup_end - t_warmup_start
225
+ t_test = t_test_end - t_test_start
226
+
227
+ print(f"✅ [{index+1}] {address} -> 延迟: [{latency_str}] -> 平均: {avg_latency:.2f} ms{avg_note}, 方差: {variance:.2f}, 丢包: {fail_count}")
228
+
229
+ if config.msc_show_timing_details:
230
+ print(f" 🕒 耗时详情: 总计 {t_total:.2f}s (预热: {t_warmup:.2f}s, 测试: {t_test:.2f}s)")
231
+ for log in detailed_logs:
232
+ print(f" -> {log}")
233
+
234
+ # 4. 获取完整 JSON 数据
235
+ # 我们需要真实的 host/port (已解析) 来获取原始 JSON
236
+ final_server = JavaServer.lookup(address, timeout=5)
237
+ real_host = final_server.address.host
238
+ real_port = final_server.address.port
239
+
240
+ rgb_json = get_rgb_json(real_host, real_port)
241
+ status_obj = MixedStatus(rgb_json, avg_latency, fail_count)
242
+
243
+ return {
244
+ "index": index,
245
+ "address": address,
246
+ "success": True,
247
+ "json": rgb_json,
248
+ "status_obj": status_obj
249
+ }
250
+
251
+ except Exception as e:
252
+ err_str = str(e)
253
+ if "Expecting value" in err_str: err_str = "服务器返回无效数据"
254
+ elif "timed out" in err_str or "lifetime expired" in err_str: err_str = "连接超时"
255
+ elif "getaddrinfo" in err_str: err_str = "域名解析失败"
256
+
257
+ print(f"⚠️ [{index+1}] {address} 失败: {err_str}")
258
+
259
+ offline_obj = OfflineStatus(err_str)
260
+ return {
261
+ "index": index,
262
+ "address": address,
263
+ "success": False,
264
+ "json": {"error": err_str},
265
+ "status_obj": offline_obj
266
+ }
267
+
268
+ def create_summary_image(combined_rows, config: Config) -> Optional[Image.Image]:
269
+ """
270
+ 将单独的服务器行合并为带有标题和页脚的单个汇总图像。
271
+ """
272
+ if not combined_rows:
273
+ return None
274
+
275
+ # 加载字体
276
+ font_path = get_font_path(config)
277
+ try:
278
+ font_title = ImageFont.truetype(font_path, 48)
279
+ font_footer = ImageFont.truetype(font_path, 24)
280
+ except:
281
+ font_title = ImageFont.load_default()
282
+ font_footer = ImageFont.load_default()
283
+
284
+ # 计算尺寸
285
+ dummy_draw = ImageDraw.Draw(Image.new("RGB", (1, 1)))
286
+ title_w = dummy_draw.textlength(TITLE_TEXT, font=font_title)
287
+ title_h = 100
288
+
289
+ footer_text = FOOTER_TEXT_TEMPLATE.format(time=time.strftime('%Y-%m-%d %H:%M:%S'))
290
+ footer_w = dummy_draw.textlength(footer_text, font=font_footer)
291
+ footer_h = 40
292
+
293
+ content_w = combined_rows[0].width
294
+ total_w = max(content_w, int(title_w + 60), int(footer_w + 60))
295
+
296
+ list_h = sum(img.height for img in combined_rows) + (len(combined_rows)-1) * ROW_GAP
297
+ total_h = title_h + list_h + footer_h
298
+
299
+ # 绘制
300
+ summary_img = Image.new('RGB', (total_w, total_h), BG_COLOR)
301
+ draw = ImageDraw.Draw(summary_img)
302
+
303
+ # 标题
304
+ draw.text(((total_w - title_w) // 2, (title_h - 48) // 2), TITLE_TEXT, fill=TEXT_COLOR, font=font_title)
305
+
306
+ # 服务器行
307
+ curr_y = title_h
308
+ for img in combined_rows:
309
+ x_offset = (total_w - img.width) // 2
310
+ summary_img.paste(img, (x_offset, curr_y))
311
+ curr_y += img.height + ROW_GAP
312
+
313
+ # 页脚
314
+ draw.text((total_w - footer_w - 20, total_h - footer_h + 10), footer_text, fill=FOOTER_COLOR, font=font_footer)
315
+
316
+ return summary_img
317
+
318
+ def generate_mcmotd_image(config: Config) -> Optional[Image.Image]:
319
+ print(f"🚀 开始并行查询 {len(config.msc_server_list)} 个服务器...")
320
+
321
+ font_path = get_font_path(config)
322
+
323
+ # 并行查询
324
+ query_results = []
325
+ with concurrent.futures.ThreadPoolExecutor(max_workers=10) as executor:
326
+ future_map = {executor.submit(query_one_server, i, server.address, config): i for i, server in enumerate(config.msc_server_list)}
327
+ for future in concurrent.futures.as_completed(future_map):
328
+ query_results.append(future.result())
329
+
330
+ query_results.sort(key=lambda x: x["index"])
331
+
332
+ # 1. 计算全局宽度以进行对齐
333
+ max_width = 0
334
+ for res in query_results:
335
+ idx = res["index"]
336
+ alias = config.msc_server_list[idx].alias
337
+ w = calculate_required_width(res["address"], res["status_obj"], font_path=font_path, alias=alias)
338
+ if w > max_width: max_width = w
339
+
340
+ max_width = max(max_width, 800) # 最小宽度
341
+
342
+ # 2. 生成单独的行
343
+ combined_rows = []
344
+ for res in query_results:
345
+ idx = res["index"]
346
+ alias = config.msc_server_list[idx].alias
347
+ icon, info = generate_server_card(res["address"], res["status_obj"], fixed_width=max_width, font_path=font_path, alias=alias)
348
+
349
+ # 合并图标 + 信息
350
+ row_width = icon.width + info.width
351
+ row_height = 128
352
+
353
+ # 检查玩家列表
354
+ player_img = None
355
+ if config.msc_show_player_list and hasattr(res["status_obj"].players, 'sample') and res["status_obj"].players.sample:
356
+ player_img = render_player_list(res["status_obj"].players.sample, row_width, font_path)
357
+
358
+ if player_img:
359
+ row_height += player_img.height
360
+
361
+ row_img = Image.new('RGB', (row_width, row_height), (30, 30, 30))
362
+ row_img.paste(icon, (0, 0))
363
+ row_img.paste(info, (icon.width, 0))
364
+
365
+ if player_img:
366
+ row_img.paste(player_img, (0, 128))
367
+
368
+ combined_rows.append(row_img)
369
+
370
+ # 3. 生成汇总图片
371
+ if combined_rows:
372
+ return create_summary_image(combined_rows, config)
373
+ return None
374
+
375
+ def render_player_list(players, width, font_path):
376
+ if not players: return None
377
+
378
+ try:
379
+ font = ImageFont.truetype(font_path, 20)
380
+ except:
381
+ font = ImageFont.load_default()
382
+
383
+ dummy = ImageDraw.Draw(Image.new("RGB", (1,1)))
384
+
385
+ # 展平名称并处理颜色
386
+ # 某些服务器在玩家列表中使用 '&' 表示颜色
387
+ names = []
388
+ for p in players:
389
+ raw_name = p.get("name", "Unknown")
390
+ # 将 & 替换为 § 以进行颜色解析,但仅当它看起来像颜色代码时
391
+ # 目前进行简单替换
392
+ names.append(raw_name.replace("&", "§"))
393
+
394
+ # 检测这是否可能是消息列表(自定义信息)或真实的玩家列表
395
+ # 标准:包含颜色代码、空格或非 ASCII 字符
396
+ is_message_list = False
397
+ for n in names:
398
+ if "§" in n or " " in n:
399
+ is_message_list = True
400
+ break
401
+ # 检查非 ascii (例如中文)
402
+ if any(ord(c) > 127 for c in n):
403
+ is_message_list = True
404
+ break
405
+
406
+ lines = []
407
+ current_line = []
408
+ current_width = 0
409
+ max_width = width - 40 # 填充
410
+
411
+ if is_message_list:
412
+ # 每行一个条目
413
+ for name in names:
414
+ lines.append([name])
415
+ else:
416
+ # 紧凑模式(逗号分隔)
417
+ for name in names:
418
+ # 去除代码以计算宽度
419
+ clean_name = name
420
+ for i in range(10): clean_name = clean_name.replace(f"§{i}", "")
421
+ for c in "abcdefklmnor": clean_name = clean_name.replace(f"§{c}", "")
422
+
423
+ name_w = dummy.textlength(clean_name + ", ", font=font)
424
+
425
+ if current_width + name_w > max_width and current_line:
426
+ lines.append(current_line)
427
+ current_line = [name]
428
+ current_width = name_w
429
+ else:
430
+ current_line.append(name)
431
+ current_width += name_w
432
+
433
+ if current_line: lines.append(current_line)
434
+
435
+ line_height = 24
436
+ h = len(lines) * line_height + 20
437
+
438
+ img = Image.new("RGB", (width, h), BG_COLOR)
439
+ draw = ImageDraw.Draw(img)
440
+
441
+ y = 10
442
+ default_color = (200, 200, 200)
443
+
444
+ for line in lines:
445
+ x = 20
446
+ for i, name in enumerate(line):
447
+ # 如果不是最后一个且不在消息模式下,则添加分隔符
448
+ separator = ", " if (not is_message_list and i < len(line) - 1) else ""
449
+ full_text = name + separator
450
+
451
+ # 解析并绘制段
452
+ segments = parse_inner_legacy(full_text, default_color)
453
+ for text, color in segments:
454
+ draw.text((x, y), text, fill=color, font=font)
455
+ x += dummy.textlength(text, font=font)
456
+
457
+ y += line_height
458
+
459
+ return img
460
+
461
+ def generate_single_server_image(address: str, config: Config) -> Optional[Image.Image]:
462
+ font_path = get_font_path(config)
463
+
464
+ # 查找别名
465
+ alias = None
466
+ for s in config.msc_server_list:
467
+ if s.address == address:
468
+ alias = s.alias
469
+ break
470
+
471
+ # 查询
472
+ res = query_one_server(0, address, config)
473
+ status = res["status_obj"]
474
+
475
+ # 生成卡片
476
+ w = calculate_required_width(address, status, font_path=font_path, alias=alias)
477
+ w = max(w, 800)
478
+
479
+ icon, info = generate_server_card(address, status, fixed_width=w, font_path=font_path, alias=alias)
480
+
481
+ # 合并图标 + 信息
482
+ row_width = icon.width + info.width
483
+ row_height = 128
484
+ row_img = Image.new('RGB', (row_width, row_height), BG_COLOR)
485
+ row_img.paste(icon, (0, 0))
486
+ row_img.paste(info, (icon.width, 0))
487
+
488
+ # 玩家列表
489
+ player_img = None
490
+ if hasattr(status.players, 'sample') and status.players.sample:
491
+ player_img = render_player_list(status.players.sample, row_width, font_path)
492
+
493
+ # 标题和页脚
494
+ try:
495
+ font_title = ImageFont.truetype(font_path, 48)
496
+ font_footer = ImageFont.truetype(font_path, 24)
497
+ except:
498
+ font_title = ImageFont.load_default()
499
+ font_footer = ImageFont.load_default()
500
+
501
+ dummy = ImageDraw.Draw(Image.new("RGB", (1, 1)))
502
+
503
+ title_w = dummy.textlength(TITLE_TEXT, font=font_title)
504
+ title_h = 100
505
+
506
+ footer_text = FOOTER_TEXT_TEMPLATE.format(time=time.strftime('%Y-%m-%d %H:%M:%S'))
507
+ footer_w = dummy.textlength(footer_text, font=font_footer)
508
+ footer_h = 40
509
+
510
+ # 计算总高度
511
+ total_h = title_h + row_height + (player_img.height if player_img else 0) + footer_h + ROW_GAP
512
+ total_w = max(row_width, int(title_w + 60), int(footer_w + 40))
513
+
514
+ # 创建最终图像
515
+ final_img = Image.new("RGB", (total_w, total_h), BG_COLOR)
516
+ draw = ImageDraw.Draw(final_img)
517
+
518
+ # 绘制标题
519
+ draw.text(((total_w - title_w) // 2, (title_h - 48) // 2), TITLE_TEXT, fill=TEXT_COLOR, font=font_title)
520
+
521
+ # 粘贴行
522
+
523
+ x_offset = (total_w - row_width) // 2
524
+ final_img.paste(row_img, (x_offset, title_h))
525
+
526
+ curr_y = title_h + row_height
527
+
528
+ # Paste Player List
529
+ if player_img:
530
+ final_img.paste(player_img, (x_offset, curr_y))
531
+ curr_y += player_img.height
532
+
533
+ # Draw Footer
534
+ draw.text((total_w - footer_w - 20, total_h - footer_h + 10), footer_text, fill=FOOTER_COLOR, font=font_footer)
535
+
536
+ return final_img
@@ -0,0 +1,17 @@
1
+ from pydantic import BaseModel
2
+ from typing import List, Optional
3
+
4
+ class Server(BaseModel):
5
+ address: str
6
+ alias: Optional[str] = None
7
+
8
+ class Config(BaseModel):
9
+ msc_server_list: List[Server] = []
10
+ msc_latency_interval: float = 0.1
11
+ msc_latency_warmup: int = 5
12
+ msc_latency_count: int = 5
13
+ msc_latency_trim: bool = True
14
+ msc_show_timing_details: bool = False
15
+ msc_font_path: str = "minecraft.ttf"
16
+ msc_command_triggers: List[str] = ["查服"]
17
+ msc_show_player_list: bool = False
@@ -0,0 +1,318 @@
1
+ """
2
+ Minecraft 服务器状态渲染器
3
+ ==========================
4
+
5
+ 此模块负责将 Minecraft 服务器状态数据(JSON)渲染为图像。
6
+ 它处理颜色代码解析、MOTD 格式化、图标处理和布局计算。
7
+
8
+ 主要功能:
9
+ - 解析 Minecraft 颜色代码 (§a, §b 等) 和 JSON 颜色格式。
10
+ - 将 MOTD 渲染为带有正确颜色的文本。
11
+ - 生成包含服务器图标、名称、MOTD、玩家数量、版本和延迟的卡片图像。
12
+ - 支持自动计算宽度以对齐多个服务器卡片。
13
+ """
14
+
15
+ import base64
16
+ from io import BytesIO
17
+ from PIL import Image, ImageDraw, ImageFont
18
+
19
+ # --- 颜色常量 ---
20
+ BG_COLOR = (30, 30, 30)
21
+ MC_COLORS = {
22
+ 'black': (0, 0, 0), 'dark_blue': (0, 0, 170),
23
+ 'dark_green': (0, 170, 0), 'dark_aqua': (0, 170, 170),
24
+ 'dark_red': (170, 0, 0), 'dark_purple': (170, 0, 170),
25
+ 'gold': (255, 170, 0), 'gray': (170, 170, 170),
26
+ 'dark_gray': (85, 85, 85), 'blue': (85, 85, 255),
27
+ 'green': (85, 255, 85), 'aqua': (85, 255, 255),
28
+ 'red': (255, 85, 85), 'light_purple': (255, 85, 255),
29
+ 'yellow': (255, 255, 85), 'white': (255, 255, 255),
30
+ '0': (0, 0, 0), '1': (0, 0, 170), '2': (0, 170, 0), '3': (0, 170, 170),
31
+ '4': (170, 0, 0), '5': (170, 0, 170), '6': (255, 170, 0), '7': (170, 170, 170),
32
+ '8': (85, 85, 85), '9': (85, 85, 255), 'a': (85, 255, 85), 'b': (85, 255, 255),
33
+ 'c': (255, 85, 85), 'd': (255, 85, 255), 'e': (255, 255, 85), 'f': (255, 255, 255),
34
+ }
35
+
36
+
37
+ # --- 辅助函数 ---
38
+
39
+ def resolve_color(color_obj):
40
+ """将颜色名称或十六进制代码解析为 RGB 元组。"""
41
+ if not color_obj: return (170, 170, 170)
42
+ if isinstance(color_obj, str):
43
+ c = color_obj.lower()
44
+ if c in MC_COLORS: return MC_COLORS[c]
45
+ if c.startswith('#') and len(c) == 7:
46
+ try: return tuple(int(c[i:i+2], 16) for i in (1, 3, 5))
47
+ except: pass
48
+ return (170, 170, 170)
49
+
50
+ def parse_inner_legacy(text, start_color):
51
+ """解析字符串中的旧版 Minecraft 颜色代码 (§a)。"""
52
+ if not text: return []
53
+ if '§' not in text: return [(text, start_color)]
54
+ result = []
55
+ buffer = ""
56
+ current_color = start_color
57
+ i = 0
58
+ while i < len(text):
59
+ if text[i] == '§' and i + 1 < len(text):
60
+ if buffer:
61
+ result.append((buffer, current_color))
62
+ buffer = ""
63
+ code = text[i+1].lower()
64
+ if code in MC_COLORS: current_color = MC_COLORS[code]
65
+ elif code == 'r': current_color = (170, 170, 170)
66
+ i += 2
67
+ else:
68
+ buffer += text[i]
69
+ i += 1
70
+ if buffer: result.append((buffer, current_color))
71
+ return result
72
+
73
+ def strip_mc_codes(text):
74
+ """从文本中移除所有 Minecraft 颜色代码 (§a)。"""
75
+ if not text: return ""
76
+ if '§' not in text: return text
77
+ result = ""
78
+ i = 0
79
+ while i < len(text):
80
+ if text[i] == '§' and i + 1 < len(text):
81
+ i += 2
82
+ else:
83
+ result += text[i]
84
+ i += 1
85
+ return result
86
+
87
+ def flatten_json_motd(data, inherited_color='gray'):
88
+ """将分层的 JSON MOTD 结构展平为 (文本, 颜色) 段列表。"""
89
+ result = []
90
+ text = ""
91
+ if isinstance(data, dict):
92
+ text = data.get('text', "")
93
+ color_name = data.get('color', inherited_color)
94
+ elif isinstance(data, str):
95
+ text = data
96
+ color_name = inherited_color
97
+ else:
98
+ return []
99
+ base_rgb = resolve_color(color_name)
100
+ if text: result.extend(parse_inner_legacy(text, base_rgb))
101
+ if isinstance(data, dict) and 'extra' in data:
102
+ for item in data['extra']:
103
+ result.extend(flatten_json_motd(item, color_name))
104
+ return result
105
+
106
+ def draw_text_with_shadow(draw, pos, text, color, font):
107
+ """绘制带有阴影效果的文本。"""
108
+ x, y = pos
109
+ r, g, b = color
110
+ shadow_color = (r // 5, g // 5, b // 5)
111
+ draw.text((x + 2, y + 2), text, fill=shadow_color, font=font)
112
+ draw.text((x, y), text, fill=color, font=font)
113
+ return draw.textlength(text, font=font)
114
+
115
+ def measure_segments_width(segments, draw, font):
116
+ """测量一系列文本段的总宽度。"""
117
+ return sum(draw.textlength(text, font=font) for text, _ in segments)
118
+
119
+
120
+ def extract_render_data(server_addr, status, alias=None):
121
+ """
122
+ 从状态对象中提取并预处理渲染所需的数据。
123
+
124
+ 参数:
125
+ server_addr (str): 服务器地址字符串。
126
+ status (MixedStatus): 包含原始 JSON 响应和延迟数据的状态对象。
127
+ alias (str, optional): 服务器别名。
128
+
129
+ 返回:
130
+ dict: 包含结构化渲染数据的字典,包括左侧文本、右侧统计信息和图标数据。
131
+ """
132
+ raw = status.raw
133
+
134
+ # --- 提取右侧统计数据 (在线人数, 版本, 延迟) ---
135
+ players = raw.get("players", {})
136
+ online, max_p = players.get('online', 0), players.get('max', 0)
137
+ txt_players = f"{online}/{max_p}"
138
+ # 在线且有延迟数据时显示绿色,否则显示灰色
139
+ color_players = (85, 255, 85) if (online > 0 and status.latency >= 0) else (170, 170, 170)
140
+
141
+ txt_version = "Unknown"
142
+ if "version" in raw:
143
+ v = raw["version"]
144
+ if isinstance(v, dict): txt_version = v.get("name", "Unknown")
145
+ elif isinstance(v, str): txt_version = v
146
+ elif hasattr(status, 'version') and status.version:
147
+ txt_version = status.version.name
148
+
149
+ # 移除版本字符串中的颜色代码以保持整洁
150
+ txt_version = strip_mc_codes(txt_version)
151
+
152
+ lat = status.latency if hasattr(status, 'latency') else -1
153
+ fail_count = getattr(status, 'fail_count', 0)
154
+
155
+ txt_ping = f"{int(lat)}ms"
156
+ if fail_count > 0:
157
+ txt_ping = f"Loss:{fail_count} {txt_ping}"
158
+
159
+ if lat < 0: color_ping = (170, 170, 170) # 离线/未知
160
+ elif lat < 150: color_ping = (85, 255, 85) # 良好 (绿色)
161
+ elif lat < 300: color_ping = (255, 170, 0) # 一般 (金色)
162
+ else: color_ping = (170, 0, 0) # 较差 (红色)
163
+
164
+ # --- 提取左侧文本数据 (服务器标题, MOTD) ---
165
+ desc_data = raw.get("description", "")
166
+ motd_parts = flatten_json_motd(desc_data)
167
+ motd_lines = [[]]
168
+ for text, color in motd_parts:
169
+ if not text: continue
170
+ segs = text.split('\n')
171
+ for i, seg in enumerate(segs):
172
+ if i > 0: motd_lines.append([])
173
+ if seg: motd_lines[-1].append((seg, color))
174
+
175
+ # 仅保留前两行 MOTD
176
+ line1 = motd_lines[0] if len(motd_lines) > 0 else []
177
+ line2 = motd_lines[1] if len(motd_lines) > 1 else []
178
+
179
+ title_text = server_addr
180
+ if alias:
181
+ title_text = f"{alias} ({server_addr})"
182
+
183
+ return {
184
+ "right": {
185
+ "p_txt": txt_players, "p_col": color_players,
186
+ "v_txt": txt_version, "v_col": (170, 170, 170),
187
+ "ping_txt": txt_ping, "ping_col": color_ping
188
+ },
189
+ "left": {
190
+ "title": title_text,
191
+ "line1": line1,
192
+ "line2": line2
193
+ },
194
+ "favicon": raw.get("favicon")
195
+ }
196
+
197
+ def calculate_required_width(server_addr, status, font_path="minecraft.ttf", alias=None):
198
+ """
199
+ 计算渲染该服务器卡片所需的最小宽度。
200
+ 此函数用于在批量渲染前确定全局统一的宽度,以确保对齐。
201
+ """
202
+ data = extract_render_data(server_addr, status, alias)
203
+
204
+ # 创建临时画布用于测量文本宽度
205
+ temp_draw = ImageDraw.Draw(Image.new('RGB', (1, 1)))
206
+ try:
207
+ font_title = ImageFont.truetype(font_path, 30)
208
+ font_desc = ImageFont.truetype(font_path, 26)
209
+ font_info = ImageFont.truetype(font_path, 26)
210
+ except:
211
+ font_title = ImageFont.load_default()
212
+ font_desc = ImageFont.load_default()
213
+ font_info = ImageFont.load_default()
214
+
215
+ # 计算右侧统计信息区域宽度 (取最大值)
216
+ r = data["right"]
217
+ w_p = temp_draw.textlength(r["p_txt"], font=font_info)
218
+ w_v = temp_draw.textlength(r["v_txt"], font=font_info)
219
+ w_ping = temp_draw.textlength(r["ping_txt"], font=font_info)
220
+ max_stats_w = max(w_p, w_v, w_ping) + 10
221
+
222
+ # 计算左侧文本区域宽度 (标题和 MOTD)
223
+ l = data["left"]
224
+ w_title = temp_draw.textlength(l["title"], font=font_title)
225
+ w_m1 = measure_segments_width(l["line1"], temp_draw, font_desc)
226
+ w_m2 = measure_segments_width(l["line2"], temp_draw, font_desc)
227
+ max_text_w = max(w_title, w_m1, w_m2)
228
+
229
+ GAP = 40 # 左右内容区域之间的间隙
230
+ PADDING = 40 # 左右两侧的总边距
231
+
232
+ return int(max_text_w + GAP + max_stats_w + PADDING)
233
+
234
+ def generate_server_card(server_addr, status, fixed_width=None, font_path="minecraft.ttf", alias=None):
235
+ """
236
+ 生成单个服务器的状态卡片图像。
237
+
238
+ 参数:
239
+ server_addr (str): 服务器地址。
240
+ status (MixedStatus): 状态对象。
241
+ fixed_width (int, optional): 指定图像宽度。若为 None,则自动计算最小宽度。
242
+ font_path (str, optional): 字体文件路径。
243
+ alias (str, optional): 服务器别名。
244
+
245
+ 返回:
246
+ tuple: (icon_image, info_image) - 返回包含图标和信息卡片的元组。
247
+ """
248
+ # 1. 基础配置与数据提取
249
+ FIXED_HEIGHT = 128
250
+ data = extract_render_data(server_addr, status, alias)
251
+
252
+ # 2. 生成服务器图标
253
+ ICON_SIZE = 128
254
+ icon_img = Image.new('RGB', (ICON_SIZE, ICON_SIZE), (50, 50, 50))
255
+ if data["favicon"] and "," in data["favicon"]:
256
+ try:
257
+ b64 = data["favicon"].split(",", 1)[1]
258
+ real_icon = Image.open(BytesIO(base64.b64decode(b64))).resize((ICON_SIZE, ICON_SIZE), Image.Resampling.NEAREST)
259
+ if real_icon.mode != 'RGB': real_icon = real_icon.convert('RGB')
260
+ icon_img.paste(real_icon, (0, 0))
261
+ except: pass
262
+
263
+ # 3. 确定图像宽度
264
+ # 若未指定固定宽度,则自动计算所需的最小宽度,并确保不小于 800px
265
+ if fixed_width:
266
+ TOTAL_WIDTH = fixed_width
267
+ else:
268
+ TOTAL_WIDTH = calculate_required_width(server_addr, status, font_path, alias)
269
+ TOTAL_WIDTH = max(TOTAL_WIDTH, 800)
270
+
271
+ # 4. 创建信息卡片画布
272
+ info_img = Image.new('RGB', (TOTAL_WIDTH, FIXED_HEIGHT), BG_COLOR)
273
+ draw = ImageDraw.Draw(info_img)
274
+
275
+ try:
276
+ font_title = ImageFont.truetype(font_path, 30)
277
+ font_desc = ImageFont.truetype(font_path, 26)
278
+ font_info = ImageFont.truetype(font_path, 26)
279
+ except:
280
+ font_title = ImageFont.load_default()
281
+ font_desc = ImageFont.load_default()
282
+ font_info = ImageFont.load_default()
283
+
284
+ LINE_HEIGHT = 36
285
+ START_Y = (FIXED_HEIGHT - (LINE_HEIGHT * 3)) // 2
286
+ Y1, Y2, Y3 = START_Y - 2, START_Y + LINE_HEIGHT, START_Y + LINE_HEIGHT * 2
287
+
288
+ PADDING_LEFT = 20
289
+ PADDING_RIGHT = 20
290
+
291
+ # 5. 绘制右侧统计信息 (始终右对齐)
292
+ ALIGN_X = TOTAL_WIDTH - PADDING_RIGHT
293
+ r = data["right"]
294
+
295
+ w_p = draw.textlength(r["p_txt"], font=font_info)
296
+ draw_text_with_shadow(draw, (ALIGN_X - w_p, Y1), r["p_txt"], r["p_col"], font_info)
297
+
298
+ w_v = draw.textlength(r["v_txt"], font=font_info)
299
+ draw_text_with_shadow(draw, (ALIGN_X - w_v, Y2), r["v_txt"], r["v_col"], font_info)
300
+
301
+ w_ping = draw.textlength(r["ping_txt"], font=font_info)
302
+ draw_text_with_shadow(draw, (ALIGN_X - w_ping, Y3), r["ping_txt"], r["ping_col"], font_info)
303
+
304
+ # 6. 绘制左侧文本信息 (标题和 MOTD)
305
+ l = data["left"]
306
+ TEXT_X = PADDING_LEFT
307
+
308
+ draw_text_with_shadow(draw, (TEXT_X, Y1), l["title"], (255, 255, 255), font_title)
309
+
310
+ curr_x = TEXT_X
311
+ for t, c in l["line1"]:
312
+ curr_x += draw_text_with_shadow(draw, (curr_x, Y2), t, c, font_desc)
313
+
314
+ curr_x = TEXT_X
315
+ for t, c in l["line2"]:
316
+ curr_x += draw_text_with_shadow(draw, (curr_x, Y3), t, c, font_desc)
317
+
318
+ return icon_img, info_img
@@ -0,0 +1,178 @@
1
+ Metadata-Version: 2.4
2
+ Name: nonebot-plugin-mcserver-status-check
3
+ Version: 0.1.2
4
+ Summary: A vanilla-style, beautiful and efficient Minecraft server status query plugin
5
+ License: MIT
6
+ License-File: LICENSE
7
+ Author: leiuary
8
+ Author-email: leiuary@qq.com
9
+ Requires-Python: >=3.9,<4.0
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.9
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Programming Language :: Python :: 3.14
18
+ Requires-Dist: Pillow (>=9.1.0)
19
+ Requires-Dist: mcstatus (>=11.0.0)
20
+ Requires-Dist: nonebot-adapter-onebot (>=2.0.0,<3.0.0)
21
+ Requires-Dist: nonebot2 (>=2.0.0,<3.0.0)
22
+ Project-URL: Homepage, https://github.com/leiuary/nonebot-plugin-mcserver-status-check
23
+ Project-URL: Repository, https://github.com/leiuary/nonebot-plugin-mcserver-status-check
24
+ Description-Content-Type: text/markdown
25
+
26
+ <div align="center">
27
+ <a href="https://v2.nonebot.dev/store">
28
+ <img src="https://raw.githubusercontent.com/fllesser/nonebot-plugin-template/refs/heads/resource/.docs/NoneBotPlugin.svg" width="310" alt="logo"></a>
29
+
30
+ ## ✨ nonebot-plugin-mcserver-status-check ✨
31
+ [![LICENSE](https://img.shields.io/github/license/leiuary/nonebot-plugin-mcserver-status-check.svg)](./LICENSE)
32
+ [![pypi](https://img.shields.io/pypi/v/nonebot-plugin-mcserver-status-check.svg)](https://pypi.python.org/pypi/nonebot-plugin-mcserver-status-check)
33
+ [![python](https://img.shields.io/badge/python-3.9+-blue.svg)](https://www.python.org)
34
+
35
+ </div>
36
+
37
+ ## 📖 介绍
38
+
39
+ - 支持同时查询多个服务器。
40
+ - 生成美观的状态卡片图片。
41
+ - 显示服务器图标 (Favicon)。
42
+ - 解析并渲染 Minecraft 颜色代码 (MOTD)。
43
+ - 详细的延迟测试(预热、多次测试取平均值、去极值)。
44
+ - 可配置的字体和显示选项。
45
+
46
+ ## 💿 安装
47
+
48
+ <details open>
49
+ <summary>使用 nb-cli 安装</summary>
50
+ 在 nonebot2 项目的根目录下打开命令行, 输入以下指令即可安装
51
+
52
+ nb plugin install nonebot-plugin-mcserver-status-check --upgrade
53
+ 使用 **pypi** 源安装
54
+
55
+ nb plugin install nonebot-plugin-mcserver-status-check --upgrade -i "https://pypi.org/simple"
56
+ 使用**清华源**安装
57
+
58
+ nb plugin install nonebot-plugin-mcserver-status-check --upgrade -i "https://pypi.tuna.tsinghua.edu.cn/simple"
59
+
60
+
61
+ </details>
62
+
63
+ <details>
64
+ <summary>使用包管理器安装</summary>
65
+ 在 nonebot2 项目的插件目录下, 打开命令行, 根据你使用的包管理器, 输入相应的安装命令
66
+
67
+ <details open>
68
+ <summary>uv</summary>
69
+
70
+ uv add nonebot-plugin-mcserver-status-check
71
+ 安装仓库 master 分支
72
+
73
+ uv add git+https://github.com/leiuary/nonebot-plugin-mcserver-status-check@master
74
+ </details>
75
+
76
+ <details>
77
+ <summary>pdm</summary>
78
+
79
+ pdm add nonebot-plugin-mcserver-status-check
80
+ 安装仓库 master 分支
81
+
82
+ pdm add git+https://github.com/leiuary/nonebot-plugin-mcserver-status-check@master
83
+ </details>
84
+ <details>
85
+ <summary>poetry</summary>
86
+
87
+ poetry add nonebot-plugin-mcserver-status-check
88
+ 安装仓库 master 分支
89
+
90
+ poetry add git+https://github.com/leiuary/nonebot-plugin-mcserver-status-check@master
91
+ </details>
92
+
93
+ 打开 nonebot2 项目根目录下的 `pyproject.toml` 文件, 在 `[tool.nonebot]` 部分追加写入
94
+
95
+ plugins = ["nonebot_plugin_mcserver_status_check"]
96
+
97
+ </details>
98
+
99
+ <details>
100
+ <summary>使用 nbr 安装(使用 uv 管理依赖可用)</summary>
101
+
102
+ [nbr](https://github.com/fllesser/nbr) 是一个基于 uv 的 nb-cli,可以方便地管理 nonebot2
103
+
104
+ nbr plugin install nonebot-plugin-mcserver-status-check
105
+ 使用 **pypi** 源安装
106
+
107
+ nbr plugin install nonebot-plugin-mcserver-status-check -i "https://pypi.org/simple"
108
+ 使用**清华源**安装
109
+
110
+ nbr plugin install nonebot-plugin-mcserver-status-check -i "https://pypi.tuna.tsinghua.edu.cn/simple"
111
+
112
+ </details>
113
+
114
+
115
+ ## ⚙️ 配置
116
+
117
+ 在 nonebot2 项目的`.env`文件中添加下表中的配置
118
+
119
+ ```dotenv
120
+ # 服务器列表
121
+ MSC_SERVER_LIST='[]'
122
+
123
+ # 延迟测试间隔(秒)
124
+ MSC_LATENCY_INTERVAL=0.1
125
+
126
+ # 延迟测试预热次数
127
+ MSC_LATENCY_WARMUP=1
128
+
129
+ # 延迟测试次数
130
+ MSC_LATENCY_COUNT=3
131
+
132
+ # 是否去极值(去掉最大最小)
133
+ MSC_LATENCY_TRIM=true
134
+
135
+ # 是否在控制台显示详细耗时
136
+ MSC_SHOW_TIMING_DETAILS=false
137
+
138
+ # 字体文件
139
+ MSC_FONT_PATH="minecraft.ttf"
140
+
141
+ # 触发指令列表
142
+ MSC_COMMAND_TRIGGERS=["查服"]
143
+
144
+ # 是否在列表模式显示玩家名单
145
+ MSC_SHOW_PLAYER_LIST=false
146
+ ```
147
+
148
+ ### 服务器列表配置示例
149
+
150
+ ```dotenv
151
+ MSC_SERVER_LIST='[
152
+ {"address": "mc.hypixel.net", "alias": "Hypixel"},
153
+ {"address": "play.example.com", "alias": "示例服"}
154
+ ]'
155
+ ```
156
+
157
+ ## 🎉 使用
158
+ ### 指令列表
159
+
160
+ - **查服**
161
+ - 查询所有配置的服务器状态,生成汇总图片。
162
+ - **查服 [IP/别名]**
163
+ - 单独查询指定服务器的状态。
164
+ - **查服列表**
165
+ - 查看当前配置的服务器列表及别名。
166
+
167
+ ### 🎨 效果图
168
+ 如果有效果图的话
169
+
170
+ ## 💡 特性
171
+
172
+ - 🎨 **原版风格**:完美还原 Minecraft 游戏内的服务器列表显示效果。
173
+ - ⚡ **高效并发**:支持多服务器并行查询,速度飞快。
174
+ - 📊 **精准延迟**:内置预热和去极值算法,提供最真实的延迟数据。
175
+ - 📝 **颜色支持**:完整支持 Minecraft 样式代码 (§) 和 JSON 格式 MOTD。
176
+ - 🖼️ **图标显示**:自动获取并显示服务器 Favicon。
177
+ - 👥 **玩家列表**:支持显示在线玩家列表(需服务器开启相关功能)。
178
+
@@ -0,0 +1,9 @@
1
+ nonebot_plugin_mcserver_status_check/__init__.py,sha256=OCZPP48pT6bTZ8ctqJrn3Z_RGgzhO0zHNaLePG9CG-8,3584
2
+ nonebot_plugin_mcserver_status_check/checker.py,sha256=oRyfVxv3AWaCQx3yYrZbQTZcD9mHQ8L9V18UVVjVA3Y,18203
3
+ nonebot_plugin_mcserver_status_check/config.py,sha256=PxjnZlmNZHlh0i1JS7YXhJtYg0mRdpkCf_OZ2vwfWw4,511
4
+ nonebot_plugin_mcserver_status_check/mc_renderer.py,sha256=CIX6rcHXfCnrwpZrqE7Jq2h3BIintbOi-GK58oWcMho,11931
5
+ nonebot_plugin_mcserver_status_check/minecraft.ttf,sha256=Dg7zi9mTTGQQZ2lrImlvJ2OVAEO6pi8b0M2rqtCypyk,16162252
6
+ nonebot_plugin_mcserver_status_check-0.1.2.dist-info/licenses/LICENSE,sha256=CxDbpUkgH8TO7CcjMlxywwLud1mUW9m9lxpH_-aROC0,1085
7
+ nonebot_plugin_mcserver_status_check-0.1.2.dist-info/METADATA,sha256=Tmhro3_9t0yNQMZ7l0YeqHux8x_kWCfqKoO99sSIxHw,5565
8
+ nonebot_plugin_mcserver_status_check-0.1.2.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
9
+ nonebot_plugin_mcserver_status_check-0.1.2.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 2.2.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 leiuary
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.