koishi-plugin-mc-tools
Version:
我的世界(Minecraft)。可查询 MC 版本、服务器信息、玩家皮肤信息以及四大平台资源;支持管理服务器,功能梭哈
1,153 lines (1,141 loc) • 126 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,
dispose: () => dispose,
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");
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 "请输入玩家用户名";
try {
const profile = await fetchPlayerProfile(ctx, username);
const modelType = profile.skin.model === "slim" ? "纤细" : "经典";
return (0, import_koishi.h)("message", [
import_koishi.h.text(`
玩家: ${profile.name} [${modelType}] `),
profile.cape && import_koishi.h.text("披风"),
import_koishi.h.text(`
UUID: ${profile.uuidDashed}`),
import_koishi.h.text('\n在游戏中使用 "/give @p minecraft:xxx" 来获取玩家头颅'),
import_koishi.h.text(`
1.12及之前:skull 1 3 {SkullOwner:"${profile.name}"}`),
import_koishi.h.text(`
1.13及之后:player_head{SkullOwner:"${profile.name}"}`)
]);
} catch (error) {
ctx.logger.error(`查询玩家信息失败: ${error.message}`, error);
return `查询玩家信息失败: ${error.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"));
function validateServerAddress(input) {
const lowerAddr = input.toLowerCase();
const forbiddenAddresses = ["localhost", "127.0.0.", "0.0.0.0", "::1", "::"];
if (forbiddenAddresses.some((addr) => lowerAddr.includes(addr)) || /^fe80:|^f[cd]|^ff/.test(lowerAddr)) {
throw new Error("无效地址");
}
let port;
if (input.includes(":")) {
const portMatch = input.match(/\]:(\d+)$/) || input.match(/:(\d+)$/);
if (portMatch) {
port = parseInt(portMatch[1], 10);
if (port < 1 || port > 65535) throw new Error("无效端口");
}
}
if (/^(\d{1,3}\.){3}\d{1,3}/.test(input)) {
const ipPart = input.split(":")[0];
const octets = ipPart.split(".").map(Number);
const isInvalid = octets[0] === 10 || octets[0] === 127 || octets[0] === 0 || octets[0] > 223 || octets[0] === 192 && octets[1] === 168 || octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31 || octets[0] === 169 && octets[1] === 254;
if (isInvalid) throw new Error("无效地址");
}
return input;
}
__name(validateServerAddress, "validateServerAddress");
async function pingServer(host, port, type) {
const startTime = Date.now();
if (type === "java") {
return new Promise((resolve, reject) => {
const socket = net.createConnection({ host, port }).once("connect", () => {
socket.destroy();
resolve(Date.now() - startTime);
}).once("error", (err) => {
socket.destroy();
reject(err);
});
});
} else {
return new Promise((resolve, reject) => {
const client = dgram.createSocket("udp4");
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]);
const timer = setTimeout(() => {
client.close();
reject(new Error("查询超时"));
}, 1e4);
client.once("message", () => {
clearTimeout(timer);
client.close();
resolve(Date.now() - startTime);
}).once("error", (err) => {
clearTimeout(timer);
client.close();
reject(err);
});
client.send(pingData, port, host, (err) => {
if (err) {
clearTimeout(timer);
client.close();
reject(err);
}
});
});
}
}
__name(pingServer, "pingServer");
async function fetchServerStatus(ctx, server, forceType, config) {
let address = validateServerAddress(server);
const serverType = forceType || "java";
const defaultPort = serverType === "java" ? 25565 : 19132;
const host = address.split(":")[0];
const port = parseInt(address.split(":")[1]) || defaultPort;
const apiEndpoints = config?.serverApis?.filter((api) => api.type === serverType)?.map((api) => api.url) || [];
const pingPromise = pingServer(host, port, serverType).catch(() => null);
const [pingResult, ...apiResults] = await Promise.allSettled([
pingPromise,
...apiEndpoints.map(async (apiUrl) => {
const startTime = Date.now();
const response = await fetch(apiUrl.replace("${address}", address), { headers: { "User-Agent": "Mozilla/5.0" }, method: "GET" });
if (!response.ok) return null;
const data = await response.json();
const result = normalizeApiResponse(data, address, serverType);
result.ping = Date.now() - startTime;
return result.online && (result.version?.name_clean || result.players.online !== null) ? result : null;
})
]);
const actualPingResult = pingResult.status === "fulfilled" ? pingResult.value : null;
const successResult = apiResults.filter((result) => result.status === "fulfilled" && result.value).map((result) => result.value)[0];
if (successResult) {
if (actualPingResult !== null) successResult.ping = actualPingResult;
return successResult;
}
return {
online: false,
host,
port,
players: { online: null, max: null },
ping: actualPingResult,
error: "查询失败:无法获取服务器状态"
};
}
__name(fetchServerStatus, "fetchServerStatus");
function normalizeApiResponse(data, address, serverType) {
const [host, portStr] = address.split(":");
const defaultPort = serverType === "java" ? 25565 : 19132;
const port = parseInt(portStr) || defaultPort;
const isOffline = data.online === false || data.status === "error" || data.status === "offline" || typeof data.status === "string" && data.status.toLowerCase() === "offline";
if (isOffline) return { online: false, host, port, players: { online: null, max: null }, error: data.error || data.description };
const processListData = /* @__PURE__ */ __name((items, isObject = false) => {
if (!items) return void 0;
if (Array.isArray(items)) return items.map((item) => typeof item === "string" ? { name: item } : item);
if (isObject && typeof items === "object") return Object.entries(items).map(([k, v]) => ({ name: k, version: v }));
return void 0;
}, "processListData");
const processMOTD = /* @__PURE__ */ __name(() => {
if (!data.motd) return data.description?.text || data.description || data.server_motd;
if (typeof data.motd === "string") return data.motd;
if (typeof data.motd !== "object") return null;
const textArray = data.motd.clean || data.motd.raw;
if (!textArray) return null;
return Array.isArray(textArray) ? textArray.join("\n") : textArray;
}, "processMOTD");
return {
online: true,
host: data.hostname || data.host || data.server || host,
port: data.port || data.ipv6Port || port,
ip_address: data.ip_address || data.ip || data.hostip,
eula_blocked: data.eula_blocked || data.blocked,
motd: processMOTD(),
version: {
name_clean: data.version?.name_clean || data.version || data.server?.version || data.server_version,
name: data.version?.name || data.protocol?.name || data.version?.protocol_name
},
players: {
online: data.players?.online ?? data.players?.now ?? data.players_online ?? data.online_players,
max: data.players?.max ?? data.players_max ?? data.max_players,
list: Array.isArray(data.players?.list) ? data.players.list.map((p) => typeof p === "string" ? p : p.name || p.name_clean || p.id) : Array.isArray(data.players) ? data.players.map((p) => typeof p === "string" ? p : p.name || p.name_clean || p.id) : data.players?.sample?.map((p) => p.name) || data.player_list
},
icon: data.icon || data.favicon || data.favocion,
srv_record: data.srv_record || data.srv,
mods: processListData(data.mods, true) || data.modinfo?.modList?.map((m) => ({ name: m.modid, version: m.version })) || (data.modInfo ? [{ name: data.modInfo }] : null) || data.modlist,
software: data.software || data.server?.name || data.server_software,
plugins: processListData(data.plugins, true) || data.plugin_list,
gamemode: data.gamemode || data.game_type || data.gametype,
server_id: data.server_id || data.serverid || data.uuid || data.serverId,
edition: data.edition || (serverType === "bedrock" ? "MCPE" : null) || (data.platform === "MINECRAFT_BEDROCK" ? "MCPE" : null)
};
}
__name(normalizeApiResponse, "normalizeApiResponse");
function formatServerStatus(status, config) {
if (!status.online) return status.error || "服务器离线 - 连接失败";
const getValue = /* @__PURE__ */ __name((name2, limit) => {
switch (name2) {
case "name":
return status.port === 25565 || status.port === 19132 ? status.host : `${status.host}:${status.port}`;
case "ip":
return status.ip_address;
case "srv":
return status.srv_record && `${status.srv_record.host}:${status.srv_record.port}`;
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 != null ? String(status.players.online) : null;
case "max":
return status.players.max != null ? String(status.players.max) : null;
case "ping":
return status.ping ? `${status.ping}ms` : null;
case "software":
return status.software;
case "edition":
return status.edition && ({ MCPE: "基岩版", MCEE: "教育版" }[status.edition] || status.edition);
case "gamemode":
return status.gamemode;
case "eulablock":
return status.eula_blocked ? "已被封禁" : null;
case "serverid":
return status.server_id;
case "playercount":
return status.players.list?.length ? String(status.players.list.length) : null;
case "plugincount":
return status.plugins?.length ? String(status.plugins.length) : null;
case "modcount":
return status.mods?.length ? String(status.mods.length) : null;
case "playerlist":
if (!status.players.list?.length) return null;
limit = limit || status.players.list.length;
return status.players.list.slice(0, limit).map((p) => p).join(", ") + (limit < status.players.list.length ? "..." : "");
case "pluginlist":
if (!status.plugins?.length) return null;
limit = limit || status.plugins.length;
return status.plugins.slice(0, limit).map((p) => p.version ? `${p.name}-${p.version}` : p.name).join(", ") + (limit < status.plugins.length ? "..." : "");
case "modlist":
if (!status.mods?.length) return null;
limit = limit || status.mods.length;
return status.mods.slice(0, limit).map((m) => m.version ? `${m.name}-${m.version}` : m.name).join(", ") + (limit < status.mods.length ? "..." : "");
default:
return null;
}
}, "getValue");
const results = config.serverTemplate.split("\n").map((line) => {
const placeholders = Array.from(line.matchAll(/\{([^{}:]+)(?::(\d+))?\}/g));
if (placeholders.length > 0 && placeholders.every((match) => {
const name2 = match[1];
const limit = match[2] ? parseInt(match[2], 10) : void 0;
const value = getValue(name2, limit);
return value === null || value === void 0 || value === "";
})) return "";
return line.replace(/\{([^{}:]+)(?::(\d+))?\}/g, (match, name2, limitStr) => {
const limit = limitStr ? parseInt(limitStr, 10) : void 0;
const value = getValue(name2, limit);
return value !== null && value !== void 0 ? value : "";
});
}).filter((line) => line.trim()).join("\n");
return results.replace(/\n{3,}/g, "\n\n").trim();
}
__name(formatServerStatus, "formatServerStatus");
function findGroupServer(session, config) {
const mapping = config.serverMaps.find((m) => m.platform === session.platform && m.channelId === session.guildId);
return mapping?.serverAddress || null;
}
__name(findGroupServer, "findGroupServer");
function registerInfo(ctx, parent, config) {
const mcinfo = parent.subcommand(".info [server]", "查询 Minecraft 服务器").usage(`mc.info [地址[:端口]] - 查询 Java 服务器
mc.info.be [地址[:端口]] - 查询 Bedrock 服务器`).action(async ({ session }, server) => {
if (!server) {
server = findGroupServer(session, config);
if (!server) return "请提供服务器地址";
}
const status = await fetchServerStatus(ctx, server, "java", config);
return formatServerStatus(status, config);
});
mcinfo.subcommand(".be [server]", "查询 Bedrock 服务器").action(async ({ session }, server) => {
if (!server) {
server = findGroupServer(session, config);
if (!server) return "请提供服务器地址";
}
const status = await fetchServerStatus(ctx, server, "bedrock", config);
return formatServerStatus(status, config);
});
}
__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(setupServerIdBefore).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(setupServerIdBefore).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 prevVersions = { release: { id: "", releaseTime: "" }, snapshot: { id: "", releaseTime: "" } };
var versionCheckInterval = null;
function cleanupVerCheck() {
if (versionCheckInterval) {
clearInterval(versionCheckInterval);
versionCheckInterval = null;
}
}
__name(cleanupVerCheck, "cleanupVerCheck");
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();
return formatVersionInfo(versions.release, versions.snapshot);
} catch (error) {
if (prevVersions.release.id && prevVersions.snapshot.id) return formatVersionInfo(prevVersions.release, prevVersions.snapshot);
return "获取 Minecraft 版本信息失败";
}
});
}
__name(registerVer, "registerVer");
function regVerCheck(ctx, config) {
const checkVersions = /* @__PURE__ */ __name(async () => {
try {
const latest = await getLatestVersion();
const isFirstCheck = !prevVersions.release.id;
if (!isFirstCheck) {
if (latest.release.id !== prevVersions.release.id) {
sendUpdateNotification(ctx, config.noticeTargets, "release", latest.release);
}
if (latest.snapshot.id !== prevVersions.snapshot.id && latest.snapshot.id !== latest.release.id) {
sendUpdateNotification(ctx, config.noticeTargets, "snapshot", latest.snapshot);
}
}
Object.assign(prevVersions, latest);
} catch (error) {
ctx.logger.warn("获取版本信息失败:", error);
}
}, "checkVersions");
checkVersions();
versionCheckInterval = 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(15e3), 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");
async function sendStatusNotification(ctx, targets, changes) {
if (!targets?.length) return;
const changeLines = changes.map(({ service, to: isOnline }) => {
const symbol = isOnline ? "[√]" : "[×]";
const statusText = isOnline ? "恢复正常" : "服务异常";
return `${symbol} ${service}: ${statusText}`;
});
const statusMessage = ["Minecraft 服务状态变更:", ...changeLines].join("\n");
const broadcastChannels = targets.map((t) => `${t.platform}:${t.channelId}`);
await ctx.broadcast(broadcastChannels, statusMessage);
}
__name(sendStatusNotification, "sendStatusNotification");
var prevStatus = {};
var statusCheckInterval = null;
function cleanupStatusCheck() {
if (statusCheckInterval) {
clearInterval(statusCheckInterval);
statusCheckInterval = null;
}
}
__name(cleanupStatusCheck, "cleanupStatusCheck");
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) {
if (!config.statusNoticeTargets?.length) return;
const checkStatus = /* @__PURE__ */ __name(async () => {
try {
const currentStatus = await getMinecraftStatus();
if (Object.keys(prevStatus).length > 0) {
const changes = Object.entries(currentStatus).filter(([service, to]) => prevStatus[service] !== void 0 && prevStatus[service] !== to).map(([service, to]) => ({ service, from: prevStatus[service], to }));
if (changes.length > 0) {
await sendStatusNotification(ctx, config.statusNoticeTargets, changes);
}
}
prevStatus = currentStatus;
} catch (error) {
ctx.logger.warn("检查 Minecraft 服务状态失败:", error);
}
}, "checkStatus");
checkStatus();
const intervalMinutes = config.statusUpdInterval ?? 10;
statusCheckInterval = setInterval(checkStatus, intervalMinutes * 60 * 1e3);
}
__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;
const displayPageSize = config.searchResults || 10;
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 * displayPageSize >= 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 * displayPageSize;
if (startIndex >= allFiles.length) return "已取消下载";
const endIndex = Math.min(startIndex + displayPageSize, allFiles.length);
const pageFiles = allFiles.slice(startIndex, endIndex);
const totalPages = Math.ceil(totalItems / displayPageSize);
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 searchCurseForge