From 6c4cb88138d132e827153346654b2cd13dd724db Mon Sep 17 00:00:00 2001 From: Arbousier1 Date: Sat, 4 Jul 2026 22:19:20 +0800 Subject: [PATCH 1/4] fix: forward Paper chat events with CarbonChat fallback --- build.gradle | 1 + .../astrbotadapter/AstrbotAdapterBukkit.java | 5 + .../bukkit/BukkitBootstrapSupport.java | 40 +++++++- .../bukkit/listener/CarbonChatBridge.java | 75 +++++++++++++- .../listener/PaperAsyncChatListener.java | 99 +++++++++++++++++++ 5 files changed, 218 insertions(+), 2 deletions(-) create mode 100644 src/main/java/io/github/railgun19457/astrbotadapter/platform/bukkit/listener/PaperAsyncChatListener.java diff --git a/build.gradle b/build.gradle index d1824a5..2b3344b 100644 --- a/build.gradle +++ b/build.gradle @@ -34,6 +34,7 @@ def referencedLibraryDependencies = nettyModules.collect { "io.netty:${it}:${net def commonCompileOnlyDependencies = [ "net.kyori:adventure-api:${adventureVersion}", + "net.kyori:adventure-text-serializer-plain:${adventureVersion}", "net.kyori:adventure-text-minimessage:${adventureVersion}", "org.jetbrains:annotations:26.1.0", ] diff --git a/src/main/java/io/github/railgun19457/astrbotadapter/AstrbotAdapterBukkit.java b/src/main/java/io/github/railgun19457/astrbotadapter/AstrbotAdapterBukkit.java index ee220a1..92f1027 100644 --- a/src/main/java/io/github/railgun19457/astrbotadapter/AstrbotAdapterBukkit.java +++ b/src/main/java/io/github/railgun19457/astrbotadapter/AstrbotAdapterBukkit.java @@ -44,6 +44,11 @@ public void onEnable() { registerMineSentinelListener(); carbonChatBridge = BukkitBootstrapSupport.registerCarbonChatBridge(this, plugin, chatHandler); + BukkitBootstrapSupport.registerPaperAsyncChatListener( + this, + plugin, + chatHandler, + () -> carbonChatBridge != null && carbonChatBridge.isRegistered()); } @Override diff --git a/src/main/java/io/github/railgun19457/astrbotadapter/platform/bukkit/BukkitBootstrapSupport.java b/src/main/java/io/github/railgun19457/astrbotadapter/platform/bukkit/BukkitBootstrapSupport.java index 837f841..a6122dc 100644 --- a/src/main/java/io/github/railgun19457/astrbotadapter/platform/bukkit/BukkitBootstrapSupport.java +++ b/src/main/java/io/github/railgun19457/astrbotadapter/platform/bukkit/BukkitBootstrapSupport.java @@ -9,10 +9,13 @@ import io.github.railgun19457.astrbotadapter.platform.bukkit.listener.BukkitProxyChatListener; import io.github.railgun19457.astrbotadapter.platform.bukkit.listener.BukkitProxyPlayerListener; import io.github.railgun19457.astrbotadapter.platform.bukkit.listener.CarbonChatBridge; +import io.github.railgun19457.astrbotadapter.platform.bukkit.listener.PaperAsyncChatListener; import io.github.railgun19457.astrbotadapter.platform.bukkit.listener.PlayerChatHandler; import org.bukkit.command.PluginCommand; import org.bukkit.plugin.java.JavaPlugin; +import java.util.function.BooleanSupplier; + /** * Shared bootstrap helpers for Bukkit-family platforms. */ @@ -78,7 +81,42 @@ public static CarbonChatBridge registerCarbonChatBridge( chatHandler, plugin.getMineSentinelService(), plugin.getPlatformAdapter().getPlatformType().supportsRegionScheduling()); - return bridge.register() ? bridge : null; + if (!bridge.register()) { + bridge.startRetrying(plugin.getPlatformAdapter().getScheduler()); + } + return bridge; + } + + public static void registerPaperAsyncChatListener( + JavaPlugin javaPlugin, + AstrbotAdapterPlugin plugin, + PlayerChatHandler chatHandler, + BooleanSupplier carbonBridgeActive + ) { + if (chatHandler == null || plugin == null || plugin.getPlatformAdapter() == null) { + return; + } + if (!hasPaperAsyncChatEvent()) { + return; + } + + javaPlugin.getServer().getPluginManager().registerEvents( + new PaperAsyncChatListener( + chatHandler, + plugin.getMineSentinelService(), + carbonBridgeActive, + plugin.getPlatformAdapter().getPlatformType().supportsRegionScheduling()), + javaPlugin); + javaPlugin.getLogger().info("Paper AsyncChatEvent fallback listener enabled"); + } + + private static boolean hasPaperAsyncChatEvent() { + try { + Class.forName("io.papermc.paper.event.player.AsyncChatEvent"); + return true; + } catch (ClassNotFoundException ex) { + return false; + } } public static final class ProxyModeContext { diff --git a/src/main/java/io/github/railgun19457/astrbotadapter/platform/bukkit/listener/CarbonChatBridge.java b/src/main/java/io/github/railgun19457/astrbotadapter/platform/bukkit/listener/CarbonChatBridge.java index 1c492c3..ee66d2c 100644 --- a/src/main/java/io/github/railgun19457/astrbotadapter/platform/bukkit/listener/CarbonChatBridge.java +++ b/src/main/java/io/github/railgun19457/astrbotadapter/platform/bukkit/listener/CarbonChatBridge.java @@ -2,6 +2,7 @@ import com.google.gson.JsonObject; import io.github.railgun19457.astrbotadapter.minesentinel.service.MineSentinelService; +import io.github.railgun19457.astrbotadapter.platform.common.CommonScheduler; import org.bukkit.Bukkit; import org.bukkit.Location; import org.bukkit.World; @@ -23,6 +24,9 @@ public final class CarbonChatBridge implements AutoCloseable { private static final String[] CARBON_PLUGIN_NAMES = {"CarbonChat", "Carbon"}; private static final int SUBSCRIBE_ORDER = 100; + private static final int MAX_RETRY_ATTEMPTS = 30; + private static final long RETRY_DELAY_TICKS = 20L; + private static final long RETRY_PERIOD_TICKS = 20L; private final JavaPlugin plugin; private final PlayerChatHandler chatHandler; @@ -36,6 +40,10 @@ public final class CarbonChatBridge implements AutoCloseable { private Method eventMessageMethod; private Method eventCancelledSetter; private Method playerUuidMethod; + private CommonScheduler retryScheduler; + private int retryTaskId = -1; + private int retryAttempts = 0; + private volatile boolean registered = false; public CarbonChatBridge( JavaPlugin plugin, @@ -51,6 +59,9 @@ public CarbonChatBridge( } public boolean register() { + if (registered) { + return true; + } try { Class providerClass = loadCarbonClass("net.draycia.carbon.api.CarbonChatProvider"); Class carbonChatClass = loadCarbonClass("net.draycia.carbon.api.CarbonChat"); @@ -83,9 +94,12 @@ public boolean register() { false, subscriber); + registered = true; + stopRetrying(); logger.info("CarbonChat chat bridge enabled"); return true; } catch (ClassNotFoundException ex) { + logger.fine("CarbonChat bridge classes are not ready: " + ex.getMessage()); return false; } catch (InvocationTargetException ex) { Throwable cause = ex.getCause() == null ? ex : ex.getCause(); @@ -97,9 +111,41 @@ public boolean register() { } } + public void startRetrying(CommonScheduler scheduler) { + if (registered || retryTaskId > 0 || scheduler == null || !isCarbonPluginPresent()) { + return; + } + + this.retryScheduler = scheduler; + logger.info("CarbonChat detected but chat bridge is not ready yet; retrying registration..."); + retryTaskId = scheduler.runTimer(() -> { + if (registered) { + stopRetrying(); + return; + } + + retryAttempts++; + if (register()) { + return; + } + + if (retryAttempts >= MAX_RETRY_ATTEMPTS) { + logger.warning("CarbonChat chat bridge could not be enabled after retries; " + + "falling back to Paper AsyncChatEvent forwarding when available"); + stopRetrying(); + } + }, RETRY_DELAY_TICKS, RETRY_PERIOD_TICKS); + } + + public boolean isRegistered() { + return registered; + } + @Override public void close() { if (subscription == null || disposeMethod == null) { + stopRetrying(); + registered = false; return; } @@ -110,9 +156,20 @@ public void close() { logger.warning("CarbonChat bridge disposal failed: " + ex.getMessage()); } finally { subscription = null; + registered = false; + stopRetrying(); } } + private void stopRetrying() { + if (retryTaskId > 0 && retryScheduler != null) { + retryScheduler.cancelTask(retryTaskId); + } + retryTaskId = -1; + retryAttempts = 0; + retryScheduler = null; + } + private InvocationHandler createSubscriberHandler() { return (proxy, method, args) -> { if ("on".equals(method.getName()) && args != null && args.length == 1) { @@ -196,10 +253,26 @@ private Class loadCarbonClass(String name) throws ClassNotFoundException { } private ClassLoader findCarbonClassLoader() { + Plugin carbonPlugin = findCarbonPlugin(); + return carbonPlugin == null ? null : carbonPlugin.getClass().getClassLoader(); + } + + private boolean isCarbonPluginPresent() { + return findCarbonPlugin() != null; + } + + private Plugin findCarbonPlugin() { for (String pluginName : CARBON_PLUGIN_NAMES) { Plugin carbonPlugin = plugin.getServer().getPluginManager().getPlugin(pluginName); if (carbonPlugin != null) { - return carbonPlugin.getClass().getClassLoader(); + return carbonPlugin; + } + } + + for (Plugin candidate : plugin.getServer().getPluginManager().getPlugins()) { + String name = candidate.getName(); + if (name != null && (name.equalsIgnoreCase("CarbonChat") || name.equalsIgnoreCase("Carbon"))) { + return candidate; } } return null; diff --git a/src/main/java/io/github/railgun19457/astrbotadapter/platform/bukkit/listener/PaperAsyncChatListener.java b/src/main/java/io/github/railgun19457/astrbotadapter/platform/bukkit/listener/PaperAsyncChatListener.java new file mode 100644 index 0000000..61ddae3 --- /dev/null +++ b/src/main/java/io/github/railgun19457/astrbotadapter/platform/bukkit/listener/PaperAsyncChatListener.java @@ -0,0 +1,99 @@ +package io.github.railgun19457.astrbotadapter.platform.bukkit.listener; + +import com.google.gson.JsonObject; +import io.github.railgun19457.astrbotadapter.minesentinel.service.MineSentinelService; +import io.papermc.paper.event.player.AsyncChatEvent; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; + +import java.util.function.BooleanSupplier; + +/** + * Fallback listener for Paper's modern chat pipeline. + * + * CarbonChat emits its own early chat event, which is preferred because it can + * cancel private AI chat cleanly. If that bridge is unavailable, this listener + * still forwards/observes delivered Paper chat messages. + */ +public final class PaperAsyncChatListener implements Listener { + + private final PlayerChatHandler chatHandler; + private final MineSentinelService mineSentinelService; + private final BooleanSupplier carbonBridgeActive; + private final boolean foliaMode; + + public PaperAsyncChatListener( + PlayerChatHandler chatHandler, + MineSentinelService mineSentinelService, + BooleanSupplier carbonBridgeActive, + boolean foliaMode + ) { + this.chatHandler = chatHandler; + this.mineSentinelService = mineSentinelService; + this.carbonBridgeActive = carbonBridgeActive; + this.foliaMode = foliaMode; + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onAsyncChat(AsyncChatEvent event) { + if (carbonBridgeActive != null && carbonBridgeActive.getAsBoolean()) { + return; + } + + Player player = event.getPlayer(); + String message = PlainTextComponentSerializer.plainText().serialize(event.message()); + if (player == null || message == null || message.isEmpty()) { + return; + } + + if (chatHandler != null) { + chatHandler.handlePlayerChat(player, message); + } + observeMineSentinel(player, message); + } + + private void observeMineSentinel(Player player, String message) { + if (mineSentinelService == null || !mineSentinelService.isEnabled()) { + return; + } + + mineSentinelService.observeChat( + player.getUniqueId(), + player.getName(), + player.getDisplayName(), + message, + "", + buildPlayerContext(player)); + } + + private JsonObject buildPlayerContext(Player player) { + JsonObject context = new JsonObject(); + context.addProperty("source", "paper-async-chat"); + context.addProperty("messageType", "PUBLIC_CHAT"); + if (foliaMode) { + return context; + } + + try { + World world = player.getWorld(); + if (world != null) { + context.addProperty("world", world.getName()); + context.addProperty("dimension", world.getEnvironment().name()); + } + if (mineSentinelService != null && mineSentinelService.shouldIncludePositionContext()) { + Location location = player.getLocation(); + context.addProperty("blockX", location.getBlockX()); + context.addProperty("blockY", location.getBlockY()); + context.addProperty("blockZ", location.getBlockZ()); + } + } catch (Exception ignored) { + // Observation context is best-effort and must not affect gameplay. + } + return context; + } +} From 15015b27a6e05bccbdb96f4765a1198e34c554e2 Mon Sep 17 00:00:00 2001 From: Arbousier1 Date: Sat, 4 Jul 2026 22:32:43 +0800 Subject: [PATCH 2/4] fix: support CarbonChat on Velocity --- .../AstrbotAdapterVelocity.java | 27 +- .../MineSentinelVelocityListener.java | 14 + .../listener/VelocityCarbonChatBridge.java | 371 ++++++++++++++++++ 3 files changed, 409 insertions(+), 3 deletions(-) create mode 100644 src/main/java/io/github/railgun19457/astrbotadapter/platform/velocity/listener/VelocityCarbonChatBridge.java diff --git a/src/main/java/io/github/railgun19457/astrbotadapter/AstrbotAdapterVelocity.java b/src/main/java/io/github/railgun19457/astrbotadapter/AstrbotAdapterVelocity.java index 611ab47..296d002 100644 --- a/src/main/java/io/github/railgun19457/astrbotadapter/AstrbotAdapterVelocity.java +++ b/src/main/java/io/github/railgun19457/astrbotadapter/AstrbotAdapterVelocity.java @@ -16,6 +16,7 @@ import io.github.railgun19457.astrbotadapter.core.util.JsonUtil; import io.github.railgun19457.astrbotadapter.platform.velocity.VelocityAdapter; import io.github.railgun19457.astrbotadapter.platform.velocity.listener.MineSentinelVelocityListener; +import io.github.railgun19457.astrbotadapter.platform.velocity.listener.VelocityCarbonChatBridge; import io.github.railgun19457.astrbotadapter.platform.velocity.listener.VelocityPlayerListener; import org.slf4j.Logger; @@ -39,6 +40,7 @@ public class AstrbotAdapterVelocity extends AstrbotAdapterPlugin { private final Logger velocityLogger; private final Path dataDirectory; private VelocityProxyBridge proxyBridge; + private VelocityCarbonChatBridge carbonChatBridge; @Inject public AstrbotAdapterVelocity(ProxyServer proxy, Logger logger, @DataDirectory Path dataDirectory) { @@ -62,6 +64,10 @@ public void onProxyInitialize(ProxyInitializeEvent event) { @Subscribe public void onProxyShutdown(ProxyShutdownEvent event) { + if (carbonChatBridge != null) { + carbonChatBridge.close(); + carbonChatBridge = null; + } if (proxyBridge != null) { proxyBridge.shutdown(); } @@ -216,20 +222,35 @@ private static String getDisplayName(JsonObject data) { * 注册Velocity事件监听器 */ private void registerVelocityListeners() { - // Chat forwarding is handled via PMC from backend servers (handleProxyBridgeEvent), - // so no VelocityChatListener is needed here. + registerCarbonChatBridge(); // 注册玩家监听器 proxy.getEventManager().register(this, new VelocityPlayerListener(this, proxy, notificationService)); if (mineSentinelService != null && mineSentinelService.isEnabled()) { - proxy.getEventManager().register(this, new MineSentinelVelocityListener(mineSentinelService)); + proxy.getEventManager().register(this, new MineSentinelVelocityListener( + mineSentinelService, + () -> carbonChatBridge != null && carbonChatBridge.isRegistered())); } logger.info("Velocity事件监听器已注册"); } + private void registerCarbonChatBridge() { + carbonChatBridge = new VelocityCarbonChatBridge( + this, + proxy, + chatService, + messageForwardService, + mineSentinelService, + configManager.getConfig(), + logger); + if (!carbonChatBridge.register()) { + carbonChatBridge.startRetrying(platformAdapter.getScheduler()); + } + } + /** * Get the proxy bridge (only available when proxy bridge is enabled). */ diff --git a/src/main/java/io/github/railgun19457/astrbotadapter/platform/velocity/listener/MineSentinelVelocityListener.java b/src/main/java/io/github/railgun19457/astrbotadapter/platform/velocity/listener/MineSentinelVelocityListener.java index 7c1c794..266313d 100644 --- a/src/main/java/io/github/railgun19457/astrbotadapter/platform/velocity/listener/MineSentinelVelocityListener.java +++ b/src/main/java/io/github/railgun19457/astrbotadapter/platform/velocity/listener/MineSentinelVelocityListener.java @@ -10,15 +10,26 @@ import io.github.railgun19457.astrbotadapter.minesentinel.model.ObservationKind; import io.github.railgun19457.astrbotadapter.minesentinel.service.MineSentinelService; +import java.util.function.BooleanSupplier; + /** * Read-only MineSentinel listener for Velocity proxy-level events. */ public class MineSentinelVelocityListener { private final MineSentinelService mineSentinelService; + private final BooleanSupplier carbonBridgeActive; public MineSentinelVelocityListener(MineSentinelService mineSentinelService) { + this(mineSentinelService, null); + } + + public MineSentinelVelocityListener( + MineSentinelService mineSentinelService, + BooleanSupplier carbonBridgeActive + ) { this.mineSentinelService = mineSentinelService; + this.carbonBridgeActive = carbonBridgeActive; } @Subscribe @@ -56,6 +67,9 @@ public void onPlayerChat(PlayerChatEvent event) { if (mineSentinelService == null || !mineSentinelService.isEnabled()) { return; } + if (carbonBridgeActive != null && carbonBridgeActive.getAsBoolean()) { + return; + } Player player = event.getPlayer(); String backend = currentBackend(player); mineSentinelService.observeChat( diff --git a/src/main/java/io/github/railgun19457/astrbotadapter/platform/velocity/listener/VelocityCarbonChatBridge.java b/src/main/java/io/github/railgun19457/astrbotadapter/platform/velocity/listener/VelocityCarbonChatBridge.java new file mode 100644 index 0000000..42f5300 --- /dev/null +++ b/src/main/java/io/github/railgun19457/astrbotadapter/platform/velocity/listener/VelocityCarbonChatBridge.java @@ -0,0 +1,371 @@ +package io.github.railgun19457.astrbotadapter.platform.velocity.listener; + +import com.google.gson.JsonObject; +import com.velocitypowered.api.plugin.PluginContainer; +import com.velocitypowered.api.proxy.Player; +import com.velocitypowered.api.proxy.ProxyServer; +import io.github.railgun19457.astrbotadapter.core.config.PluginConfig; +import io.github.railgun19457.astrbotadapter.core.util.PlaceholderUtil; +import io.github.railgun19457.astrbotadapter.minesentinel.service.MineSentinelService; +import io.github.railgun19457.astrbotadapter.platform.common.CommonScheduler; +import io.github.railgun19457.astrbotadapter.service.chat.ChatMode; +import io.github.railgun19457.astrbotadapter.service.chat.ChatService; +import io.github.railgun19457.astrbotadapter.service.forward.MessageForwardService; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; + +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.util.Optional; +import java.util.UUID; +import java.util.logging.Logger; + +/** + * Runtime bridge for CarbonChat on Velocity without requiring CarbonChat at compile time. + */ +public final class VelocityCarbonChatBridge implements AutoCloseable { + + private static final String[] CARBON_PLUGIN_IDS = {"carbonchat", "carbon"}; + private static final int SUBSCRIBE_ORDER = 100; + private static final int MAX_RETRY_ATTEMPTS = 30; + private static final long RETRY_DELAY_TICKS = 20L; + private static final long RETRY_PERIOD_TICKS = 20L; + + private final Object plugin; + private final ProxyServer proxy; + private final ChatService chatService; + private final MessageForwardService forwardService; + private final MineSentinelService mineSentinelService; + private final PluginConfig config; + private final Logger logger; + + private Object subscription; + private Method disposeMethod; + private Method eventSenderMethod; + private Method eventMessageMethod; + private Method eventCancelledSetter; + private Method playerUuidMethod; + private Method playerUsernameMethod; + private Method playerDisplayNameMethod; + private CommonScheduler retryScheduler; + private int retryTaskId = -1; + private int retryAttempts = 0; + private volatile boolean registered = false; + + public VelocityCarbonChatBridge( + Object plugin, + ProxyServer proxy, + ChatService chatService, + MessageForwardService forwardService, + MineSentinelService mineSentinelService, + PluginConfig config, + Logger logger + ) { + this.plugin = plugin; + this.proxy = proxy; + this.chatService = chatService; + this.forwardService = forwardService; + this.mineSentinelService = mineSentinelService; + this.config = config; + this.logger = logger; + } + + public boolean register() { + if (registered) { + return true; + } + try { + Class providerClass = loadCarbonClass("net.draycia.carbon.api.CarbonChatProvider"); + Class carbonChatClass = loadCarbonClass("net.draycia.carbon.api.CarbonChat"); + Class eventHandlerClass = loadCarbonClass("net.draycia.carbon.api.event.CarbonEventHandler"); + Class subscriberClass = loadCarbonClass("net.draycia.carbon.api.event.CarbonEventSubscriber"); + Class subscriptionClass = loadCarbonClass("net.draycia.carbon.api.event.CarbonEventSubscription"); + Class earlyChatEventClass = loadCarbonClass("net.draycia.carbon.common.event.events.CarbonEarlyChatEvent"); + Class carbonPlayerClass = loadCarbonClass("net.draycia.carbon.api.users.CarbonPlayer"); + + Object carbonChat = providerClass.getMethod("carbonChat").invoke(null); + Object eventHandler = carbonChatClass.getMethod("eventHandler").invoke(carbonChat); + + this.eventSenderMethod = earlyChatEventClass.getMethod("sender"); + this.eventMessageMethod = earlyChatEventClass.getMethod("message"); + this.eventCancelledSetter = earlyChatEventClass.getMethod("cancelled", boolean.class); + this.playerUuidMethod = carbonPlayerClass.getMethod("uuid"); + this.playerUsernameMethod = carbonPlayerClass.getMethod("username"); + this.playerDisplayNameMethod = carbonPlayerClass.getMethod("displayName"); + this.disposeMethod = subscriptionClass.getMethod("dispose"); + + Object subscriber = Proxy.newProxyInstance( + subscriberClass.getClassLoader(), + new Class[]{subscriberClass}, + createSubscriberHandler()); + + Method subscribeMethod = eventHandlerClass.getMethod( + "subscribe", Class.class, int.class, boolean.class, subscriberClass); + this.subscription = subscribeMethod.invoke( + eventHandler, + earlyChatEventClass, + SUBSCRIBE_ORDER, + false, + subscriber); + + registered = true; + stopRetrying(); + logger.info("CarbonChat Velocity chat bridge enabled"); + return true; + } catch (ClassNotFoundException ex) { + logger.fine("CarbonChat Velocity bridge classes are not ready: " + ex.getMessage()); + return false; + } catch (InvocationTargetException ex) { + Throwable cause = ex.getCause() == null ? ex : ex.getCause(); + if (cause instanceof IllegalStateException) { + logger.fine("CarbonChat Velocity bridge is not initialized yet: " + cause.getMessage()); + return false; + } + logger.warning("CarbonChat Velocity bridge registration failed: " + cause.getMessage()); + return false; + } catch (ReflectiveOperationException | LinkageError ex) { + logger.warning("CarbonChat Velocity bridge registration failed: " + ex.getMessage()); + return false; + } + } + + public void startRetrying(CommonScheduler scheduler) { + if (registered || retryTaskId > 0 || scheduler == null || !isCarbonPluginPresent()) { + return; + } + + this.retryScheduler = scheduler; + logger.info("CarbonChat detected on Velocity but bridge is not ready yet; retrying registration..."); + retryTaskId = scheduler.runTimer(() -> { + if (registered) { + stopRetrying(); + return; + } + + retryAttempts++; + if (register()) { + return; + } + + if (retryAttempts >= MAX_RETRY_ATTEMPTS) { + logger.warning("CarbonChat Velocity bridge could not be enabled after retries"); + stopRetrying(); + } + }, RETRY_DELAY_TICKS, RETRY_PERIOD_TICKS); + } + + public boolean isRegistered() { + return registered; + } + + @Override + public void close() { + if (subscription == null || disposeMethod == null) { + stopRetrying(); + registered = false; + return; + } + + try { + disposeMethod.invoke(subscription); + logger.info("CarbonChat Velocity chat bridge disabled"); + } catch (ReflectiveOperationException ex) { + logger.warning("CarbonChat Velocity bridge disposal failed: " + ex.getMessage()); + } finally { + subscription = null; + registered = false; + stopRetrying(); + } + } + + private void stopRetrying() { + if (retryTaskId > 0 && retryScheduler != null) { + retryScheduler.cancelTask(retryTaskId); + } + retryTaskId = -1; + retryAttempts = 0; + retryScheduler = null; + } + + private InvocationHandler createSubscriberHandler() { + return (proxyObject, method, args) -> { + if ("on".equals(method.getName()) && args != null && args.length == 1) { + handleCarbonEarlyChat(args[0]); + } + return null; + }; + } + + private void handleCarbonEarlyChat(Object event) { + try { + Object carbonPlayer = eventSenderMethod.invoke(event); + String message = (String) eventMessageMethod.invoke(event); + UUID playerUuid = (UUID) playerUuidMethod.invoke(carbonPlayer); + Optional velocityPlayer = proxy.getPlayer(playerUuid); + String playerName = getPlayerName(carbonPlayer, velocityPlayer); + String displayName = getDisplayName(carbonPlayer, playerName); + + if (message == null || message.isEmpty()) { + return; + } + + if (handleAiChat(velocityPlayer, playerUuid, playerName, displayName, message)) { + eventCancelledSetter.invoke(event, true); + return; + } + + if (forwardService != null && forwardService.shouldForward(message)) { + forwardService.handlePlayerMessage(playerUuid, playerName, displayName, message); + } + observeMineSentinel(velocityPlayer, playerUuid, playerName, displayName, message); + } catch (ReflectiveOperationException | RuntimeException ex) { + logger.warning("CarbonChat Velocity message handling failed: " + ex.getMessage()); + } + } + + private boolean handleAiChat( + Optional velocityPlayer, + UUID playerUuid, + String playerName, + String displayName, + String message + ) { + if (chatService == null || config == null) { + return false; + } + + if (config.isPrivateChatEnabled()) { + String privatePrefix = config.getPrivateChatPrefix(); + if (privatePrefix != null && !privatePrefix.isEmpty() && message.startsWith(privatePrefix)) { + String content = message.substring(privatePrefix.length()).trim(); + if (!content.isEmpty()) { + String echoMessage = PlaceholderUtil.replace( + config.getPrivateChatEchoFormat(), + "player", displayName, + "message", message); + velocityPlayer.ifPresent(player -> player.sendMessage(Component.text(echoMessage))); + chatService.sendChatRequest(playerUuid, playerName, displayName, content, ChatMode.PRIVATE); + return true; + } + } + } + + if (config.isGroupChatEnabled()) { + String groupPrefix = config.getGroupChatPrefix(); + if (groupPrefix != null && !groupPrefix.isEmpty() && message.startsWith(groupPrefix)) { + String content = message.substring(groupPrefix.length()).trim(); + if (!content.isEmpty()) { + chatService.sendChatRequest(playerUuid, playerName, displayName, content, ChatMode.GROUP); + } + } + } + + return false; + } + + private void observeMineSentinel( + Optional velocityPlayer, + UUID playerUuid, + String playerName, + String displayName, + String message + ) { + if (mineSentinelService == null || !mineSentinelService.isEnabled()) { + return; + } + + String backend = currentBackend(velocityPlayer); + mineSentinelService.observeChat( + playerUuid, + playerName, + displayName, + message, + backend, + buildProxyContext(backend)); + } + + private JsonObject buildProxyContext(String backend) { + JsonObject context = new JsonObject(); + context.addProperty("source", "carbonchat-velocity"); + context.addProperty("messageType", "PROXY_PUBLIC_CHAT"); + if (backend != null && !backend.isBlank()) { + context.addProperty("backendServer", backend); + } + return context; + } + + private String currentBackend(Optional velocityPlayer) { + if (velocityPlayer.isEmpty()) { + return ""; + } + return velocityPlayer.get().getCurrentServer() + .map(server -> server.getServerInfo().getName()) + .orElse(""); + } + + private String getPlayerName(Object carbonPlayer, Optional velocityPlayer) + throws ReflectiveOperationException { + Object username = playerUsernameMethod.invoke(carbonPlayer); + if (username instanceof String value && !value.isBlank()) { + return value; + } + return velocityPlayer.map(Player::getUsername).orElse("Unknown"); + } + + private String getDisplayName(Object carbonPlayer, String fallback) + throws ReflectiveOperationException { + Object displayName = playerDisplayNameMethod.invoke(carbonPlayer); + if (displayName instanceof Component component) { + String plain = PlainTextComponentSerializer.plainText().serialize(component); + if (!plain.isBlank()) { + return plain; + } + } + return fallback; + } + + private Class loadCarbonClass(String name) throws ClassNotFoundException { + try { + return Class.forName(name); + } catch (ClassNotFoundException ex) { + ClassLoader carbonClassLoader = findCarbonClassLoader(); + if (carbonClassLoader == null) { + throw ex; + } + return Class.forName(name, false, carbonClassLoader); + } + } + + private ClassLoader findCarbonClassLoader() { + PluginContainer carbonPlugin = findCarbonPlugin(); + if (carbonPlugin == null || carbonPlugin.getInstance().isEmpty()) { + return null; + } + return carbonPlugin.getInstance().get().getClass().getClassLoader(); + } + + private boolean isCarbonPluginPresent() { + return findCarbonPlugin() != null; + } + + private PluginContainer findCarbonPlugin() { + for (String pluginId : CARBON_PLUGIN_IDS) { + Optional carbonPlugin = proxy.getPluginManager().getPlugin(pluginId); + if (carbonPlugin.isPresent()) { + return carbonPlugin.get(); + } + } + + for (PluginContainer candidate : proxy.getPluginManager().getPlugins()) { + String id = candidate.getDescription().getId(); + String name = candidate.getDescription().getName().orElse(""); + if ("carbonchat".equalsIgnoreCase(id) || "carbon".equalsIgnoreCase(id) + || "CarbonChat".equalsIgnoreCase(name) || "Carbon".equalsIgnoreCase(name)) { + return candidate; + } + } + return null; + } +} From d29ab546ac5176d5909bea3a2914fc80d56ed482 Mon Sep 17 00:00:00 2001 From: Arbousier1 Date: Sun, 5 Jul 2026 00:07:10 +0800 Subject: [PATCH 3/4] Forward all Minecraft chat by default --- README.md | 2 +- .../railgun19457/astrbotadapter/core/config/ConfigManager.java | 2 +- .../railgun19457/astrbotadapter/core/config/PluginConfig.java | 2 +- src/main/resources/config-backend.yml | 2 +- src/main/resources/config-velocity.yml | 2 +- src/main/resources/config.yml | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 052fbc6..bad62a8 100644 --- a/README.md +++ b/README.md @@ -171,7 +171,7 @@ AI 总结由 Java 端 MineSentinel 只读采集 observation,AstrBot 插件侧 | `server.restapi.enabled` | `true` | boolean | 是否启用 REST API | | `server.restapi.rateLimit` | `100` | int(次/分钟) | REST 频率限制,`0` 为不限流 | | `messageForward.enabled` | `true` | boolean | 是否启用聊天消息转发 | -| `messageForward.prefix` | `*` | string | 转发触发前缀(留空表示转发所有消息) | +| `messageForward.prefix` | `""` | string | 转发触发前缀(留空表示转发所有消息) | | `messageForward.stripPrefix` | `true` | boolean | 转发时是否移除前缀 | | `messageForward.incomingFormat` | `§7[§b{platform}§7] §f{username}§7: §f{content}` | string | 外来消息显示格式,支持 `{platform}` `{username}` `{content}` | | `aiChat.group.enabled` | `true` | boolean | 是否启用群聊 AI | diff --git a/src/main/java/io/github/railgun19457/astrbotadapter/core/config/ConfigManager.java b/src/main/java/io/github/railgun19457/astrbotadapter/core/config/ConfigManager.java index e47bf2d..e678e36 100644 --- a/src/main/java/io/github/railgun19457/astrbotadapter/core/config/ConfigManager.java +++ b/src/main/java/io/github/railgun19457/astrbotadapter/core/config/ConfigManager.java @@ -328,7 +328,7 @@ private void parseConfig(Map yaml) { Map forward = getMap(yaml, "messageForward"); if (forward != null) { config.setForwardEnabled(getBoolean(forward, "enabled", true)); - config.setForwardPrefix(getString(forward, "prefix", "*")); + config.setForwardPrefix(getString(forward, "prefix", "")); config.setStripPrefix(getBoolean(forward, "stripPrefix", true)); config.setIncomingFormat(getString(forward, "incomingFormat", "§7[§b{platform}§7] §f{username}§7: §f{content}")); diff --git a/src/main/java/io/github/railgun19457/astrbotadapter/core/config/PluginConfig.java b/src/main/java/io/github/railgun19457/astrbotadapter/core/config/PluginConfig.java index 03aa6a6..cec480c 100644 --- a/src/main/java/io/github/railgun19457/astrbotadapter/core/config/PluginConfig.java +++ b/src/main/java/io/github/railgun19457/astrbotadapter/core/config/PluginConfig.java @@ -34,7 +34,7 @@ public class PluginConfig { // 消息转发配置 private boolean forwardEnabled = true; - private String forwardPrefix = "*"; + private String forwardPrefix = ""; private boolean stripPrefix = true; private String incomingFormat = "§7[§b{platform}§7] §f{username}§7: §f{content}"; diff --git a/src/main/resources/config-backend.yml b/src/main/resources/config-backend.yml index 80f9c63..7040f70 100644 --- a/src/main/resources/config-backend.yml +++ b/src/main/resources/config-backend.yml @@ -59,7 +59,7 @@ messageForward: # 触发前缀(玩家消息以此开头才会转发) # 留空表示转发所有消息 - prefix: "*" + prefix: "" # 是否移除前缀后转发(true: 转发内容不含前缀) stripPrefix: true diff --git a/src/main/resources/config-velocity.yml b/src/main/resources/config-velocity.yml index 1050c72..e266d0b 100644 --- a/src/main/resources/config-velocity.yml +++ b/src/main/resources/config-velocity.yml @@ -58,7 +58,7 @@ messageForward: # 触发前缀(玩家消息以此开头才会转发) # 留空表示转发所有消息 - prefix: "*" + prefix: "" # 是否移除前缀后转发(true: 转发内容不含前缀) stripPrefix: true diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index b6c6415..610d4e6 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -55,7 +55,7 @@ messageForward: # 触发前缀(玩家消息以此开头才会转发) # 留空表示转发所有消息 - prefix: "*" + prefix: "" # 是否移除前缀后转发(true: 转发内容不含前缀) stripPrefix: true From 22227a0795a4a11bbd6ea3dacccea15f8e36f7cb Mon Sep 17 00:00:00 2001 From: Arbousier1 Date: Sun, 5 Jul 2026 00:17:46 +0800 Subject: [PATCH 4/4] docs: update deployment prompt for session targets --- README.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index bad62a8..34046c6 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ AI 总结由 Java 端 MineSentinel 只读采集 observation,AstrBot 插件侧 1. 部署模式:单服 / Velocity 群组服。 2. Minecraft 服务器根目录。单服给一个根目录;Velocity 群组服请给 Velocity 根目录和每个后端服根目录。 3. AstrBot 根目录。 -4. 要接收 AI 总结的 QQ 群或 UMO,例如 group:123456789 或 aiocqhttp:GroupMessage:123456789。 +4. 要接收 AI 总结的 QQ 群或 UMO。优先提供 AstrBot `/sid` 输出,例如 napcat:GroupMessage:123456789;也可以提供 group:123456789 或 qq:123456789 简写。 5. NapCat 安装目录如果已知可一并提供;不知道就留空,由你先自动检测。 6. 是否现在重启 MC 服务端、AstrBot 和 NapCat。 @@ -120,13 +120,13 @@ AI 总结由 Java 端 MineSentinel 只读采集 observation,AstrBot 插件侧 - 不要让 AI 输入 QQ 密码;启动 NapCat 后让用户扫码/手动登录。 - 配置 OneBot v11 WebSocket 或 HTTP,使 AstrBot 能连接 NapCat;若已有可用连接,保持原配置。 4. 从 GitHub Actions 下载两个仓库最新 successful workflow 的构建产物和源码。优先使用 gh: - - gh run list -R EllanServer/AstrBotAdapter --branch main --status success --limit 1 + - gh run list -R EllanServer/AstrBotAdapter --branch codex/fix-paper-carbon-chat-forwarding --status success --limit 1 - gh run download -R EllanServer/AstrBotAdapter --dir ./downloads/AstrBotAdapter - - gh run list -R EllanServer/astrbot_plugin_minecraft_adapter --branch master --status success --limit 1 + - gh run list -R EllanServer/astrbot_plugin_minecraft_adapter --branch pyo3-mine-sentinel --status success --limit 1 - gh run download -R EllanServer/astrbot_plugin_minecraft_adapter --dir ./downloads/astrbot_plugin_minecraft_adapter 如果没有 gh,就打开上面的 Actions 链接下载最新成功运行的 artifacts。若 Actions 没有源码 artifact,则同时下载源码 ZIP: - - https://github.com/EllanServer/AstrBotAdapter/archive/refs/heads/main.zip - - https://github.com/EllanServer/astrbot_plugin_minecraft_adapter/archive/refs/heads/master.zip + - https://github.com/EllanServer/AstrBotAdapter/archive/refs/heads/codex/fix-paper-carbon-chat-forwarding.zip + - https://github.com/EllanServer/astrbot_plugin_minecraft_adapter/archive/refs/heads/pyo3-mine-sentinel.zip 5. 安装 Java 插件: - 单服:把最新 Backend jar 放进该服务器根目录的 plugins/。 - Velocity 群组服:把 Velocity jar 放进 Velocity 的 plugins/,把 Backend jar 放进每个后端服的 plugins/;如果产物包含 libs/,按 README 保持 libs/ 与 Velocity jar 同级。 @@ -134,11 +134,12 @@ AI 总结由 Java 端 MineSentinel 只读采集 observation,AstrBot 插件侧 7. 配置 Java 插件: - 单服保持 proxyMode.enabled=false。 - Velocity 群组服先启动/读取 Velocity 端 secret,再给每个后端服写入 proxyMode.enabled=true 和 proxyMode.secret。 + - 如果需要把所有 Minecraft 普通聊天转发到 AstrBot/QQ,保持 messageForward.prefix="";留空表示不再要求玩家输入 * 前缀。 - 确认 mine_sentinel.enabled、observation.enabled、chat.enabled、metrics.enabled 为 true。 8. 配置 AstrBot 插件: - 单服在 mc_servers 中添加后端服 host/port/token。 - 群组服只添加 Velocity 端 host/port/token。 - - 把 QQ 群写入 message.target_sessions,必要时写入 mine_sentinel.report.delivery_targets。 + - 把 QQ 群写入 message.target_sessions,必要时写入 mine_sentinel.report.delivery_targets;优先写 /sid 输出的 UMO,简写 group:/qq: 会由 AstrBot 插件解析到当前平台。 - 开启 mine_sentinel.enabled、storage.enabled、report.enabled、send_as_image、send_full_log_file。 - 确认 AstrBot 已连接 NapCat/OneBot,且目标 QQ 群可以收发消息。 9. 做备份:覆盖任何 jar、插件目录或配置文件前,先复制到带时间戳的 backup 目录。