UNPKG

koishi-plugin-mc-tools

Version:

我的世界(Minecraft)。可查询 MC 版本、服务器信息、玩家皮肤信息以及四大平台资源;支持管理服务器,功能梭哈

1,153 lines (1,141 loc) 126 kB
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