feat: 添加 SkinBridge 皮肤兼容
This commit is contained in:
@@ -67,6 +67,7 @@
|
||||
| `jei-sync` | 开启 | Fabric / NeoForge JEI 配方同步修复 |
|
||||
| `mob-drops` | 关闭 | 末影人掉落控制,默认关闭以保留过去标准版行为 |
|
||||
| `maintenance` | 开启 | 维护模式命令、MOTD 替换、登录拦截、白名单和拦截通知 |
|
||||
| `skin-bridge` | 关闭 | 查询外置 Yggdrasil profile,并通过 MineSkin 与 Paper Profile API 同步皮肤 |
|
||||
|
||||
修改模块开关后可先使用 `/essc reload` 刷新运行期服务与监听器状态。由于 Bukkit 命令表不适合在运行期完整热增删,若模块是在启动时关闭的,对应直连命令可能仍需重启后才会注册;通过 `/essc <子命令>` 入口通常可立即按新的模块状态执行。
|
||||
|
||||
@@ -82,12 +83,15 @@
|
||||
|
||||
当前配置结构以“行为配置”和“文本配置”分离为原则:
|
||||
|
||||
SkinBridge 默认关闭。使用前需同时将 `modules.yml` 中的 `modules.skin-bridge.enabled` 与 `config.yml` 中的 `skin-bridge.enabled` 设为 `true`,在 `skin-bridge.mineskin.api-key` 中配置 MineSkin API Key,并至少启用一个 Provider。真实密钥只应填写在服务器运行目录的 `config.yml` 中,不要写入源码或提交到公开仓库。该模块不再要求安装 SkinsRestorer。`skin-bridge.send-player-message` 控制是否向玩家发送检测、匹配和同步结果提示。`skin-bridge.providers` 下的键名可自由命名,会显示在日志和 `/essc skin status <玩家>` 中;建议使用小写英文、数字和连字符,避免使用点号。控制台可使用 `/essc skin status <玩家>` 与 `/essc skin refresh <玩家>` 进行验证。
|
||||
|
||||
- `config.yml`
|
||||
- 语言选择
|
||||
- 管理模式行为
|
||||
- JEI 同步开关
|
||||
- 掉落控制
|
||||
- TPSBar 模式
|
||||
- SkinBridge Provider、缓存和超时配置
|
||||
- 便捷菜单布局
|
||||
- `modules.yml`
|
||||
- 功能模块开关
|
||||
@@ -127,6 +131,7 @@ essentialsc.command.seen
|
||||
essentialsc.command.admin
|
||||
essentialsc.command.tpsbar
|
||||
essentialsc.command.maintenance
|
||||
essentialsc.command.skin
|
||||
essentialsc.maintenance.bypass
|
||||
essentialsc.maintenance.notify
|
||||
essentialsc.shulkerbox.open
|
||||
|
||||
+2
-1
@@ -8,7 +8,7 @@ plugins {
|
||||
}
|
||||
|
||||
group = 'cn.infstar'
|
||||
version = '1.6.0'
|
||||
version = '1.7.0'
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
@@ -21,6 +21,7 @@ repositories {
|
||||
dependencies {
|
||||
paperweight.paperDevBundle('1.21.11-R0.1-SNAPSHOT')
|
||||
implementation project(':compat-api')
|
||||
implementation 'com.google.code.gson:gson:2.11.0'
|
||||
}
|
||||
|
||||
java {
|
||||
|
||||
@@ -13,6 +13,7 @@ import cn.infstar.essentialsC.listeners.ShulkerBoxListener;
|
||||
import cn.infstar.essentialsC.listeners.VanishListener;
|
||||
import cn.infstar.essentialsC.maintenance.MaintenanceListener;
|
||||
import cn.infstar.essentialsC.maintenance.MaintenanceManager;
|
||||
import cn.infstar.essentialsC.skinbridge.SkinBridgeManager;
|
||||
import cn.infstar.essentialsC.teleport.TeleportRequestManager;
|
||||
import cn.infstar.essentialsC.tpsbar.TpsBarManager;
|
||||
import cn.infstar.essentialsC.tpsbar.TpsBarService;
|
||||
@@ -45,6 +46,7 @@ public final class EssentialsC extends JavaPlugin {
|
||||
private MobDropListener mobDropListener;
|
||||
private MobDropMenuListener mobDropMenuListener;
|
||||
private VanishListener vanishListener;
|
||||
private SkinBridgeManager skinBridgeManager;
|
||||
private boolean commandsRegistered;
|
||||
private final Map<String, String> moduleStatus = new LinkedHashMap<>();
|
||||
|
||||
@@ -73,6 +75,9 @@ public final class EssentialsC extends JavaPlugin {
|
||||
if (teleportRequestManager != null) {
|
||||
teleportRequestManager.shutdown();
|
||||
}
|
||||
if (skinBridgeManager != null) {
|
||||
skinBridgeManager.shutdown();
|
||||
}
|
||||
VanishCommand.clearAll(this);
|
||||
unregisterRuntimeListeners();
|
||||
unregisterPluginChannels();
|
||||
@@ -103,6 +108,10 @@ public final class EssentialsC extends JavaPlugin {
|
||||
return tpsBarManager;
|
||||
}
|
||||
|
||||
public SkinBridgeManager getSkinBridgeManager() {
|
||||
return skinBridgeManager;
|
||||
}
|
||||
|
||||
public void reloadRuntimeModules() {
|
||||
moduleStatus.clear();
|
||||
refreshPlayer();
|
||||
@@ -112,6 +121,7 @@ public final class EssentialsC extends JavaPlugin {
|
||||
refreshBlocks();
|
||||
refreshJeiSync();
|
||||
refreshMobDrops();
|
||||
refreshSkinBridge();
|
||||
}
|
||||
|
||||
private void refreshPlayer() {
|
||||
@@ -278,6 +288,26 @@ public final class EssentialsC extends JavaPlugin {
|
||||
setModuleStatus("生物掉落", true, "末影人掉落控制已启用");
|
||||
}
|
||||
|
||||
private void refreshSkinBridge() {
|
||||
if (!moduleManager.isEnabled(ModuleManager.SKIN_BRIDGE)) {
|
||||
if (skinBridgeManager != null) {
|
||||
skinBridgeManager.shutdown();
|
||||
HandlerList.unregisterAll(skinBridgeManager);
|
||||
skinBridgeManager = null;
|
||||
}
|
||||
setModuleStatus("皮肤桥接", false, "已禁用");
|
||||
return;
|
||||
}
|
||||
|
||||
if (skinBridgeManager == null) {
|
||||
skinBridgeManager = new SkinBridgeManager(this);
|
||||
getServer().getPluginManager().registerEvents(skinBridgeManager, this);
|
||||
} else {
|
||||
skinBridgeManager.reload();
|
||||
}
|
||||
setModuleStatus("皮肤桥接", true, skinBridgeManager.getModuleDetail());
|
||||
}
|
||||
|
||||
private void registerPluginChannels() {
|
||||
Messenger messenger = getServer().getMessenger();
|
||||
messenger.registerOutgoingPluginChannel(this, "fabric:recipe_sync");
|
||||
@@ -299,6 +329,7 @@ public final class EssentialsC extends JavaPlugin {
|
||||
unregisterListener(mobDropMenuListener);
|
||||
unregisterListener(vanishListener);
|
||||
unregisterListener(teleportRequestManager);
|
||||
unregisterListener(skinBridgeManager);
|
||||
if (tpsBarManager instanceof Listener listener) {
|
||||
unregisterListener(listener);
|
||||
}
|
||||
|
||||
@@ -152,15 +152,24 @@ public class LangManager {
|
||||
}
|
||||
|
||||
private void loadDefaultLanguageFallback() {
|
||||
InputStream defaultLangStream = plugin.getResource("lang/en_US.yml");
|
||||
if (defaultLangStream == null) {
|
||||
InputStream selectedLangStream = plugin.getResource("lang/" + currentLanguage + ".yml");
|
||||
if (selectedLangStream == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
YamlConfiguration defaultLang = YamlConfiguration.loadConfiguration(
|
||||
new InputStreamReader(defaultLangStream, StandardCharsets.UTF_8)
|
||||
YamlConfiguration selectedDefaults = YamlConfiguration.loadConfiguration(
|
||||
new InputStreamReader(selectedLangStream, StandardCharsets.UTF_8)
|
||||
);
|
||||
langFile.setDefaults(defaultLang);
|
||||
if (!"en_US".equalsIgnoreCase(currentLanguage)) {
|
||||
InputStream englishLangStream = plugin.getResource("lang/en_US.yml");
|
||||
if (englishLangStream != null) {
|
||||
YamlConfiguration englishDefaults = YamlConfiguration.loadConfiguration(
|
||||
new InputStreamReader(englishLangStream, StandardCharsets.UTF_8)
|
||||
);
|
||||
selectedDefaults.setDefaults(englishDefaults);
|
||||
}
|
||||
}
|
||||
langFile.setDefaults(selectedDefaults);
|
||||
}
|
||||
|
||||
private String applyPlaceholders(String value, Map<String, String> placeholders) {
|
||||
|
||||
@@ -20,6 +20,7 @@ public final class ModuleManager {
|
||||
public static final String JEI_SYNC = "jei-sync";
|
||||
public static final String MOB_DROPS = "mob-drops";
|
||||
public static final String MAINTENANCE = "maintenance";
|
||||
public static final String SKIN_BRIDGE = "skin-bridge";
|
||||
|
||||
private static final Map<String, Boolean> DEFAULT_MODULES = new LinkedHashMap<>();
|
||||
|
||||
@@ -31,6 +32,7 @@ public final class ModuleManager {
|
||||
DEFAULT_MODULES.put(JEI_SYNC, true);
|
||||
DEFAULT_MODULES.put(MOB_DROPS, false);
|
||||
DEFAULT_MODULES.put(MAINTENANCE, true);
|
||||
DEFAULT_MODULES.put(SKIN_BRIDGE, false);
|
||||
}
|
||||
|
||||
private final JavaPlugin plugin;
|
||||
|
||||
@@ -50,6 +50,7 @@ public final class CommandRegistry {
|
||||
register("mobdrops", "essentialsc.mobdrops.enderman", ModuleManager.MOB_DROPS, "cn.infstar.essentialsC.commands.MobDropCommand");
|
||||
register("maintenance", "essentialsc.command.maintenance", ModuleManager.MAINTENANCE, "cn.infstar.essentialsC.commands.MaintenanceCommand", "maint");
|
||||
registerSubCommand("admin", "essentialsc.command.admin", ModuleManager.ADMIN_MODE, "cn.infstar.essentialsC.commands.AdminCommand");
|
||||
registerSubCommand("skin", "essentialsc.command.skin", ModuleManager.SKIN_BRIDGE, "cn.infstar.essentialsC.commands.SkinBridgeCommand");
|
||||
}
|
||||
|
||||
private CommandRegistry() {
|
||||
|
||||
@@ -202,6 +202,10 @@ public class HelpCommand extends BaseCommand implements TabCompleter {
|
||||
otherCommands.append(lang.getString("help.commands.tpignore")).append("\n");
|
||||
hasOtherCommands = true;
|
||||
}
|
||||
if (CommandRegistry.isAvailable("skin") && player.hasPermission("essentialsc.command.skin")) {
|
||||
otherCommands.append(lang.getString("help.commands.skin")).append("\n");
|
||||
hasOtherCommands = true;
|
||||
}
|
||||
if (CommandRegistry.isAvailable("admin") && player.hasPermission("essentialsc.command.admin")) {
|
||||
otherCommands.append(lang.getString("help.commands.admin")).append("\n");
|
||||
hasOtherCommands = true;
|
||||
@@ -293,6 +297,7 @@ public class HelpCommand extends BaseCommand implements TabCompleter {
|
||||
{"tpdecline", "essentialsc.command.tpdeny"},
|
||||
{"tpno", "essentialsc.command.tpdeny"},
|
||||
{"tpignore", "essentialsc.command.tpignore"},
|
||||
{"skin", "essentialsc.command.skin"},
|
||||
{"tpsbar", "essentialsc.command.tpsbar"},
|
||||
{"maintenance", "essentialsc.command.maintenance"},
|
||||
{"maint", "essentialsc.command.maintenance"},
|
||||
@@ -345,6 +350,13 @@ public class HelpCommand extends BaseCommand implements TabCompleter {
|
||||
return manager == null ? List.of() : manager.getIncomingRequesterNames(completionPlayer, args[1]);
|
||||
}
|
||||
|
||||
if (subCmd.equals("skin") && sender.hasPermission("essentialsc.command.skin")) {
|
||||
String partial = args[1].toLowerCase();
|
||||
return List.of("status", "refresh").stream()
|
||||
.filter(option -> option.startsWith(partial))
|
||||
.toList();
|
||||
}
|
||||
|
||||
if ((subCmd.equals("nightvision") || subCmd.equals("nv")) && sender.hasPermission("essentialsc.command.nightvision")) {
|
||||
return completeToggleArgs(args[1]);
|
||||
}
|
||||
@@ -370,6 +382,19 @@ public class HelpCommand extends BaseCommand implements TabCompleter {
|
||||
}
|
||||
}
|
||||
|
||||
if (args.length == 3 && args[0].equalsIgnoreCase("skin")
|
||||
&& (args[1].equalsIgnoreCase("status") || args[1].equalsIgnoreCase("refresh"))
|
||||
&& sender.hasPermission("essentialsc.command.skin")) {
|
||||
List<String> players = new ArrayList<>();
|
||||
String partial = args[2].toLowerCase();
|
||||
for (Player player : Bukkit.getOnlinePlayers()) {
|
||||
if (player.getName().toLowerCase().startsWith(partial)) {
|
||||
players.add(player.getName());
|
||||
}
|
||||
}
|
||||
return players;
|
||||
}
|
||||
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
package cn.infstar.essentialsC.commands;
|
||||
|
||||
import cn.infstar.essentialsC.skinbridge.SkinBridgeManager;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.command.Command;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.command.TabCompleter;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
public final class SkinBridgeCommand extends BaseCommand implements TabCompleter {
|
||||
|
||||
public SkinBridgeCommand() {
|
||||
super("essentialsc.command.skin");
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean execute(Player player, String[] args) {
|
||||
return executeCommand(player, args, false);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean executeConsole(CommandSender sender, String[] args) {
|
||||
return executeCommand(sender, args, true);
|
||||
}
|
||||
|
||||
private boolean executeCommand(CommandSender sender, String[] args, boolean console) {
|
||||
if (args.length < 1 || args.length > 2) {
|
||||
sender.sendMessage(getLang().getPrefixedString("skin-bridge.messages.usage"));
|
||||
return true;
|
||||
}
|
||||
|
||||
SkinBridgeManager manager = plugin.getSkinBridgeManager();
|
||||
if (manager == null) {
|
||||
sender.sendMessage(getLang().getPrefixedString("messages.module-disabled"));
|
||||
return true;
|
||||
}
|
||||
|
||||
Player target = console ? null : (Player) sender;
|
||||
if (args.length == 2) {
|
||||
target = Bukkit.getPlayerExact(args[1]);
|
||||
if (target == null || !target.isOnline()) {
|
||||
sender.sendMessage(getLang().getPrefixedString("messages.player-not-found", Map.of("player", args[1])));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (target == null) {
|
||||
sender.sendMessage(getLang().getPrefixedString("skin-bridge.messages.usage"));
|
||||
return true;
|
||||
}
|
||||
|
||||
String action = args[0].toLowerCase(Locale.ROOT);
|
||||
if (action.equals("status")) {
|
||||
sendStatus(sender, target, manager);
|
||||
return true;
|
||||
}
|
||||
if (action.equals("refresh")) {
|
||||
sendRefreshResult(sender, target, manager.queueSync(target, true));
|
||||
return true;
|
||||
}
|
||||
|
||||
sender.sendMessage(getLang().getPrefixedString("skin-bridge.messages.usage"));
|
||||
return true;
|
||||
}
|
||||
|
||||
private void sendStatus(CommandSender sender, Player target, SkinBridgeManager manager) {
|
||||
if (!manager.isEnabled()) {
|
||||
sender.sendMessage(getLang().getPrefixedString("skin-bridge.messages.feature-disabled"));
|
||||
return;
|
||||
}
|
||||
if (!manager.isSkinGatewayAvailable()) {
|
||||
sender.sendMessage(getLang().getPrefixedString("skin-bridge.messages.dependency-missing"));
|
||||
return;
|
||||
}
|
||||
if (manager.getProviderCount() == 0) {
|
||||
sender.sendMessage(getLang().getPrefixedString("skin-bridge.messages.no-providers"));
|
||||
return;
|
||||
}
|
||||
|
||||
SkinBridgeManager.Status status = manager.getStatus(target);
|
||||
Map<String, String> placeholders = Map.of("player", target.getName(), "provider", String.valueOf(status.providerId()));
|
||||
switch (status.state()) {
|
||||
case EXTERNAL -> sender.sendMessage(getLang().getPrefixedString("skin-bridge.messages.status-external", placeholders));
|
||||
case NOT_EXTERNAL -> sender.sendMessage(getLang().getPrefixedString("skin-bridge.messages.status-not-external", placeholders));
|
||||
case PENDING -> sender.sendMessage(getLang().getPrefixedString("skin-bridge.messages.status-pending", placeholders));
|
||||
case UNKNOWN -> sender.sendMessage(getLang().getPrefixedString("skin-bridge.messages.status-unknown", placeholders));
|
||||
}
|
||||
}
|
||||
|
||||
private void sendRefreshResult(CommandSender sender, Player target, SkinBridgeManager.SyncResult result) {
|
||||
Map<String, String> placeholders = Map.of("player", target.getName());
|
||||
String messagePath = switch (result) {
|
||||
case QUEUED -> "skin-bridge.messages.refresh-queued";
|
||||
case CACHED -> "skin-bridge.messages.refresh-cached";
|
||||
case ALREADY_RUNNING -> "skin-bridge.messages.refresh-running";
|
||||
case FEATURE_DISABLED -> "skin-bridge.messages.feature-disabled";
|
||||
case DEPENDENCY_MISSING -> "skin-bridge.messages.dependency-missing";
|
||||
case NO_PROVIDERS -> "skin-bridge.messages.no-providers";
|
||||
};
|
||||
sender.sendMessage(getLang().getPrefixedString(messagePath, placeholders));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> onTabComplete(CommandSender sender, Command command, String label, String[] args) {
|
||||
if (args.length == 1) {
|
||||
String partial = args[0].toLowerCase(Locale.ROOT);
|
||||
return List.of("status", "refresh").stream()
|
||||
.filter(option -> option.startsWith(partial))
|
||||
.toList();
|
||||
}
|
||||
if (args.length == 2 && (args[0].equalsIgnoreCase("status") || args[0].equalsIgnoreCase("refresh"))) {
|
||||
String partial = args[1].toLowerCase(Locale.ROOT);
|
||||
List<String> players = new ArrayList<>();
|
||||
for (Player onlinePlayer : Bukkit.getOnlinePlayers()) {
|
||||
if (onlinePlayer.getName().toLowerCase(Locale.ROOT).startsWith(partial)) {
|
||||
players.add(onlinePlayer.getName());
|
||||
}
|
||||
}
|
||||
return players;
|
||||
}
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package cn.infstar.essentialsC.skinbridge;
|
||||
|
||||
import com.destroystokyo.paper.profile.PlayerProfile;
|
||||
import com.destroystokyo.paper.profile.ProfileProperty;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
|
||||
public final class MineSkinGateway implements SkinBridgeGateway {
|
||||
|
||||
private static final String TEXTURES_PROPERTY = "textures";
|
||||
private static final String QUEUE_PATH = "/v2/queue";
|
||||
private static final long POLL_INTERVAL_MILLIS = 1000L;
|
||||
|
||||
private final HttpClient httpClient;
|
||||
private final URI queueUri;
|
||||
private final String apiKey;
|
||||
private final String visibility;
|
||||
private final int timeoutSeconds;
|
||||
private final String userAgent;
|
||||
|
||||
public MineSkinGateway(HttpClient httpClient, String endpoint, String apiKey, String visibility, int timeoutSeconds, String userAgent) {
|
||||
this.httpClient = httpClient;
|
||||
this.queueUri = URI.create(normalizeEndpoint(endpoint) + QUEUE_PATH);
|
||||
this.apiKey = apiKey;
|
||||
this.visibility = normalizeVisibility(visibility);
|
||||
this.timeoutSeconds = timeoutSeconds;
|
||||
this.userAgent = userAgent;
|
||||
}
|
||||
|
||||
@Override
|
||||
public GeneratedSkin generateSkin(String skinUrl, SkinModel model) throws Exception {
|
||||
JsonObject requestBody = new JsonObject();
|
||||
requestBody.addProperty("url", skinUrl);
|
||||
requestBody.addProperty("variant", model == SkinModel.SLIM ? "slim" : "classic");
|
||||
requestBody.addProperty("visibility", visibility);
|
||||
|
||||
HttpResponse<String> response = send(HttpRequest.newBuilder(queueUri)
|
||||
.POST(HttpRequest.BodyPublishers.ofString(requestBody.toString(), StandardCharsets.UTF_8))
|
||||
.header("Content-Type", "application/json"));
|
||||
JsonObject body = parseResponse(response);
|
||||
if (response.statusCode() == 200) {
|
||||
return parseGeneratedSkin(body);
|
||||
}
|
||||
if (response.statusCode() != 202) {
|
||||
throw apiError("提交 MineSkin 生成请求", response, body);
|
||||
}
|
||||
|
||||
JsonObject job = object(body, "job");
|
||||
String jobId = string(job, "id");
|
||||
return pollJob(jobId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void applySkin(Player player, GeneratedSkin skin) {
|
||||
PlayerProfile profile = player.getPlayerProfile();
|
||||
profile.removeProperty(TEXTURES_PROPERTY);
|
||||
profile.setProperty(new ProfileProperty(TEXTURES_PROPERTY, skin.value(), skin.signature()));
|
||||
player.setPlayerProfile(profile);
|
||||
}
|
||||
|
||||
private GeneratedSkin pollJob(String jobId) throws Exception {
|
||||
long deadline = System.nanoTime() + Duration.ofSeconds(timeoutSeconds).toNanos();
|
||||
URI jobUri = URI.create(queueUri + "/" + jobId);
|
||||
while (System.nanoTime() < deadline) {
|
||||
HttpResponse<String> response = send(HttpRequest.newBuilder(jobUri).GET());
|
||||
JsonObject body = parseResponse(response);
|
||||
if (response.statusCode() != 200) {
|
||||
throw apiError("查询 MineSkin 生成任务", response, body);
|
||||
}
|
||||
|
||||
JsonObject job = object(body, "job");
|
||||
String status = string(job, "status");
|
||||
if ("completed".equalsIgnoreCase(status)) {
|
||||
return parseGeneratedSkin(body);
|
||||
}
|
||||
if ("failed".equalsIgnoreCase(status)) {
|
||||
throw new IllegalStateException("MineSkin 生成任务失败: " + errorMessage(body));
|
||||
}
|
||||
if (!"waiting".equalsIgnoreCase(status) && !"active".equalsIgnoreCase(status)) {
|
||||
throw new IllegalStateException("MineSkin 返回未知任务状态: " + status);
|
||||
}
|
||||
Thread.sleep(POLL_INTERVAL_MILLIS);
|
||||
}
|
||||
throw new IllegalStateException("MineSkin 生成任务超时,请增大 skin-bridge.mineskin.request-timeout-seconds。");
|
||||
}
|
||||
|
||||
private HttpResponse<String> send(HttpRequest.Builder builder) throws Exception {
|
||||
HttpRequest request = builder
|
||||
.timeout(Duration.ofSeconds(timeoutSeconds))
|
||||
.header("Accept", "application/json")
|
||||
.header("Authorization", "Bearer " + apiKey)
|
||||
.header("User-Agent", userAgent)
|
||||
.build();
|
||||
return httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
private JsonObject parseResponse(HttpResponse<String> response) {
|
||||
if (response.body() == null || response.body().isBlank()) {
|
||||
throw new IllegalStateException("MineSkin 返回空响应,HTTP " + response.statusCode());
|
||||
}
|
||||
if (response.body().length() > 1_048_576) {
|
||||
throw new IllegalStateException("MineSkin 响应超过 1 MiB 限制。");
|
||||
}
|
||||
try {
|
||||
return JsonParser.parseString(response.body()).getAsJsonObject();
|
||||
} catch (RuntimeException exception) {
|
||||
throw new IllegalStateException("MineSkin 返回了无效 JSON,HTTP " + response.statusCode(), exception);
|
||||
}
|
||||
}
|
||||
|
||||
private GeneratedSkin parseGeneratedSkin(JsonObject body) {
|
||||
JsonObject skin = object(body, "skin");
|
||||
JsonObject texture = object(skin, "texture");
|
||||
JsonObject data = object(texture, "data");
|
||||
return new GeneratedSkin(string(data, "value"), string(data, "signature"));
|
||||
}
|
||||
|
||||
private IllegalStateException apiError(String action, HttpResponse<String> response, JsonObject body) {
|
||||
return new IllegalStateException(action + "失败,HTTP " + response.statusCode() + ": " + errorMessage(body));
|
||||
}
|
||||
|
||||
private String errorMessage(JsonObject body) {
|
||||
for (String key : new String[]{"message", "error", "code"}) {
|
||||
if (body.has(key) && body.get(key).isJsonPrimitive()) {
|
||||
return body.get(key).getAsString();
|
||||
}
|
||||
}
|
||||
return "未提供错误信息";
|
||||
}
|
||||
|
||||
private static JsonObject object(JsonObject parent, String key) {
|
||||
if (!parent.has(key) || !parent.get(key).isJsonObject()) {
|
||||
throw new IllegalStateException("MineSkin 响应缺少对象字段: " + key);
|
||||
}
|
||||
return parent.getAsJsonObject(key);
|
||||
}
|
||||
|
||||
private static String string(JsonObject parent, String key) {
|
||||
if (!parent.has(key) || !parent.get(key).isJsonPrimitive()) {
|
||||
throw new IllegalStateException("MineSkin 响应缺少字符串字段: " + key);
|
||||
}
|
||||
String value = parent.get(key).getAsString();
|
||||
if (value.isBlank()) {
|
||||
throw new IllegalStateException("MineSkin 响应字符串字段为空: " + key);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private static String normalizeEndpoint(String endpoint) {
|
||||
String normalized = endpoint == null ? "" : endpoint.trim();
|
||||
while (normalized.endsWith("/")) {
|
||||
normalized = normalized.substring(0, normalized.length() - 1);
|
||||
}
|
||||
if (normalized.isBlank()) {
|
||||
throw new IllegalArgumentException("MineSkin API 地址不能为空。");
|
||||
}
|
||||
URI uri = URI.create(normalized);
|
||||
if (!"https".equalsIgnoreCase(uri.getScheme())) {
|
||||
throw new IllegalArgumentException("MineSkin API 必须使用 HTTPS。");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static String normalizeVisibility(String visibility) {
|
||||
String normalized = visibility == null ? "" : visibility.trim().toLowerCase(java.util.Locale.ROOT);
|
||||
if (!normalized.equals("public") && !normalized.equals("unlisted") && !normalized.equals("private")) {
|
||||
throw new IllegalArgumentException("MineSkin visibility 必须是 public、unlisted 或 private。");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package cn.infstar.essentialsC.skinbridge;
|
||||
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
interface SkinBridgeGateway {
|
||||
|
||||
GeneratedSkin generateSkin(String skinUrl, SkinModel model) throws Exception;
|
||||
|
||||
void applySkin(Player player, GeneratedSkin skin) throws Exception;
|
||||
}
|
||||
|
||||
enum SkinModel {
|
||||
CLASSIC,
|
||||
SLIM
|
||||
}
|
||||
|
||||
record GeneratedSkin(String value, String signature) {
|
||||
}
|
||||
@@ -0,0 +1,491 @@
|
||||
package cn.infstar.essentialsC.skinbridge;
|
||||
|
||||
import cn.infstar.essentialsC.EssentialsC;
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.configuration.ConfigurationSection;
|
||||
import org.bukkit.configuration.file.FileConfiguration;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.player.PlayerJoinEvent;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ConcurrentMap;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ThreadFactory;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
public final class SkinBridgeManager implements Listener {
|
||||
|
||||
private final EssentialsC plugin;
|
||||
private final HttpClient httpClient;
|
||||
private final ExecutorService executor;
|
||||
private final ConcurrentMap<UUID, CachedLookup> cache = new ConcurrentHashMap<>();
|
||||
private final ConcurrentMap<UUID, Long> pendingLookups = new ConcurrentHashMap<>();
|
||||
private final AtomicLong configurationGeneration = new AtomicLong();
|
||||
|
||||
private volatile List<SkinProvider> providers = List.of();
|
||||
private volatile SkinBridgeGateway gateway;
|
||||
private volatile boolean enabled;
|
||||
private volatile boolean debug;
|
||||
private volatile boolean sendPlayerMessage;
|
||||
private volatile int requestTimeoutSeconds;
|
||||
private volatile int cacheMinutes;
|
||||
private volatile long joinDelayTicks;
|
||||
|
||||
public SkinBridgeManager(EssentialsC plugin) {
|
||||
this.plugin = plugin;
|
||||
this.httpClient = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(5))
|
||||
.followRedirects(HttpClient.Redirect.NEVER)
|
||||
.build();
|
||||
this.executor = Executors.newFixedThreadPool(2, new SkinBridgeThreadFactory());
|
||||
reload();
|
||||
}
|
||||
|
||||
public void reload() {
|
||||
configurationGeneration.incrementAndGet();
|
||||
FileConfiguration config = plugin.getConfig();
|
||||
addDefaults(config);
|
||||
config.options().copyDefaults(true);
|
||||
plugin.saveConfig();
|
||||
|
||||
enabled = config.getBoolean("skin-bridge.enabled", false);
|
||||
debug = config.getBoolean("skin-bridge.debug", false);
|
||||
sendPlayerMessage = config.getBoolean("skin-bridge.send-player-message", true);
|
||||
requestTimeoutSeconds = clamp(config.getInt("skin-bridge.profile-request-timeout-seconds", 5), 1, 30);
|
||||
String mineSkinEndpoint = config.getString("skin-bridge.mineskin.endpoint", "https://api.mineskin.org");
|
||||
String mineSkinApiKey = config.getString("skin-bridge.mineskin.api-key", "").trim();
|
||||
String mineSkinVisibility = config.getString("skin-bridge.mineskin.visibility", "unlisted");
|
||||
int mineSkinTimeoutSeconds = clamp(config.getInt("skin-bridge.mineskin.request-timeout-seconds", 30), 10, 180);
|
||||
cacheMinutes = clamp(config.getInt("skin-bridge.cache-minutes", 120), 5, 10080);
|
||||
joinDelayTicks = clamp(config.getLong("skin-bridge.join-delay-ticks", 20L), 0, 200);
|
||||
providers = loadProviders(config);
|
||||
cache.clear();
|
||||
gateway = loadGateway(mineSkinEndpoint, mineSkinApiKey, mineSkinVisibility, mineSkinTimeoutSeconds);
|
||||
|
||||
if (enabled && gateway == null) {
|
||||
plugin.getLogger().warning("SkinBridge 已启用,但未配置有效的 MineSkin API Key,皮肤同步不会执行。");
|
||||
}
|
||||
}
|
||||
|
||||
public void shutdown() {
|
||||
configurationGeneration.incrementAndGet();
|
||||
cache.clear();
|
||||
pendingLookups.clear();
|
||||
executor.shutdownNow();
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
public void onPlayerJoin(PlayerJoinEvent event) {
|
||||
queueSync(event.getPlayer(), false);
|
||||
}
|
||||
|
||||
public SyncResult queueSync(Player player, boolean force) {
|
||||
if (!enabled) {
|
||||
return SyncResult.FEATURE_DISABLED;
|
||||
}
|
||||
if (gateway == null) {
|
||||
return SyncResult.DEPENDENCY_MISSING;
|
||||
}
|
||||
if (providers.isEmpty()) {
|
||||
return SyncResult.NO_PROVIDERS;
|
||||
}
|
||||
|
||||
UUID playerId = player.getUniqueId();
|
||||
if (force) {
|
||||
cache.remove(playerId);
|
||||
}
|
||||
|
||||
CachedLookup cached = cache.get(playerId);
|
||||
if (!force && cached != null && !cached.hasExpired()) {
|
||||
if (cached.skin() != null) {
|
||||
applySkin(playerId, cached, configurationGeneration.get());
|
||||
}
|
||||
return SyncResult.CACHED;
|
||||
}
|
||||
|
||||
long lookupGeneration = configurationGeneration.get();
|
||||
if (!registerPendingLookup(playerId, lookupGeneration)) {
|
||||
return SyncResult.ALREADY_RUNNING;
|
||||
}
|
||||
|
||||
String playerName = player.getName();
|
||||
Bukkit.getScheduler().runTaskLater(plugin, () -> startLookup(playerId, playerName, lookupGeneration), joinDelayTicks);
|
||||
sendPlayerNotification(playerId, "skin-bridge.notifications.detecting", Map.of());
|
||||
return SyncResult.QUEUED;
|
||||
}
|
||||
|
||||
public Status getStatus(Player player) {
|
||||
CachedLookup cached = cache.get(player.getUniqueId());
|
||||
if (cached != null && !cached.hasExpired()) {
|
||||
return new Status(cached.state(), cached.providerId());
|
||||
}
|
||||
if (pendingLookups.containsKey(player.getUniqueId())) {
|
||||
return new Status(State.PENDING, null);
|
||||
}
|
||||
return new Status(State.UNKNOWN, null);
|
||||
}
|
||||
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public boolean isSkinGatewayAvailable() {
|
||||
return gateway != null;
|
||||
}
|
||||
|
||||
public int getProviderCount() {
|
||||
return providers.size();
|
||||
}
|
||||
|
||||
public String getModuleDetail() {
|
||||
if (!enabled) {
|
||||
return "配置未启用";
|
||||
}
|
||||
if (gateway == null) {
|
||||
return "缺少 MineSkin API Key 或配置无效";
|
||||
}
|
||||
if (providers.isEmpty()) {
|
||||
return "未配置有效 Provider";
|
||||
}
|
||||
return providers.size() + " 个 Provider 已就绪";
|
||||
}
|
||||
|
||||
private void startLookup(UUID playerId, String playerName, long lookupGeneration) {
|
||||
if (executor.isShutdown() || lookupGeneration != configurationGeneration.get()) {
|
||||
pendingLookups.remove(playerId, lookupGeneration);
|
||||
return;
|
||||
}
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
CachedLookup resolved = resolve(playerId, playerName);
|
||||
if (lookupGeneration != configurationGeneration.get()) {
|
||||
return;
|
||||
}
|
||||
cache.put(playerId, resolved);
|
||||
if (resolved.skin() != null) {
|
||||
applySkin(playerId, resolved, lookupGeneration);
|
||||
} else {
|
||||
sendPlayerNotification(playerId, "skin-bridge.notifications.not-external", Map.of());
|
||||
if (debug) {
|
||||
plugin.getLogger().info("SkinBridge 未识别到外置登录玩家: " + playerName);
|
||||
}
|
||||
}
|
||||
} catch (Exception exception) {
|
||||
plugin.getLogger().warning("SkinBridge 查询 " + playerName + " 的皮肤资料失败: " + exception.getMessage());
|
||||
sendPlayerNotification(playerId, "skin-bridge.notifications.failed", Map.of());
|
||||
if (debug) {
|
||||
plugin.getLogger().warning("SkinBridge 异常类型: " + exception.getClass().getName());
|
||||
}
|
||||
} finally {
|
||||
pendingLookups.remove(playerId, lookupGeneration);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private boolean registerPendingLookup(UUID playerId, long lookupGeneration) {
|
||||
while (true) {
|
||||
Long runningGeneration = pendingLookups.putIfAbsent(playerId, lookupGeneration);
|
||||
if (runningGeneration == null) {
|
||||
return true;
|
||||
}
|
||||
if (runningGeneration == lookupGeneration) {
|
||||
return false;
|
||||
}
|
||||
if (pendingLookups.replace(playerId, runningGeneration, lookupGeneration)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private CachedLookup resolve(UUID playerId, String playerName) throws Exception {
|
||||
Exception lastFailure = null;
|
||||
for (SkinProvider provider : providers) {
|
||||
Optional<ProviderProfile> profile;
|
||||
try {
|
||||
profile = queryProfile(provider, playerId, playerName);
|
||||
} catch (Exception exception) {
|
||||
lastFailure = exception;
|
||||
plugin.getLogger().warning("SkinBridge Provider " + provider.id() + " 查询失败: " + exception.getMessage());
|
||||
continue;
|
||||
}
|
||||
if (profile.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
SkinBridgeGateway currentGateway = gateway;
|
||||
if (currentGateway == null) {
|
||||
throw new IllegalStateException("MineSkin 网关在查询期间不可用。");
|
||||
}
|
||||
|
||||
ProviderProfile matchedProfile = profile.get();
|
||||
GeneratedSkin generatedSkin = currentGateway.generateSkin(matchedProfile.skinUrl(), matchedProfile.model());
|
||||
return cached(provider.id(), generatedSkin, State.EXTERNAL);
|
||||
}
|
||||
|
||||
if (lastFailure != null) {
|
||||
throw new IllegalStateException("所有可用 Provider 均未能完成确认。", lastFailure);
|
||||
}
|
||||
return cached(null, null, State.NOT_EXTERNAL);
|
||||
}
|
||||
|
||||
private Optional<ProviderProfile> queryProfile(SkinProvider provider, UUID playerId, String playerName) throws Exception {
|
||||
URI requestUri = URI.create(provider.resolveProfileUrl(playerId));
|
||||
HttpRequest request = HttpRequest.newBuilder(requestUri)
|
||||
.timeout(Duration.ofSeconds(requestTimeoutSeconds))
|
||||
.header("Accept", "application/json")
|
||||
.header("User-Agent", "EssentialsC/" + plugin.getDescription().getVersion() + " SkinBridge")
|
||||
.GET()
|
||||
.build();
|
||||
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||
|
||||
if (response.statusCode() == 204 || response.statusCode() == 404) {
|
||||
return Optional.empty();
|
||||
}
|
||||
if (response.statusCode() != 200) {
|
||||
throw new IllegalStateException(provider.id() + " 返回 HTTP " + response.statusCode());
|
||||
}
|
||||
if (response.body().length() > 1_048_576) {
|
||||
throw new IllegalStateException(provider.id() + " 返回的 profile 超过 1 MiB 限制。");
|
||||
}
|
||||
|
||||
JsonObject profile = JsonParser.parseString(response.body()).getAsJsonObject();
|
||||
String profileId = requireString(profile, "id");
|
||||
String profileName = requireString(profile, "name");
|
||||
if (!normalizeUuid(profileId).equals(normalizeUuid(playerId.toString())) || !profileName.equalsIgnoreCase(playerName)) {
|
||||
if (debug) {
|
||||
plugin.getLogger().warning("SkinBridge 忽略 " + provider.id() + " 的不匹配 profile: " + profileName + " / " + profileId);
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
JsonObject textureData = findTextureData(profile);
|
||||
JsonObject skin = textureData.getAsJsonObject("textures").getAsJsonObject("SKIN");
|
||||
String skinUrl = requireString(skin, "url");
|
||||
URI skinUri = URI.create(skinUrl);
|
||||
if (!"https".equalsIgnoreCase(skinUri.getScheme()) && !"http".equalsIgnoreCase(skinUri.getScheme())) {
|
||||
throw new IllegalStateException(provider.id() + " 返回了不支持的皮肤 URL 协议。");
|
||||
}
|
||||
|
||||
SkinModel model = SkinModel.CLASSIC;
|
||||
JsonObject metadata = skin.has("metadata") && skin.get("metadata").isJsonObject()
|
||||
? skin.getAsJsonObject("metadata")
|
||||
: null;
|
||||
if (metadata != null && "slim".equalsIgnoreCase(metadata.has("model") ? metadata.get("model").getAsString() : "")) {
|
||||
model = SkinModel.SLIM;
|
||||
}
|
||||
return Optional.of(new ProviderProfile(skinUrl, model));
|
||||
}
|
||||
|
||||
private JsonObject findTextureData(JsonObject profile) {
|
||||
JsonArray properties = profile.has("properties") && profile.get("properties").isJsonArray()
|
||||
? profile.getAsJsonArray("properties")
|
||||
: new JsonArray();
|
||||
for (JsonElement element : properties) {
|
||||
if (!element.isJsonObject()) {
|
||||
continue;
|
||||
}
|
||||
JsonObject property = element.getAsJsonObject();
|
||||
if (!"textures".equals(property.has("name") ? property.get("name").getAsString() : "")) {
|
||||
continue;
|
||||
}
|
||||
String encodedValue = requireString(property, "value");
|
||||
String decodedValue = new String(Base64.getDecoder().decode(encodedValue), StandardCharsets.UTF_8);
|
||||
JsonObject textureData = JsonParser.parseString(decodedValue).getAsJsonObject();
|
||||
if (textureData.has("textures")
|
||||
&& textureData.get("textures").isJsonObject()
|
||||
&& textureData.getAsJsonObject("textures").has("SKIN")
|
||||
&& textureData.getAsJsonObject("textures").get("SKIN").isJsonObject()) {
|
||||
return textureData;
|
||||
}
|
||||
}
|
||||
throw new IllegalStateException("profile 不包含有效的皮肤 textures 属性。");
|
||||
}
|
||||
|
||||
private List<SkinProvider> loadProviders(FileConfiguration config) {
|
||||
ConfigurationSection providersSection = config.getConfigurationSection("skin-bridge.providers");
|
||||
if (providersSection == null) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
List<SkinProvider> loadedProviders = new ArrayList<>();
|
||||
for (String key : providersSection.getKeys(false)) {
|
||||
ConfigurationSection providerSection = providersSection.getConfigurationSection(key);
|
||||
if (providerSection == null || !providerSection.getBoolean("enabled", false)) {
|
||||
continue;
|
||||
}
|
||||
String profileUrl = providerSection.getString("profile-url", "").trim();
|
||||
if (!profileUrl.contains("{uuid}") && !profileUrl.contains("{uuid-dashed}")) {
|
||||
plugin.getLogger().warning("SkinBridge Provider " + key + " 缺少 {uuid} 或 {uuid-dashed} 占位符,已跳过。");
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
URI.create(profileUrl.replace("{uuid}", "00000000000000000000000000000000")
|
||||
.replace("{uuid-dashed}", "00000000-0000-0000-0000-000000000000"));
|
||||
loadedProviders.add(new SkinProvider(key, profileUrl, providerSection.getInt("priority", 100)));
|
||||
} catch (IllegalArgumentException exception) {
|
||||
plugin.getLogger().warning("SkinBridge Provider " + key + " 的 profile-url 无效,已跳过。");
|
||||
}
|
||||
}
|
||||
loadedProviders.sort(Comparator.comparingInt(SkinProvider::priority).thenComparing(SkinProvider::id));
|
||||
return List.copyOf(loadedProviders);
|
||||
}
|
||||
|
||||
private SkinBridgeGateway loadGateway(String endpoint, String apiKey, String visibility, int timeoutSeconds) {
|
||||
if (apiKey.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return new MineSkinGateway(httpClient, endpoint, apiKey, visibility, timeoutSeconds,
|
||||
"EssentialsC/" + plugin.getDescription().getVersion() + " SkinBridge");
|
||||
} catch (Exception | LinkageError exception) {
|
||||
plugin.getLogger().warning("加载 MineSkin SkinBridge 适配器失败: " + exception.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void addDefaults(FileConfiguration config) {
|
||||
config.addDefault("skin-bridge.enabled", false);
|
||||
config.addDefault("skin-bridge.debug", false);
|
||||
config.addDefault("skin-bridge.send-player-message", true);
|
||||
config.addDefault("skin-bridge.profile-request-timeout-seconds", 5);
|
||||
config.addDefault("skin-bridge.mineskin.endpoint", "https://api.mineskin.org");
|
||||
config.addDefault("skin-bridge.mineskin.api-key", "");
|
||||
config.addDefault("skin-bridge.mineskin.visibility", "unlisted");
|
||||
config.addDefault("skin-bridge.mineskin.request-timeout-seconds", 30);
|
||||
config.addDefault("skin-bridge.cache-minutes", 120);
|
||||
config.addDefault("skin-bridge.join-delay-ticks", 20);
|
||||
}
|
||||
|
||||
private void applySkin(UUID playerId, CachedLookup resolved, long lookupGeneration) {
|
||||
Bukkit.getScheduler().runTask(plugin, () -> {
|
||||
if (lookupGeneration != configurationGeneration.get()) {
|
||||
return;
|
||||
}
|
||||
Player player = Bukkit.getPlayer(playerId);
|
||||
SkinBridgeGateway currentGateway = gateway;
|
||||
if (player == null || !player.isOnline() || currentGateway == null || resolved.skin() == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
currentGateway.applySkin(player, resolved.skin());
|
||||
sendPlayerNotification(playerId, "skin-bridge.notifications.synced",
|
||||
Map.of("provider", resolved.providerId()));
|
||||
if (debug) {
|
||||
plugin.getLogger().info("SkinBridge 已应用 " + player.getName() + " 的 " + resolved.providerId() + " 皮肤。");
|
||||
}
|
||||
} catch (Exception exception) {
|
||||
plugin.getLogger().warning("SkinBridge 应用 " + player.getName() + " 的皮肤失败: " + exception.getMessage());
|
||||
sendPlayerNotification(playerId, "skin-bridge.notifications.failed", Map.of());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void sendPlayerNotification(UUID playerId, String messagePath, Map<String, String> placeholders) {
|
||||
if (!sendPlayerMessage) {
|
||||
return;
|
||||
}
|
||||
|
||||
Runnable notification = () -> {
|
||||
Player player = Bukkit.getPlayer(playerId);
|
||||
if (player != null && player.isOnline()) {
|
||||
player.sendMessage(EssentialsC.getLangManager().getPrefixedString(messagePath, placeholders));
|
||||
}
|
||||
};
|
||||
if (Bukkit.isPrimaryThread()) {
|
||||
notification.run();
|
||||
} else {
|
||||
Bukkit.getScheduler().runTask(plugin, notification);
|
||||
}
|
||||
}
|
||||
|
||||
private CachedLookup cached(String providerId, GeneratedSkin skin, State state) {
|
||||
return new CachedLookup(providerId, skin, state, System.currentTimeMillis() + Duration.ofMinutes(cacheMinutes).toMillis());
|
||||
}
|
||||
|
||||
private static String requireString(JsonObject object, String key) {
|
||||
if (!object.has(key) || !object.get(key).isJsonPrimitive()) {
|
||||
throw new IllegalStateException("缺少字符串字段: " + key);
|
||||
}
|
||||
String value = object.get(key).getAsString();
|
||||
if (value.isBlank()) {
|
||||
throw new IllegalStateException("字符串字段为空: " + key);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private static String normalizeUuid(String value) {
|
||||
return value.replace("-", "").toLowerCase(java.util.Locale.ROOT);
|
||||
}
|
||||
|
||||
private static int clamp(int value, int min, int max) {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
private static long clamp(long value, long min, long max) {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
private record SkinProvider(String id, String profileUrl, int priority) {
|
||||
private String resolveProfileUrl(UUID playerId) {
|
||||
return profileUrl.replace("{uuid}", playerId.toString().replace("-", ""))
|
||||
.replace("{uuid-dashed}", playerId.toString());
|
||||
}
|
||||
}
|
||||
|
||||
private record ProviderProfile(String skinUrl, SkinModel model) {
|
||||
}
|
||||
|
||||
private record CachedLookup(String providerId, GeneratedSkin skin, State state, long expiresAtMillis) {
|
||||
private boolean hasExpired() {
|
||||
return System.currentTimeMillis() >= expiresAtMillis;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class SkinBridgeThreadFactory implements ThreadFactory {
|
||||
@Override
|
||||
public Thread newThread(Runnable runnable) {
|
||||
Thread thread = new Thread(runnable, "EssentialsC-SkinBridge");
|
||||
thread.setDaemon(true);
|
||||
return thread;
|
||||
}
|
||||
}
|
||||
|
||||
public enum SyncResult {
|
||||
QUEUED,
|
||||
CACHED,
|
||||
ALREADY_RUNNING,
|
||||
FEATURE_DISABLED,
|
||||
DEPENDENCY_MISSING,
|
||||
NO_PROVIDERS
|
||||
}
|
||||
|
||||
public record Status(State state, String providerId) {
|
||||
}
|
||||
|
||||
public enum State {
|
||||
EXTERNAL,
|
||||
NOT_EXTERNAL,
|
||||
PENDING,
|
||||
UNKNOWN
|
||||
}
|
||||
}
|
||||
@@ -75,6 +75,43 @@ tpa:
|
||||
# 传送完成时播放的音效。
|
||||
complete: "entity.enderman.teleport"
|
||||
|
||||
skin-bridge:
|
||||
# 是否启用外置皮肤站桥接。还需要在 modules.yml 启用 skin-bridge 模块。
|
||||
enabled: false
|
||||
# 是否输出 Provider 查询和皮肤应用的调试日志。
|
||||
debug: false
|
||||
# 是否像 JEI 修复一样向玩家发送检测与同步提示。
|
||||
send-player-message: true
|
||||
# 查询 Yggdrasil profile 的超时时间,单位为秒。
|
||||
profile-request-timeout-seconds: 5
|
||||
mineskin:
|
||||
# MineSkin v2 API 地址,一般不需要修改。
|
||||
endpoint: "https://api.mineskin.org"
|
||||
# MineSkin API Key。请仅在服务器运行目录中填写,不要提交到公开仓库。
|
||||
api-key: ""
|
||||
# 生成结果的可见性:public、unlisted 或 private。默认使用 unlisted。
|
||||
visibility: "unlisted"
|
||||
# MineSkin 生成队列的总等待时间,单位为秒。
|
||||
request-timeout-seconds: 30
|
||||
# 玩家登录来源与生成皮肤的内存缓存时间,单位为分钟。
|
||||
cache-minutes: 120
|
||||
# 玩家加入后延迟多少 tick 再查询,避免登录资料尚未完全稳定。
|
||||
join-delay-ticks: 20
|
||||
providers:
|
||||
# 每个子节点都是一个 Provider,键名可自由命名,并会显示在日志和 /essc skin status 中。
|
||||
# 建议使用小写英文、数字和连字符;不要使用点号,因为点号会被 Bukkit 视为配置路径分隔符。
|
||||
littleskin:
|
||||
# LittleSkin 示例 Provider。键名 littleskin 可按需改名。
|
||||
enabled: false
|
||||
priority: 10
|
||||
# {uuid} 为无连字符 UUID;{uuid-dashed} 为标准 UUID。
|
||||
profile-url: "https://littleskin.cn/api/yggdrasil/sessionserver/session/minecraft/profile/{uuid}?unsigned=false"
|
||||
my-blessing-skin:
|
||||
# 自建 Blessing Skin 示例 Provider。my-blessing-skin 可替换为皮肤站名称。
|
||||
enabled: false
|
||||
priority: 20
|
||||
profile-url: "https://skin.example.com/api/yggdrasil/sessionserver/session/minecraft/profile/{uuid}?unsigned=false"
|
||||
|
||||
blocks-menu:
|
||||
# 菜单布局版本,用于后续自动迁移槽位布局
|
||||
layout-version: 2
|
||||
|
||||
@@ -88,6 +88,7 @@ help:
|
||||
tpaccept: " &f/tpaccept [player] &7- Accept a teleport request"
|
||||
tpdeny: " &f/tpdeny [player] &7- Deny a teleport request"
|
||||
tpignore: " &f/tpignore &7- Toggle ignoring teleport requests"
|
||||
skin: " &f/essc skin <status|refresh> [player] &7- View or refresh SkinBridge status"
|
||||
admin: " &f/essc admin &7- Toggle admin mode"
|
||||
tpsbar: " &f/tpsbar [player] &7- Toggle TPS boss bar"
|
||||
maintenance: " &f/maintenance <on|off|status|reload|add|remove|list> &7- Manage maintenance mode"
|
||||
@@ -132,6 +133,25 @@ tpa:
|
||||
tpaall-sent: "&aSent teleport requests to all players."
|
||||
tpaall-no-targets: "&cThere are no online players who can receive the request."
|
||||
|
||||
skin-bridge:
|
||||
notifications:
|
||||
detecting: "&6SKIN-FIX&8: &eDetecting your skin source..."
|
||||
synced: "&6SKIN-FIX&8(&b{provider}&8): &aSkin synchronization completed."
|
||||
not-external: "&6SKIN-FIX&8: &7No external skin provider matched; your current skin was kept."
|
||||
failed: "&6SKIN-FIX&8: &cSkin detection or synchronization failed. Please try again later."
|
||||
messages:
|
||||
usage: "&cUsage: /essc skin <status|refresh> [player]"
|
||||
feature-disabled: "&cSkinBridge is disabled in config.yml."
|
||||
dependency-missing: "&cNo valid MineSkin API key is configured in config.yml."
|
||||
no-providers: "&cNo valid SkinBridge provider is enabled."
|
||||
status-external: "&a{player} is identified as an external skin provider player. Provider: &f{provider}"
|
||||
status-not-external: "&7{player} does not match any configured external skin provider."
|
||||
status-pending: "&eSkinBridge is still detecting {player}'s login source."
|
||||
status-unknown: "&7{player} has not been checked by SkinBridge yet."
|
||||
refresh-queued: "&aSkinBridge is detecting and refreshing {player}'s skin."
|
||||
refresh-cached: "&7{player} is using a valid skin cache entry."
|
||||
refresh-running: "&eSkinBridge is already processing {player}."
|
||||
|
||||
blocks-menu:
|
||||
title: "&6&lEssentialsC &8- &e&lShortcut Menu"
|
||||
items:
|
||||
|
||||
@@ -88,6 +88,7 @@ help:
|
||||
tpaccept: " &f/tpaccept [玩家] &7- 接受传送请求"
|
||||
tpdeny: " &f/tpdeny [玩家] &7- 拒绝传送请求"
|
||||
tpignore: " &f/tpignore &7- 切换是否忽略传送请求"
|
||||
skin: " &f/essc skin <status|refresh> [玩家] &7- 查看或刷新皮肤桥接状态"
|
||||
admin: " &f/essc admin &7- 切换管理模式"
|
||||
tpsbar: " &f/tpsbar [玩家] &7- 切换 TPS 状态栏"
|
||||
maintenance: " &f/maintenance <on|off|status|reload|add|remove|list> &7- 管理维护模式"
|
||||
@@ -132,6 +133,25 @@ tpa:
|
||||
tpaall-sent: "&a已向所有玩家发送传送请求。"
|
||||
tpaall-no-targets: "&c没有可接收请求的在线玩家。"
|
||||
|
||||
skin-bridge:
|
||||
notifications:
|
||||
detecting: "&6SKIN-FIX&8: &e正在检测皮肤来源..."
|
||||
synced: "&6SKIN-FIX&8(&b{provider}&8): &a皮肤同步完成。"
|
||||
not-external: "&6SKIN-FIX&8: &7未匹配外置皮肤站,已保留当前皮肤。"
|
||||
failed: "&6SKIN-FIX&8: &c皮肤检测或同步失败,请稍后重试。"
|
||||
messages:
|
||||
usage: "&c用法:/essc skin <status|refresh> [玩家]"
|
||||
feature-disabled: "&cSkinBridge 在 config.yml 中未启用。"
|
||||
dependency-missing: "&c未在 config.yml 中配置有效的 MineSkin API Key。"
|
||||
no-providers: "&c没有启用有效的 SkinBridge Provider。"
|
||||
status-external: "&a{player} 已识别为外置皮肤站玩家,Provider:&f{provider}"
|
||||
status-not-external: "&7{player} 未匹配任何已配置的外置皮肤站。"
|
||||
status-pending: "&e{player} 的皮肤站来源正在检测中。"
|
||||
status-unknown: "&7{player} 尚未进行 SkinBridge 检测。"
|
||||
refresh-queued: "&a已开始重新检测并刷新 {player} 的皮肤。"
|
||||
refresh-cached: "&7{player} 正在使用有效的皮肤缓存。"
|
||||
refresh-running: "&e{player} 的皮肤检测任务仍在运行。"
|
||||
|
||||
blocks-menu:
|
||||
title: "&6&lEssentialsC &8- &e&l便捷菜单"
|
||||
items:
|
||||
|
||||
@@ -30,3 +30,6 @@ modules:
|
||||
maintenance:
|
||||
# 维护模式。开启维护后可替换 MOTD,并阻止无绕过权限的玩家进入。
|
||||
enabled: true
|
||||
skin-bridge:
|
||||
# 查询外置 Yggdrasil profile,并通过 MineSkin 与 Paper Profile API 同步皮肤。
|
||||
enabled: false
|
||||
|
||||
@@ -119,6 +119,9 @@ permissions:
|
||||
essentialsc.command.maintenance:
|
||||
description: 允许管理维护模式
|
||||
default: op
|
||||
essentialsc.command.skin:
|
||||
description: 允许管理 SkinBridge 状态与刷新
|
||||
default: op
|
||||
essentialsc.maintenance.bypass:
|
||||
description: 允许在维护模式下进入服务器
|
||||
default: op
|
||||
@@ -171,6 +174,7 @@ permissions:
|
||||
essentialsc.command.tpsbar: true
|
||||
essentialsc.command.tpsbar.others: true
|
||||
essentialsc.command.maintenance: true
|
||||
essentialsc.command.skin: true
|
||||
essentialsc.maintenance.bypass: true
|
||||
essentialsc.maintenance.notify: true
|
||||
essentialsc.shulkerbox.open: true
|
||||
|
||||
Reference in New Issue
Block a user