koishi-plugin-mc-tools
Version:
我的世界(Minecraft)。可查询 MC 版本、服务器信息、玩家皮肤信息以及四大平台资源;支持管理服务器,功能梭哈
1,186 lines (1,174 loc) • 124 kB
JavaScript
var __create = Object.create;
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __getProtoOf = Object.getPrototypeOf;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
var __export = (target, all) => {
for (var name2 in all)
__defProp(target, name2, { get: all[name2], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
// If the importer is in node compatibility mode or this is not an ESM
// file that has been converted to a CommonJS file using a Babel-
// compatible transform (i.e. "__esModule" has not been set), then set
// "default" to the CommonJS "module.exports" for node compatibility.
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
mod
));
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
// src/index.ts
var src_exports = {};
__export(src_exports, {
Config: () => Config,
apply: () => apply,
inject: () => inject,
name: () => name,
usage: () => usage
});
module.exports = __toCommonJS(src_exports);
var import_koishi8 = require("koishi");
// src/tool/player.ts
var import_koishi = require("koishi");
var import_crypto = require("crypto");
function getOfflineUUID(username) {
const data = `OfflinePlayer:${username}`;
const hash = (0, import_crypto.createHash)("md5").update(data).digest();
hash[6] = hash[6] & 15 | 48;
hash[8] = hash[8] & 63 | 128;
const hex = hash.toString("hex");
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
__name(getOfflineUUID, "getOfflineUUID");
async function fetchPlayerProfile(ctx, username) {
try {
const playerData = await ctx.http.get(`https://api.mojang.com/users/profiles/minecraft/${username}`);
if (!playerData) throw new Error(`不存在玩家 ${username}`);
const profileData = await ctx.http.get(`https://sessionserver.mojang.com/session/minecraft/profile/${playerData.id}`);
const texturesData = profileData.properties?.[0]?.value ? JSON.parse(Buffer.from(profileData.properties[0].value, "base64").toString()) : null;
if (!texturesData?.textures?.SKIN) throw new Error(`无法获取玩家皮肤`);
const profile = {
name: playerData.name,
uuid: playerData.id,
uuidDashed: playerData.id.replace(/(\w{8})(\w{4})(\w{4})(\w{4})(\w{12})/, "$1-$2-$3-$4-$5"),
skin: {
url: texturesData.textures.SKIN.url,
model: texturesData.textures.SKIN.metadata?.model || "classic"
}
};
if (texturesData?.textures?.CAPE) profile.cape = { url: texturesData.textures.CAPE.url };
return profile;
} catch (error) {
ctx.logger.error(`玩家信息获取失败: ${error.message}`, error);
throw error;
}
}
__name(fetchPlayerProfile, "fetchPlayerProfile");
async function renderWithPuppeteer(ctx, html, selector) {
const page = await ctx.puppeteer.page();
await page.setContent(html);
await page.waitForFunction(
(s) => document.querySelector(s) && Array.from(document.querySelectorAll("canvas")).every((c) => c.toDataURL() !== "data:,"),
{ timeout: 5e3 },
selector
);
await new Promise((resolve) => setTimeout(resolve, 100));
const screenshot = await (await page.$(selector)).screenshot({ encoding: "base64", omitBackground: true });
await page.close();
return screenshot;
}
__name(renderWithPuppeteer, "renderWithPuppeteer");
async function renderPlayerSkin(ctx, skinUrl, capeUrl, renderElytra = false, backgroundColor) {
const viewportWidth = renderElytra ? 600 : capeUrl ? 400 : 360;
const skinViewWidth = renderElytra ? 300 : capeUrl ? 200 : 180;
const capeCode = capeUrl ? `
await view.loadCape("${capeUrl}");
${renderElytra ? "view.playerObject.cape.visible = false; view.playerObject.elytra.visible = true;" : "view.playerObject.cape.visible = true; view.playerObject.elytra.visible = false;"}
` : "";
const backgroundStyle = backgroundColor ? `background:${backgroundColor};` : `background:transparent;`;
const html = `<html><head>
<script src="https://unpkg.com/skinview3d@3.1.0/bundles/skinview3d.bundle.js"></script>
<style>body{margin:0;${backgroundStyle}display:flex;justify-content:center;align-items:center}.container{display:flex;width:${viewportWidth}px;height:400px}.view{width:${skinViewWidth}px;height:400px}</style>
</head><body><div class="container">
<canvas id="view1" class="view"></canvas><canvas id="view2" class="view"></canvas></div>
<script>(async()=>{
const createViewer=(id,angle)=>{
const v=new skinview3d.SkinViewer({canvas:document.getElementById(id),width:${skinViewWidth},height:400,preserveDrawingBuffer:true,fov:30,zoom:0.95});
v.renderer.setClearColor(0x000000,0);v.playerObject.rotation.y=angle;v.animation=null;return v;
};
const views=[createViewer('view1',-Math.PI/5),createViewer('view2',Math.PI*4/5)];
for(const view of views){
await view.loadSkin("${skinUrl}");${capeCode}
view.render();
}
})()</script></body></html>`;
return renderWithPuppeteer(ctx, html, ".container");
}
__name(renderPlayerSkin, "renderPlayerSkin");
async function renderPlayerHead(ctx, skinUrl, backgroundColor) {
const backgroundStyle = backgroundColor ? `background:${backgroundColor};` : `background:transparent;`;
const html = `<html><head>
<script src="https://unpkg.com/skinview3d@3.1.0/bundles/skinview3d.bundle.js"></script>
<style>body{margin:0;${backgroundStyle}display:flex;justify-content:center;align-items:center}.container{width:400px;height:400px}</style>
</head><body><div class="container"><canvas id="view" width="400" height="400"></canvas></div>
<script>(async()=>{
const viewer=new skinview3d.SkinViewer({canvas:document.getElementById('view'),width:400,height:400,preserveDrawingBuffer:true,fov:10,zoom:1.0});
viewer.renderer.setClearColor(0x000000,0);
await viewer.loadSkin("${skinUrl}");
viewer.playerObject.rotation.x=0.05;
viewer.playerObject.skin.head.scale.set(3.0,3.0,3.0);
viewer.playerObject.skin.head.position.y=1.0;
viewer.playerObject.scale.set(0.6,0.6,0.6);
viewer.playerObject.position.y=-5;
viewer.animation=null;
viewer.render();
})()</script></body></html>`;
return renderWithPuppeteer(ctx, html, ".container");
}
__name(renderPlayerHead, "renderPlayerHead");
function registerPlayer(ctx, parent) {
const player = parent.subcommand(".player <username>", "查询 Minecraft 玩家信息").action(async ({}, username) => {
if (!username) return "请输入玩家用户名";
const offlineUUID = getOfflineUUID(username);
let profile = null;
try {
profile = await fetchPlayerProfile(ctx, username);
} catch (e) {
}
const message = [import_koishi.h.text(`玩家: ${profile ? profile.name : username}`)];
if (profile) {
const modelType = profile.skin.model === "slim" ? "纤细" : "经典";
message.push(import_koishi.h.text(` [${modelType}] `));
if (profile.cape) message.push(import_koishi.h.text("(披风)"));
}
message.push(import_koishi.h.text(`
Offine UUID: ${offlineUUID}`));
if (profile) message.push(import_koishi.h.text(`
Online UUID: ${profile.uuidDashed}`));
message.push(
import_koishi.h.text('\n使用 "/give @p minecraft:xxx" 获取玩家头颅'),
import_koishi.h.text(`
[1.12-]skull 1 3 {SkullOwner:"${profile ? profile.name : username}"}`),
import_koishi.h.text(`
[1.13+]player_head{SkullOwner:"${profile ? profile.name : username}"}`)
);
return (0, import_koishi.h)("message", message);
});
player.subcommand(".skin <username>", "获取玩家皮肤预览").option("elytra", "-e 显示鞘翅").option("cape", "-c 不显示披风").option("bg", "-b <color:string> 设置背景色(HEX)").action(async ({ options }, username) => {
if (!username) return "请输入玩家用户名";
try {
const profile = await fetchPlayerProfile(ctx, username);
const showCape = Boolean(!options.cape && profile.cape?.url);
const showElytra = Boolean(options.elytra && profile.cape?.url);
const skinImage = await renderPlayerSkin(ctx, profile.skin.url, showCape || showElytra ? profile.cape?.url : void 0, showElytra, options.bg);
return import_koishi.h.image(`data:image/png;base64,${skinImage}`);
} catch (error) {
ctx.logger.error(`获取玩家皮肤预览失败: ${error.message}`, error);
return `获取玩家皮肤失败: ${error.message}`;
}
});
player.subcommand(".head <username>", "获取玩家大头娃娃").option("bg", "-b <color:string> 设置背景色(HEX)").action(async ({ options }, username) => {
if (!username) return "请输入玩家用户名";
try {
const profile = await fetchPlayerProfile(ctx, username);
return import_koishi.h.image(`data:image/png;base64,${await renderPlayerHead(ctx, profile.skin.url, options.bg)}`);
} catch (error) {
ctx.logger.error(`获取玩家大头娃娃失败: ${error.message}`, error);
return `获取玩家皮肤失败: ${error.message}`;
}
});
player.subcommand(".raw <username>", "获取玩家原始皮肤").action(async ({}, username) => {
if (!username) return "请输入玩家用户名";
try {
const profile = await fetchPlayerProfile(ctx, username);
return import_koishi.h.image(profile.skin.url);
} catch (error) {
ctx.logger.error(`获取玩家原始皮肤失败: ${error.message}`, error);
return `获取玩家皮肤失败: ${error.message}`;
}
});
}
__name(registerPlayer, "registerPlayer");
// src/server/info.ts
var import_koishi2 = require("koishi");
var net = __toESM(require("net"));
var dgram = __toESM(require("dgram"));
var FORBIDDEN_PATTERNS = [
/^localhost$/,
/^127\./,
/^0\.0\.0\.0$/,
/^\[::\]/,
/^::$/,
/^\[::1\]/,
/^10\./,
/^(192\.168)\./,
/^(172\.(1[6-9]|2[0-9]|3[0-1]))\./,
/^(169\.254)\./,
/^fe80:/,
/^[fd]/,
/^ff/
];
function validateServerAddress(input) {
const lowerAddr = input.toLowerCase();
if (FORBIDDEN_PATTERNS.some((pattern) => pattern.test(lowerAddr))) return null;
const portPart = lowerAddr.includes(":") ? lowerAddr.substring(lowerAddr.lastIndexOf(":") + 1) : null;
if (portPart) {
const port = parseInt(portPart, 10);
if (isNaN(port) || port < 1 || port > 65535) return null;
}
return input;
}
__name(validateServerAddress, "validateServerAddress");
async function pingServer(host, port, type) {
const startTime = Date.now();
return new Promise((resolve) => {
if (type === "java") {
const socket = new net.Socket();
const onError = /* @__PURE__ */ __name(() => {
socket.destroy();
resolve(-1);
}, "onError");
socket.setTimeout(1e4);
socket.on("connect", () => {
socket.destroy();
resolve(Date.now() - startTime);
});
socket.on("error", onError);
socket.on("timeout", onError);
socket.connect(port, host);
} else {
const client = dgram.createSocket("udp4");
const timer = setTimeout(() => {
client.close();
resolve(-1);
}, 1e4);
const cleanup = /* @__PURE__ */ __name(() => {
clearTimeout(timer);
client.close();
}, "cleanup");
client.on("message", () => {
cleanup();
resolve(Date.now() - startTime);
});
client.on("error", () => {
cleanup();
resolve(-1);
});
const pingData = Buffer.from([1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 255, 0, 254, 254, 254, 254, 253, 253, 253, 253, 18, 52, 86, 120]);
client.send(pingData, port, host, (err) => {
if (err) {
cleanup();
resolve(-1);
}
});
}
});
}
__name(pingServer, "pingServer");
function parseServerAddress(address, defaultPort) {
const ipv6WithPortMatch = address.match(/^\[(.+)\]:(\d+)$/);
if (ipv6WithPortMatch) return { host: ipv6WithPortMatch[1], port: parseInt(ipv6WithPortMatch[2], 10) };
const ipv6Match = address.match(/^\[(.+)\]$/);
if (ipv6Match) return { host: ipv6Match[1], port: defaultPort };
if (address.split(":").length > 2 && !address.endsWith("]")) return { host: address, port: defaultPort };
const lastColonIndex = address.lastIndexOf(":");
if (lastColonIndex > -1) {
const host = address.substring(0, lastColonIndex);
const port = parseInt(address.substring(lastColonIndex + 1), 10);
if (!isNaN(port)) return { host, port };
}
return { host: address, port: defaultPort };
}
__name(parseServerAddress, "parseServerAddress");
async function fetchServerStatus(server, forceType, config) {
const serverType = forceType || "java";
const defaultPort = serverType === "java" ? 25565 : 19132;
const address = validateServerAddress(server);
if (!address) {
const { host: host2, port: port2 } = parseServerAddress(server, defaultPort);
return { online: false, host: host2, port: port2, players: { online: null, max: null }, error: "无效地址" };
}
const { host, port } = parseServerAddress(address, defaultPort);
const apiEndpoints = config?.serverApis?.filter((api) => api.type === serverType)?.map((api) => api.url) || [];
const apiResults = await Promise.allSettled(
apiEndpoints.map((apiUrl) => fetch(apiUrl.replace("${address}", address), { headers: { "User-Agent": "Koishi-MC-Info/1.0" } }).then((res) => res.ok ? res.json() : Promise.reject(`API 请求失败: ${res.status}`)))
);
const successfulData = apiResults.find((r) => r.status === "fulfilled")?.value;
if (successfulData) {
const status = normalizeApiResponse(successfulData, address, serverType);
if (status.online) {
status.ping = await pingServer(status.host, status.port, serverType);
return status;
}
}
return { online: false, host, port, players: { online: null, max: null }, error: "查询失败" };
}
__name(fetchServerStatus, "fetchServerStatus");
function normalizeApiResponse(data, address, serverType) {
const [hostFromAddr, portStr] = address.split(":");
const defaultPort = serverType === "java" ? 25565 : 19132;
const portFromAddr = parseInt(portStr) || defaultPort;
if (data.online === false || ["error", "offline"].includes(data.status?.toLowerCase())) {
return { online: false, host: hostFromAddr, port: portFromAddr, players: { online: null, max: null }, error: data.error || data.description };
}
let finalHost = data.hostname || data.host || data.server || hostFromAddr;
let finalPort = data.port ?? data.ipv6Port;
if (finalPort == null) {
const ipv6Match = finalHost.match(/^\[(.+)\]:(\d+)$/);
const hostPortMatch = finalHost.lastIndexOf(":") > finalHost.indexOf(":") ? null : finalHost.match(/^([^:]+):(\d+)$/);
const match = ipv6Match || hostPortMatch;
if (match) {
finalHost = match[1];
finalPort = parseInt(match[2], 10);
}
}
finalPort = finalPort ?? portFromAddr;
const processListData = /* @__PURE__ */ __name((items) => Array.isArray(items) ? items.map((item) => typeof item === "string" ? { name: item } : item) : void 0, "processListData");
const motdText = (() => {
if (!data.motd) return data.description?.text || data.description;
if (typeof data.motd === "string") return data.motd;
if (typeof data.motd === "object") {
const textArray = data.motd.clean || data.motd.raw;
return Array.isArray(textArray) ? textArray.join("\n") : textArray;
}
return null;
})();
const playerList = data.players?.list || data.players?.sample?.map((p) => p.name) || (Array.isArray(data.players) ? data.players : data.player_list);
return {
online: true,
host: finalHost,
port: finalPort,
ip_address: data.ip_address || data.ip,
eula_blocked: data.eula_blocked,
motd: motdText,
version: {
name_clean: data.version?.name_clean ?? data.version,
name: data.version?.name ?? data.protocol?.name
},
players: {
online: data.players?.online ?? data.players?.now,
max: data.players?.max,
list: playerList?.map((p) => (typeof p === "string" ? p : p.name) || "")
},
icon: data.icon || data.favicon,
srv_record: data.srv_record || data.srv,
mods: processListData(data.mods || data.modinfo?.modList),
software: data.software,
plugins: processListData(data.plugins),
gamemode: data.gamemode,
server_id: data.server_id,
edition: data.edition || (serverType === "bedrock" ? "MCPE" : null)
};
}
__name(normalizeApiResponse, "normalizeApiResponse");
function formatServerStatus(status, config) {
if (!status.online) return status.error;
const formatList = /* @__PURE__ */ __name((list, limit) => {
if (!list?.length) return null;
const limitedList = list.slice(0, limit || list.length);
const text = limitedList.map((item) => item.version ? `${item.name}-${item.version}` : item.name).join(", ");
return limit && limit < list.length ? `${text}...` : text;
}, "formatList");
const getValue = /* @__PURE__ */ __name((name2, limit) => {
switch (name2) {
case "ip":
return status.ip_address;
case "srv":
return status.srv_record ? `${status.srv_record.host}:${status.srv_record.port}` : null;
case "icon":
return status.icon?.startsWith("data:image/png;base64,") ? import_koishi2.h.image(status.icon).toString() : null;
case "motd":
return status.motd;
case "version":
return status.version?.name_clean;
case "online":
return status.players.online?.toString();
case "max":
return status.players.max?.toString();
case "ping":
return status.ping != null && status.ping !== -1 ? `${status.ping}ms` : null;
case "software":
return status.software;
case "edition":
return status.edition === "MCPE" ? "基岩版" : status.edition === "MCEE" ? "教育版" : status.edition;
case "gamemode":
return status.gamemode;
case "eulablock":
return status.eula_blocked ? "是" : null;
case "serverid":
return status.server_id;
case "playercount": {
const count = status.players.list?.length;
return count > 0 ? count.toString() : null;
}
case "plugincount": {
const count = status.plugins?.length;
return count > 0 ? count.toString() : null;
}
case "modcount": {
const count = status.mods?.length;
return count > 0 ? count.toString() : null;
}
case "playerlist":
return formatList(status.players.list?.map((name3) => ({ name: name3 })), limit);
case "pluginlist":
return formatList(status.plugins, limit);
case "modlist":
return formatList(status.mods, limit);
default:
return null;
}
}, "getValue");
return config.serverTemplate.split("\n").map((line) => {
const placeholders = [...line.matchAll(/\{([^{}:]+)(?::(\d+))?\}/g)];
if (placeholders.length > 0 && placeholders.every((p) => !getValue(p[1], p[2] ? parseInt(p[2]) : void 0))) return "";
return line.replace(/\{([^{}:]+)(?::(\d+))?\}/g, (match, name2, limitStr) => getValue(name2, limitStr ? parseInt(limitStr) : void 0) ?? "");
}).filter((line) => line.trim().length > 0).join("\n").replace(/\n{3,}/g, "\n\n").trim();
}
__name(formatServerStatus, "formatServerStatus");
function registerInfo(parent, config) {
const commandAction = /* @__PURE__ */ __name(async (session, server, type) => {
const targetServer = server || (config.serverMaps.find((m) => m.platform === session.platform && m.channelId === session.guildId)?.serverAddress ?? null);
if (!targetServer) return "请提供服务器地址";
const status = await fetchServerStatus(targetServer, type, config);
return formatServerStatus(status, config);
}, "commandAction");
const mcinfo = parent.subcommand(".info [server]", "查询 Java 服务器").usage(`用法: mc.info [地址[:端口]]
查询 Java 版服务器的状态。`).action(async ({ session }, server) => commandAction(session, server, "java"));
mcinfo.subcommand(".be [server]", "查询 Bedrock 服务器").usage(`用法: mc.info.be [地址[:端口]]
查询基岩版服务器的状态。`).action(async ({ session }, server) => commandAction(session, server, "bedrock"));
}
__name(registerInfo, "registerInfo");
// src/utils/fileManager.ts
var fs = __toESM(require("fs/promises"));
var path = __toESM(require("path"));
var FileManager = class {
constructor(ctx) {
this.ctx = ctx;
this.dataDir = path.join(ctx.baseDir, "data");
}
static {
__name(this, "FileManager");
}
dataDir;
/**
* 保存数据到JSON文件
* @template T 数据类型
* @param filename 文件名
* @param data 数据对象
* @returns 保存是否成功
*/
async saveJson(filename, data) {
try {
const filePath = path.join(this.dataDir, filename);
await fs.writeFile(filePath, JSON.stringify(data, null, 2), "utf8");
return true;
} catch (error) {
this.ctx.logger.error(`保存文件失败 (${filename}): ${error.message}`);
return false;
}
}
/**
* 从JSON文件读取数据
* @template T 数据类型
* @param filename 文件名
* @param defaultValue 默认值,当文件不存在或读取失败时返回
* @returns 读取到的数据或默认值
*/
async loadJson(filename, defaultValue) {
try {
const filePath = path.join(this.dataDir, filename);
try {
const data = await fs.readFile(filePath, "utf8");
return JSON.parse(data);
} catch (error) {
if (error.code === "ENOENT") {
await this.saveJson(filename, defaultValue);
return defaultValue;
}
throw error;
}
} catch (error) {
this.ctx.logger.error(`读取文件失败 (${filename}): ${error.message}`);
return defaultValue;
}
}
/**
* 获取白名单绑定数据
* @returns 白名单绑定数据对象,如果不存在则返回空对象
*/
async getWhitelistBindings() {
return await this.loadJson("whitelist.json", {});
}
/**
* 保存白名单绑定数据
* @param bindings 绑定数据
* @returns 保存是否成功
*/
async saveWhitelistBindings(bindings) {
return await this.saveJson("whitelist.json", bindings);
}
};
// src/server/server.ts
var import_rcon_client = require("rcon-client");
async function executeRconCommand(command, serverConfig) {
const [host, portStr] = (serverConfig.rconAddress || "").split(":");
const port = parseInt(portStr || "");
if (!serverConfig.rconPassword || !host || !portStr || isNaN(port)) {
throw new Error(`服务器 #${serverConfig.id} RCON 配置错误`);
}
const rcon = await import_rcon_client.Rcon.connect({ host, port, password: serverConfig.rconPassword });
try {
const result = await rcon.send(command);
return result;
} finally {
await rcon.end();
}
}
__name(executeRconCommand, "executeRconCommand");
function findServer(config, serverId) {
const rconServer = config.rconServers.find((s) => s.id === serverId);
return {
found: !!rconServer,
id: serverId,
displayName: `服务器 #${serverId}`,
rconConfig: rconServer || null
};
}
__name(findServer, "findServer");
function setupServerIdBefore({ session, options }, config) {
if (!session) return "";
if (!options.server) {
const mapping = config.serverMaps.find((m) => m.platform === session.platform && m.channelId === session.guildId);
if (!mapping) {
return "该群组未配置对应服务器";
}
options.server = mapping.serverId;
}
}
__name(setupServerIdBefore, "setupServerIdBefore");
function registerServer(ctx, parent, config) {
const server = parent.subcommand(".server", "管理 Minecraft 服务器").usage("mc.server - 向 Minecraft 服务器内发送消息和执行命令");
server.subcommand(".say <message:text>", "发送聊天消息").usage("mc.server.say <消息内容> - 发送消息到 Minecraft 服务器").option("server", "-s <serverId:number> 指定服务器 ID").before((argv) => setupServerIdBefore(argv, config)).action(async ({ session, options }, message) => {
if (!session) return;
if (!message) return "请输入要发送的消息";
const serverId = options.server;
const serverInfo = findServer(config, serverId);
if (!serverInfo.found) return `未找到服务器 #${serverId}`;
if (!serverInfo.rconConfig) return `服务器 #${serverId} 未配置 RCON`;
const sender = session.username || session.userId;
const command = `say ${sender}: ${message}`;
try {
await executeRconCommand(command, serverInfo.rconConfig);
return `已执行命令 [#${serverId}]`;
} catch (error) {
return `命令执行失败 [#${serverId}] - ${error.message}`;
}
});
server.subcommand(".run <command:text>", "执行命令").usage("mc.server.run <命令内容> - 执行指定 Minecraft 命令").option("server", "-s <serverId:number> 指定服务器 ID").before((argv) => setupServerIdBefore(argv, config)).action(async ({ session, options }, command) => {
if (!session) return;
if (!command) return "请输入要执行的命令";
const serverId = options.server;
const serverInfo = findServer(config, serverId);
if (!serverInfo.found) return `未找到服务器 #${serverId}`;
if (!serverInfo.rconConfig) return `服务器 #${serverId} 未配置 RCON`;
try {
const result = await executeRconCommand(command, serverInfo.rconConfig);
return result ? `已执行命令 [#${serverId}]
${result}` : `已执行命令 [#${serverId}]`;
} catch (error) {
return `命令执行失败 [#${serverId}] - ${error.message}`;
}
});
if (config.bindEnabled) {
const fileManager = new FileManager(ctx);
server.subcommand(".bind [username:string]", "白名单管理").usage("mc.server.bind [用户名] - 绑定或解绑 Minecraft 用户名").option("server", "-s <serverId:number> 指定服务器 ID").option("remove", "-r 解绑指定用户名").action(async ({ session, options }, username) => {
if (!session || !session.userId) return;
const bindings = await fileManager.getWhitelistBindings();
const userId = session.userId;
if (!username) {
const userBindings = bindings[userId];
if (!userBindings || Object.keys(userBindings).length === 0) return "未绑定任何用户名";
const bindingList = Object.entries(userBindings).map(([name2, serverId2]) => `${name2} → 服务器#${serverId2}`).join("\n");
return `已绑定的用户名:
${bindingList}`;
}
const serverId = options.server || config.serverMaps.find((m) => m.platform === session.platform && m.channelId === session.channelId)?.serverId;
if (options.remove) {
if (!bindings[userId]?.[username]) return `未找到绑定用户名 ${username}`;
const boundServerId = bindings[userId][username];
const serverInfo2 = findServer(config, boundServerId);
if (!serverInfo2.found || !serverInfo2.rconConfig) return `服务器 #${boundServerId} 不存在或未配置RCON`;
try {
await executeRconCommand(`whitelist remove ${username}`, serverInfo2.rconConfig);
delete bindings[userId][username];
if (Object.keys(bindings[userId]).length === 0) delete bindings[userId];
await fileManager.saveWhitelistBindings(bindings);
return `已解绑用户名 ${username} [#${boundServerId}]`;
} catch (error) {
ctx.logger.warn(`白名单移除失败: ${error.message} [#${boundServerId}]`);
return `白名单移除失败,未解除绑定: ${error.message} [#${boundServerId}]`;
}
}
if (username.length < 3 || username.length > 16) return "无效的用户名";
if (!serverId) return "该群组未配置对应服务器";
const serverInfo = findServer(config, serverId);
if (!serverInfo.found) return `未找到服务器 #${serverId}`;
if (!serverInfo.rconConfig) return `服务器 #${serverId} 未配置 RCON`;
for (const [uid, userBindings] of Object.entries(bindings)) {
if (uid !== userId && username in userBindings) {
return `用户名 ${username} 已被其他用户绑定到服务器 #${userBindings[username]}`;
}
}
if (bindings[userId]?.[username] === serverId) return `已绑定用户名 ${username} 到服务器 #${serverId}`;
try {
await executeRconCommand(`whitelist add ${username}`, serverInfo.rconConfig);
if (!bindings[userId]) bindings[userId] = {};
bindings[userId][username] = serverId;
await fileManager.saveWhitelistBindings(bindings);
return `已绑定用户名 ${username} 到服务器 #${serverId}`;
} catch (error) {
return `白名单添加失败,未绑定: ${error.message} [#${serverId}]`;
}
});
}
}
__name(registerServer, "registerServer");
// src/tool/ver.ts
async function getLatestVersion() {
const apiUrl = "https://launchermeta.mojang.com/mc/game/version_manifest.json";
const response = await fetch(apiUrl);
if (!response.ok) throw new Error(`API 响应错误: ${response.status}`);
const { latest, versions } = await response.json();
const release = versions.find((v) => v.id === latest.release);
const snapshot = versions.find((v) => v.id === latest.snapshot);
return {
release: { id: release.id, releaseTime: release.releaseTime },
snapshot: { id: snapshot.id, releaseTime: snapshot.releaseTime }
};
}
__name(getLatestVersion, "getLatestVersion");
async function sendUpdateNotification(ctx, targets, versionType, versionInfo) {
const filteredTargets = targets.filter((t) => t.type === "both" || t.type === versionType);
if (!filteredTargets.length) return;
const typeName = versionType === "release" ? "正式版" : "快照版";
const updateMsg = `Minecraft ${typeName}更新:${versionInfo.id}
发布时间: ${new Date(versionInfo.releaseTime).toLocaleString("zh-CN")}`;
const broadcastChannels = filteredTargets.map((t) => `${t.platform}:${t.channelId}`);
await ctx.broadcast(broadcastChannels, updateMsg);
}
__name(sendUpdateNotification, "sendUpdateNotification");
var sharedVersionCache = { release: { id: "", releaseTime: "" }, snapshot: { id: "", releaseTime: "" } };
function registerVer(mc) {
mc.subcommand(".ver", "查询 Minecraft 最新版本").action(async () => {
const formatVersionInfo = /* @__PURE__ */ __name((release, snapshot) => {
const formatDate = /* @__PURE__ */ __name((date) => new Date(date).toLocaleDateString("zh-CN"), "formatDate");
return `Minecraft 最新版本:
正式版: ${release.id}(${formatDate(release.releaseTime)})
快照版: ${snapshot.id}(${formatDate(snapshot.releaseTime)})`;
}, "formatVersionInfo");
try {
const versions = await getLatestVersion();
Object.assign(sharedVersionCache, versions);
return formatVersionInfo(versions.release, versions.snapshot);
} catch (error) {
if (sharedVersionCache.release.id && sharedVersionCache.snapshot.id) return formatVersionInfo(sharedVersionCache.release, sharedVersionCache.snapshot);
return "获取 Minecraft 版本信息失败";
}
});
}
__name(registerVer, "registerVer");
function regVerCheck(ctx, config) {
const trackedVersion = { release: { id: "", releaseTime: "" }, snapshot: { id: "", releaseTime: "" } };
const checkVersions = /* @__PURE__ */ __name(async () => {
try {
const latest = await getLatestVersion();
Object.assign(sharedVersionCache, latest);
const isFirstCheck = !trackedVersion.release.id;
if (!isFirstCheck) {
if (latest.release.id !== trackedVersion.release.id) await sendUpdateNotification(ctx, config.noticeTargets, "release", latest.release);
if (latest.snapshot.id !== trackedVersion.snapshot.id && latest.snapshot.id !== latest.release.id) await sendUpdateNotification(ctx, config.noticeTargets, "snapshot", latest.snapshot);
}
Object.assign(trackedVersion, latest);
} catch (error) {
return;
}
}, "checkVersions");
checkVersions();
ctx.setInterval(checkVersions, config.updInterval * 6e4);
}
__name(regVerCheck, "regVerCheck");
// src/tool/status.ts
var servicesToCheck = {
"Minecraft Net": "https://minecraft.net/",
"Session": "http://session.minecraft.net/",
"Textures": "http://textures.minecraft.net/",
"Mojang API": "https://api.mojang.com/",
"Account": "http://account.mojang.com/",
"Session Server": "https://sessionserver.mojang.com/"
};
function formatStatusMessage(status) {
const statusLines = Object.entries(status).map(([service, isOnline]) => {
const symbol = isOnline ? "[√]" : "[×]";
return `${symbol} ${service}`;
});
return ["Minecraft 服务状态:", ...statusLines].join("\n");
}
__name(formatStatusMessage, "formatStatusMessage");
async function checkServiceStatus(url) {
try {
const response = await fetch(url, { signal: AbortSignal.timeout(1e4), redirect: "follow" });
return response.status < 500;
} catch {
return false;
}
}
__name(checkServiceStatus, "checkServiceStatus");
async function getMinecraftStatus() {
const statusEntries = await Promise.all(
Object.entries(servicesToCheck).map(async ([name2, url]) => {
const isOnline = await checkServiceStatus(url);
return [name2, isOnline];
})
);
return Object.fromEntries(statusEntries);
}
__name(getMinecraftStatus, "getMinecraftStatus");
function registerStatus(mc) {
mc.subcommand(".status", "查询 Minecraft 服务状态").action(async ({}) => {
try {
const currentStatus = await getMinecraftStatus();
return formatStatusMessage(currentStatus);
} catch (error) {
return "获取 Minecraft 服务状态失败";
}
});
}
__name(registerStatus, "registerStatus");
function regStatusCheck(ctx, config) {
const targets = config.statusNoticeTargets;
if (!targets?.length) return;
let lastConfirmedState = true;
let pendingState = null;
let count = 0;
const check = /* @__PURE__ */ __name(async () => {
try {
const current = await getMinecraftStatus();
const onlineCount = Object.values(current).filter((v) => v).length;
const totalCount = Object.keys(servicesToCheck).length;
let currentState = null;
if (onlineCount === totalCount) currentState = true;
else if (onlineCount === 0) currentState = false;
if (currentState === null || currentState === lastConfirmedState) {
count = 0;
pendingState = null;
return;
}
if (currentState === pendingState) {
count++;
} else {
pendingState = currentState;
count = 1;
}
if (count >= 3) {
const msg = currentState ? "Minecraft 服务恢复正常" : "Minecraft 服务全部宕机";
const channels = targets.map((t) => `${t.platform}:${t.channelId}`);
await ctx.broadcast(channels, msg);
lastConfirmedState = currentState;
count = 0;
pendingState = null;
}
} catch (e) {
ctx.logger.warn("检查 Minecraft 服务状态失败:", e);
}
}, "check");
check();
ctx.setInterval(check, config.statusUpdInterval * 6e4);
}
__name(regStatusCheck, "regStatusCheck");
// src/resource/render.ts
var import_koishi3 = require("koishi");
async function takeScreenshot(url, ctx, onImageCallback) {
try {
const browser = await ctx.puppeteer.browser;
const context = await browser.createBrowserContext();
const page = await context.newPage();
try {
await page.setRequestInterception(true);
page.on("request", (request) => {
const resourceType = request.resourceType();
const requestUrl = request.url().toLowerCase();
if (requestUrl.includes("at.alicdn.com") && (requestUrl.endsWith(".js") || requestUrl.includes("font_")) || requestUrl.includes("iconfont") && requestUrl.includes(".svg")) {
request.continue();
} else if (["image", "media", "font", "script"].includes(resourceType) && /\.(gif|analytics|tracking|ad|pixel)|\/ad(s|vert(ising)?)?\/|(pagead2\.googlesyndication|adservice\.google|amazon-adsystem|googletagmanager|scorecardresearch)\.com/.test(requestUrl)) {
request.abort();
} else {
request.continue();
}
});
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 1e4 });
await page.evaluate(() => new Promise((resolve) => {
document.readyState === "complete" ? resolve(true) : window.addEventListener("load", () => resolve(true), { once: true });
setTimeout(resolve, 5e3);
}));
await optimizePage(page, url);
const contentBox = await getContentBox(page);
return await captureScreenshots(page, contentBox, onImageCallback);
} finally {
await context.close();
}
} catch (e) {
ctx.logger.error(`渲染截图失败: ${e.message}`, e);
return null;
}
}
__name(takeScreenshot, "takeScreenshot");
async function getContentBox(page) {
const url = page.url();
return await page.evaluate((currentUrl) => {
const siteMap = {
"mcmod.cn": [".item-row", ".post-row", ".class-text"],
"modrinth.com": [".new-page.sidebar", ".markdown-body", ".project-description"],
"minecraft.wiki": ["#bodyContent", "#content"]
};
const selectors = Object.entries(siteMap).find(([site]) => currentUrl.includes(site))?.[1] || [];
selectors.push("main", ".content");
for (const selector of selectors) {
const element = document.querySelector(selector);
if (element) {
const rect = element.getBoundingClientRect();
return {
x: Math.max(0, rect.left),
y: Math.max(0, rect.top),
width: Math.min(rect.width, window.innerWidth),
height: rect.height
};
}
}
return null;
}, url);
}
__name(getContentBox, "getContentBox");
async function captureScreenshots(page, contentBox, onImageCallback) {
if (!contentBox) return null;
const maxHeight = 4096;
const screenshotOpts = { type: "webp", quality: 80, optimizeForSpeed: true, omitBackground: true };
if (contentBox.height <= maxHeight) {
const image = await page.screenshot({ ...screenshotOpts, clip: contentBox });
if (!image) return null;
const imageElement = import_koishi3.h.image(image, "image/webp");
if (onImageCallback) {
await onImageCallback(imageElement);
return null;
}
return imageElement;
}
const pageCount = Math.ceil(contentBox.height / maxHeight);
const screenshots = await Promise.all(
Array(pageCount).fill(0).map(async (_, i) => {
const startY = contentBox.y + i * maxHeight;
const height = Math.min(maxHeight, contentBox.height - i * maxHeight);
const image = await page.screenshot({
...screenshotOpts,
clip: { x: contentBox.x, y: startY, width: contentBox.width, height }
});
return { image, index: i };
})
);
for (const { image, index } of screenshots.sort((a, b) => a.index - b.index)) {
if (image) {
const imageElement = import_koishi3.h.image(image, "image/webp");
if (onImageCallback) {
await onImageCallback(imageElement);
} else if (index === 0) {
return imageElement;
}
}
}
return null;
}
__name(captureScreenshots, "captureScreenshots");
async function optimizePage(page, url) {
const siteType = url.includes("mcmod.cn") ? "mcmod" : url.includes("minecraft.wiki") ? "minecraft-wiki" : url.includes("modrinth.com") ? "modrinth" : "generic";
await page.evaluate((type) => {
const selectorsToRemove = [
"footer",
"header",
"nav",
".ads-container",
"ins.adsbygoogle",
"iframe",
"script",
...type === "mcmod" ? [".comment-ad", ".class-rating-submit"] : [],
...type === "minecraft-wiki" ? [
".mw-editsection",
".noprint",
".mw-indicators",
"#siteNotice",
"#mw-page-base",
"#mw-head-base",
".wiki-nav",
".page-header",
"#mw-head",
"#mw-navigation",
".mcw-sidebar"
] : [],
...type === "modrinth" ? [
".notification-container",
".vue-notification-group",
".project-description + div",
".joined-buttons",
".donate-button",
".social-buttons",
".btn-group",
".sidebar-left",
".sidebar-right",
".header-wrapper"
] : []
];
selectorsToRemove.forEach((selector) => {
document.querySelectorAll(selector).forEach((el) => el?.remove());
});
if (type === "mcmod") {
document.querySelectorAll(".uknowtoomuch").forEach((el) => {
if (el.parentNode) {
const newElement = document.createElement("span");
newElement.textContent = el.textContent;
el.parentNode.replaceChild(newElement, el);
}
});
} else if (type === "minecraft-wiki") {
document.querySelectorAll(".collapsible").forEach((el) => {
el.classList.remove("collapsed");
el.classList.add("expanded");
});
} else if (type === "modrinth") {
document.querySelectorAll("details").forEach((detail) => detail.setAttribute("open", "true"));
document.querySelectorAll("img").forEach((img) => {
img.loading = "eager";
if (img.dataset.src) {
img.src = img.dataset.src;
delete img.dataset.src;
}
});
}
}, siteType);
}
__name(optimizePage, "optimizePage");
async function renderOutput(session, content, url = null, ctx, config, screenshot = false) {
if (config.useScreenshot && screenshot && url && ctx.puppeteer) {
try {
const screenshotResult = await takeScreenshot(url, ctx, async (image) => {
await session.send(image);
});
return screenshotResult || "";
} catch (error) {
ctx.logger.error("截图失败", error);
}
}
if (config.useForward && session.platform === "onebot") {
try {
const messages = content.map((item) => ({
type: "node",
data: {
name: "MC Tools",
uin: session.selfId,
content: typeof item === "object" && item?.type === "img" ? `[CQ:image,file=${item.attrs?.src || ""}]` : item
}
}));
const isGroup = session.subtype === "group";
const target = isGroup ? session.guildId : session.userId;
const method = isGroup ? "sendGroupForwardMsg" : "sendPrivateForwardMsg";
await session.bot.internal[method](target, messages);
return "";
} catch (error) {
ctx.logger.error("合并转发失败", error);
if (!config.useFallback) return "";
}
}
try {
for (const item of content) await session.send(item);
return "";
} catch (error) {
ctx.logger.error("消息发送失败", error);
}
}
__name(renderOutput, "renderOutput");
// src/resource/modrinth.ts
var import_koishi7 = require("koishi");
// src/resource/curseforge.ts
var import_koishi4 = require("koishi");
// src/resource/download.ts
var CF_API_BASE = "https://api.curseforge.com/v1";
var MR_API_BASE = "https://api.modrinth.com/v2";
async function fetchAPI(ctx, url, options = {}) {
try {
return await ctx.http.get(url, options);
} catch (error) {
ctx.logger.error(`API请求失败: ${url}`, error);
return null;
}
}
__name(fetchAPI, "fetchAPI");
async function getModrinthVersions(ctx, projectId, options = {}) {
const result = await fetchAPI(ctx, `${MR_API_BASE}/project/${projectId}/version`);
if (!result) return [];
return [...result].sort((a, b) => new Date(b.date_published).getTime() - new Date(a.date_published).getTime()).filter((v) => !options.version || v.game_versions?.includes(options.version)).filter((v) => !options.loader || v.loaders?.includes(options.loader));
}
__name(getModrinthVersions, "getModrinthVersions");
async function getCurseForgeFiles(ctx, modId, apiKey, options = {}, index = 0, pageSize = 50) {
const params = { index };
if (options.version) params.gameVersion = options.version;
if (options.loader && options.loader in { forge: 1, fabric: 1, quilt: 1 }) params.modLoaderType = options.loader === "forge" ? 1 : options.loader === "fabric" ? 4 : 5;
const response = await fetchAPI(ctx, `${CF_API_BASE}/mods/${modId}/files`, { headers: { "x-api-key": apiKey }, params });
return { files: response?.data || [], pagination: response?.pagination || { index, pageSize, resultCount: 0, totalCount: 0 } };
}
__name(getCurseForgeFiles, "getCurseForgeFiles");
function formatFileSize(bytes) {
const units = ["B", "KB", "MB", "GB"];
let size = bytes, unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(unitIndex > 0 ? 2 : 0)} ${units[unitIndex]}`;
}
__name(formatFileSize, "formatFileSize");
async function handleUserInput(session, input, allFiles, isLastPage) {
if (!input || input.toLowerCase() === "c") return { action: "cancel" };
if (input.toLowerCase() === "n") return isLastPage ? { action: "cancel" } : { action: "next" };
const choice = parseInt(input);
if (isNaN(choice) || choice < 1 || choice > allFiles.length) {
await session.send(`请回复序号下载文件,输入n查看下页,输入c取消`);
return handleUserInput(session, await session.prompt(6e4), allFiles, isLastPage);
}
return { action: "select", index: choice - 1 };
}
__name(handleUserInput, "handleUserInput");
function formatFileInfo(file, platform, globalIndex) {
const index = globalIndex + 1;
if (platform === "modrinth") {
return `${index}. ${file.name} [${file.game_versions?.join(", ")}] [${file.loaders?.join(", ")}] (${formatFileSize(file.files[0].size)})`;
} else {
const loaders = (file.gameVersions || []).filter((v) => !/^\d+\.\d+(\.\d+)?$/.test(v) && v !== "Client").join(", ");
const versions = (file.gameVersions || []).filter((v) => /^\d+\.\d+(\.\d+)?$/.test(v)).join(", ");
return `${index}. ${file.displayName || file.fileName} [${versions}] [${loaders}] (${formatFileSize(file.fileLength)})`;
}
}
__name(formatFileInfo, "formatFileInfo");
async function displayFileList(session, files, pageInfo, platform, ctx, config, startIndex) {
const messages = [
"请回复序号下载文件,输入n查看下页,输入c取消",
...files.map((file, i) => formatFileInfo(file, platform, startIndex + i)),
pageInfo
];
await renderOutput(session, messages, null, ctx, config, false);
}
__name(displayFileList, "displayFileList");
async function handleDownload(ctx, session, platform, project, config, options = {}) {
try {
let allFiles = [], currentIndex = 0, currentPage = 0, totalItems = 0;
let hasMoreResults = true;
let cfPagination = null;
if (platform === "modrinth") {
allFiles = await getModrinthVersions(ctx, project.project_id, options);
totalItems = allFiles.length;
hasMoreResults = false;
if (!allFiles?.length) return "该项目未找到任何版本";
}
while (true) {
if (platform === "curseforge" && currentPage * 10 >= allFiles.length && hasMoreResults) {
const result2 = await getCurseForgeFiles(ctx, project.id, config.curseforgeEnabled, options, currentIndex);
if (!result2.files?.length) {
if (allFiles.length === 0) return "该项目未找到任何文件";
hasMoreResults = false;
} else {
allFiles = [...allFiles, ...result2.files];
currentIndex += result2.pagination.pageSize;
cfPagination = result2.pagination;
totalItems = cfPagination.totalCount;
hasMoreResults = allFiles.length < cfPagination.totalCount;
}
}
const startIndex = currentPage * 10;
if (startIndex >= allFiles.length) return "已取消下载";
const endIndex = Math.min(startIndex + 10, allFiles.length);
const pageFiles = allFiles.slice(startIndex, endIndex);
const totalPages = Math.ceil(totalItems / 10);
const isLastPage = !hasMoreResults && endIndex >= allFiles.length;
const pageInfo = `第 ${currentPage + 1}/${totalPages || "?"} 页${isLastPage ? "(最后一页)" : ""}`;
await displayFileList(session, pageFiles, pageInfo, platform, ctx, config, startIndex);
const input = await session.prompt(6e4);
const result = await handleUserInput(session, input, allFiles, isLastPage);
if (result.action === "cancel") {
return "已取消下载";
} else if (result.action === "next") {
if (!isLastPage) currentPage++;
else return "已取消下载";
} else if (result.action === "select" && result.index !== void 0) {
const selectedFile = allFiles[result.index];
if (platform === "modrinth") {
if (selectedFile.files.length > 1) {
const fileMessages = [
"请选择要下载的文件:",
...selectedFile.files.map((f, i) => `${i + 1}. ${f.filename} (${formatFileSize(f.size)}) [${f.primary ? "主要" : "次要"}]`)
];
await renderOutput(session, fileMessages, null, ctx, config, false);
const fileInput = await session.prompt(6e4);
const fileIndex = parseInt(fileInput) - 1;
if (isNaN(fileIndex) || fileIndex < 0 || fileIndex >= selectedFile.files.length) return "无效选择";
await session.send(`[${selectedFile.files[fileIndex].filename}](${selectedFile.files[fileIndex].url})`);
} else {
await session.send(`[${selectedFile.files[0].filename}](${selectedFile.files[0].url})`);
}
} else {
if (!selectedFile.downloadUrl) return "获取下载链接失败";
await session.send(`[${selectedFile.fileName}](${selectedFile.downloadUrl})`);
}
return "";
}
}
} catch (error) {
ctx.logger.error(`下载处理失败:`, error);
return "下载过程中出错";
}
}
__name(handleDownload, "handleDownload");
// src/resource/curseforge.ts
var CF_API_BASE2 = "https://api.curseforge.com/v1";
async function searchCurseForgeProjects(ctx, keyword, api, options = {}) {
try {
if (!api) return { results: [], pagination: { totalCount: 0 } };
const params = { gameId: 432, searchFilter: keyword, sortOrder: options["sortOrder"] || "desc" };
const validParams = [
"categoryId",
"classId",
"gameVersion",
"modLoaderType",
"gameVersionTypeId",
"authorId",
"primaryAuthorId",
"slug",
"categoryIds",
"gameVersions",
"modLoaderTypes",
"sortField",
"pageSize",
"index"
];
validParams.forEach((param) => {
if (options[param] === void 0 || param === "categoryId" && options[param] === 0) return;
if (Array.isArray(options[param])) {
params[param] = options[param].join(",");
} else if (typeof options[param] === "string" && (param === "categoryIds" || param === "gameVersions" || param === "modLoaderTypes")) {
try {
const pars