UNPKG

koishi-plugin-mc-tools

Version:

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

1,186 lines (1,174 loc) 124 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, 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