diff options
author | Keuin <[email protected]> | 2021-12-17 23:55:44 +0800 |
---|---|---|
committer | Keuin <[email protected]> | 2021-12-17 23:55:44 +0800 |
commit | 12ddbba66e6f2585e59d05d1782c0e8ce9fe6146 (patch) | |
tree | 0d98ee95c01c8509160658080523e351357d4a9b /src/main/java | |
parent | 7fc64f506ea7ebc68fcb0a9e98351deed7c1d212 (diff) |
Use the latest Velocity API.
Implement API server for online players and server status.
Implement core message routing abstraction and concrete BungeeCross, Velocity, Telegram endpoint impl.
Load config from config file "crosslink/config.json".
Test core components. Proxy API related stuff are not tested.
Add README in English and Chinese.
TODO: Add config hot reloading. More configurable system. PSMB endpoint impl.
Diffstat (limited to 'src/main/java')
78 files changed, 3224 insertions, 45 deletions
diff --git a/src/main/java/com/keuin/crosslink/BungeeMain.java b/src/main/java/com/keuin/crosslink/BungeeMain.java deleted file mode 100644 index 01486be..0000000 --- a/src/main/java/com/keuin/crosslink/BungeeMain.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.keuin.crosslink; - -import net.md_5.bungee.api.plugin.Plugin; - -import java.util.logging.Logger; - -public class BungeeMain extends Plugin { - - private final Logger logger = getLogger(); - - @Override - public void onLoad() { - logger.info("CrossLink is loading in BungeeCord mode."); - } - - @Override - public void onEnable() { - } - - @Override - public void onDisable() { - } - -}
\ No newline at end of file diff --git a/src/main/java/com/keuin/crosslink/VelocityMain.java b/src/main/java/com/keuin/crosslink/VelocityMain.java deleted file mode 100644 index 7123712..0000000 --- a/src/main/java/com/keuin/crosslink/VelocityMain.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.keuin.crosslink; - -import com.google.inject.Inject; -import com.velocitypowered.api.plugin.Plugin; -import com.velocitypowered.api.proxy.ProxyServer; -import org.slf4j.Logger; - -@Plugin(id = "crosslink", name = "CrossLink", version = "1.0-SNAPSHOT", - description = "Link your grouped servers with external world.", authors = {"Keuin"}) -public class VelocityMain { - private final ProxyServer server; - private final Logger logger; - - @Inject - public VelocityMain(ProxyServer server, Logger logger) { - this.server = server; - this.logger = logger; - - logger.info("CrossLink is loading in Velocity mode."); - } -}
\ No newline at end of file diff --git a/src/main/java/com/keuin/crosslink/api/ApiServer.java b/src/main/java/com/keuin/crosslink/api/ApiServer.java new file mode 100644 index 0000000..d7599a5 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/api/ApiServer.java @@ -0,0 +1,124 @@ +package com.keuin.crosslink.api; + +import com.google.common.collect.ImmutableMap; +import com.google.inject.Inject; +import com.keuin.crosslink.api.error.ApiStartupException; +import com.keuin.crosslink.api.request.JsonHttpExchange; +import com.keuin.crosslink.api.request.JsonReqHandler; +import com.keuin.crosslink.data.PlayerInfo; +import com.keuin.crosslink.data.ServerInfo; +import com.keuin.crosslink.plugin.common.ICoreAccessor; +import com.keuin.crosslink.util.LoggerNaming; +import com.sun.net.httpserver.HttpServer; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.stream.Collectors; + +/** + * CrossLink API server. + * Serves API on a specific port. Fetch data from given accessor. + */ +public class ApiServer implements IApiServer { + private final Logger logger = LoggerFactory.getLogger(LoggerNaming.name().of("api").of("server").toString()); + private HttpServer server = null; + private final ICoreAccessor coreAccessor; + + @Inject + public ApiServer(@NotNull ICoreAccessor coreAccessor) { + Objects.requireNonNull(coreAccessor); + this.coreAccessor = coreAccessor; + } + + @Override + public void startup(InetSocketAddress listen) throws ApiStartupException { + try { + this.server = HttpServer.create(listen, 0); + ImmutableMap.<String, JsonReqHandler>builder() + .put("/", new JsonReqHandler("GET") { + @Override + protected void handle(JsonHttpExchange exc) { + logger.debug("Root handler is called."); + } + }) + .put("/online_players", new JsonReqHandler("GET") { + @Override + protected void handle(JsonHttpExchange exchange) { + // FIXME "true" or other non-zero values does not work + if (exchange.queryMap().getOrDefault("grouped", "0").equals("1")) { + // grouped by server name + var map = exchange.getResponseBody().putObject("players"); + coreAccessor.getOnlinePlayers().stream() + .collect(Collectors.groupingBy(PlayerInfo::serverName)) + .forEach((serverName, players) -> { + var arr = map.putArray(serverName); + players.stream().map(PlayerInfo::name).forEach(arr::add); + }); + } else { + var players = exchange.getResponseBody().putArray("players"); + coreAccessor.getOnlinePlayers().stream().map(PlayerInfo::name).forEach(players::add); + } + } + }) + .put("/server_status", new JsonReqHandler("GET") { + @Override + protected void handle(JsonHttpExchange exchange) throws IOException { + // FIXME make this http server async, prevent serialize non-blocking operation + var ev = new Object(); + var isDone = new AtomicBoolean(false); + logger.debug("Start reading server info."); + coreAccessor.getServerInfo((infoList) -> { + logger.debug("Async reading status."); + var response = exchange.getResponseBody().putObject("servers"); + for (ServerInfo info : infoList) { + var obj = response.putObject(info.name()); + obj.put("status", info.status().getValue()); + } + // unlock the main routine + logger.debug("Async notifying main routine."); + isDone.set(true); + synchronized (ev) { + ev.notifyAll(); + } + }); + // block until the async routine returns + logger.debug("Wait for reading status."); + while (!isDone.get()) { + try { + logger.debug("Spin waiting for pinging to finish."); + synchronized (ev) { + ev.wait(1000); + } + } catch (InterruptedException ignored) { + } + } + logger.debug("Finished reading server status. Sending response to HTTP client."); + } + }) + .build().forEach((p, h) -> server.createContext(p).setHandler(h)); + server.start(); + logger.info("API server is listening on {}:{}.", + listen.getAddress().toString().replaceFirst("/(.+)", "$1"), + listen.getPort()); + } catch (IOException ex) { + throw new ApiStartupException(ex); + } + } + + @Override + public void shutdown() { + var s = server; + if (s != null) { + logger.info("Waiting for incoming connections to close."); + s.stop(1); + logger.info("API server is stopped."); + } + } + + // TODO http server, get data from coreAccessor +} diff --git a/src/main/java/com/keuin/crosslink/api/IApiServer.java b/src/main/java/com/keuin/crosslink/api/IApiServer.java new file mode 100644 index 0000000..678d8ac --- /dev/null +++ b/src/main/java/com/keuin/crosslink/api/IApiServer.java @@ -0,0 +1,19 @@ +package com.keuin.crosslink.api; + +import com.keuin.crosslink.api.error.ApiStartupException; + +import java.net.InetSocketAddress; + +public interface IApiServer { + /** + * Start serving the API in other threads. + * @throws ApiStartupException if failed to startup. + */ + void startup(InetSocketAddress listen) throws ApiStartupException; + + /** + * Shutdown the API server. + * If the API server is not started, this method does nothing. + */ + void shutdown(); +} diff --git a/src/main/java/com/keuin/crosslink/api/error/ApiBindException.java b/src/main/java/com/keuin/crosslink/api/error/ApiBindException.java new file mode 100644 index 0000000..5e5b23a --- /dev/null +++ b/src/main/java/com/keuin/crosslink/api/error/ApiBindException.java @@ -0,0 +1,7 @@ +package com.keuin.crosslink.api.error; + +public class ApiBindException extends ApiStartupException { + public ApiBindException(Throwable cause) { + super(cause); + } +} diff --git a/src/main/java/com/keuin/crosslink/api/error/ApiStartupException.java b/src/main/java/com/keuin/crosslink/api/error/ApiStartupException.java new file mode 100644 index 0000000..4024101 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/api/error/ApiStartupException.java @@ -0,0 +1,18 @@ +package com.keuin.crosslink.api.error; + +public class ApiStartupException extends Exception { + public ApiStartupException() { + } + + public ApiStartupException(String message) { + super(message); + } + + public ApiStartupException(String message, Throwable cause) { + super(message, cause); + } + + public ApiStartupException(Throwable cause) { + super(cause); + } +} diff --git a/src/main/java/com/keuin/crosslink/api/request/JsonHttpExchange.java b/src/main/java/com/keuin/crosslink/api/request/JsonHttpExchange.java new file mode 100644 index 0000000..d1c010d --- /dev/null +++ b/src/main/java/com/keuin/crosslink/api/request/JsonHttpExchange.java @@ -0,0 +1,186 @@ +package com.keuin.crosslink.api.request; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.keuin.crosslink.util.HttpQuery; +import com.keuin.crosslink.util.LazyEvaluated; +import com.sun.net.httpserver.Headers; +import com.sun.net.httpserver.HttpContext; +import com.sun.net.httpserver.HttpExchange; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.URI; +import java.util.Map; + +public class JsonHttpExchange implements AutoCloseable { + + private static final ObjectMapper mapper = new ObjectMapper(); + + private final HttpExchange exchange; + private int rCode = -1; // 200 by default (<= 0) + private final JsonNode requestBody; + private final JsonNode responseBody = mapper.readTree("{}"); + private final LazyEvaluated<Map<String, String>> lazyQueryMap; + private byte[] responseBytes = new byte[0]; + + JsonHttpExchange(HttpExchange exchange) throws IOException { + this.exchange = exchange; + this.requestBody = mapper.readTree(exchange.getRequestBody()); + this.lazyQueryMap = new LazyEvaluated<>(() -> HttpQuery.getParamMap(exchange.getRequestURI().getQuery())); + this.getResponseHeaders().set("Content-Type", "application/json"); + } + + public Map<String, String> queryMap() { + // FIXME string parameter parsing + return lazyQueryMap.get(); + } + + /** + * Returns an immutable {@link Map} containing the HTTP headers that were + * included with this request. The keys in this {@code Map} will be the header + * names, while the values will be a {@link java.util.List} of + * {@linkplain java.lang.String Strings} containing each value that was + * included (either for a header that was listed several times, or one that + * accepts a comma-delimited list of values on a single line). In either of + * these cases, the values for the header name will be presented in the + * order that they were included in the request. + * + * <p> The keys in {@code Map} are case-insensitive. + * + * @return a read-only {@code Map} which can be used to access request headers + */ + public Headers getRequestHeaders() { + return exchange.getRequestHeaders(); + } + + /** + * Returns a mutable {@link Map} into which the HTTP response headers can be + * stored and which will be transmitted as part of this response. The keys in + * the {@code Map} will be the header names, while the values must be a + * {@link java.util.List} of {@linkplain java.lang.String Strings} containing + * each value that should be included multiple times (in the order that they + * should be included). + * + * <p> The keys in {@code Map} are case-insensitive. + * + * @return a writable {@code Map} which can be used to set response headers. + */ + public Headers getResponseHeaders() { + return exchange.getResponseHeaders(); + } + + /** + * Get the request {@link URI}. + * + * @return the request {@code URI} + */ + public URI getRequestURI() { + return exchange.getRequestURI(); + } + + /** + * Get the request method. + * + * @return the request method + */ + public String getRequestMethod() { + return exchange.getRequestMethod(); + } + + /** + * Get the {@link HttpContext} for this exchange. + * + * @return the {@code HttpContext} + */ + public HttpContext getHttpContext() { + return exchange.getHttpContext(); + } + + /** + * Ends this exchange by doing the following in sequence: + * <ol> + * <li> close the request {@link InputStream}, if not already closed. + * <li> close the response {@link OutputStream}, if not already closed. + * </ol> + */ + public void close() throws IOException { + this.getResponseHeaders().set("Content-Type", "application/json"); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + mapper.writeValue(baos, responseBody); + responseBytes = baos.toByteArray(); + exchange.sendResponseHeaders(getResponseCode(), responseBytes.length); + exchange.getResponseBody().write(responseBytes); + exchange.close(); + } + + /** + * Returns a stream from which the request body can be read. + * Multiple calls to this method will return the same stream. + * It is recommended that applications should consume (read) all of the data + * from this stream before closing it. If a stream is closed before all data + * has been read, then the {@link InputStream#close()} call will read + * and discard remaining data (up to an implementation specific number of + * bytes). + * + * @return the stream from which the request body can be read + */ + public JsonNode getRequestBody() { + return requestBody; + } + + /** + * Returns the response JSON body. + */ + public ObjectNode getResponseBody() { + return (ObjectNode) responseBody; + } + + + public void setResponseCode(int code) { + rCode = code; + } + + /** + * Returns the address of the remote entity invoking this request. + * + * @return the {@link InetSocketAddress} of the caller + */ + public InetSocketAddress getRemoteAddress() { + return exchange.getRemoteAddress(); + } + + /** + * Returns the response code, if it has already been set. + * + * @return the response code, if available. {@code -1} if not available yet. + */ + public int getResponseCode() { + return (rCode <= 0) ? 200 : rCode; + } + + /** + * Returns the local address on which the request was received. + * + * @return the {@link InetSocketAddress} of the local interface + */ + public InetSocketAddress getLocalAddress() { + return exchange.getLocalAddress(); + } + + /** + * Returns the protocol string from the request in the form + * <i>protocol/majorVersion.minorVersion</i>. For example, + * "{@code HTTP/1.1}". + * + * @return the protocol string from the request + */ + public String getProtocol() { + return exchange.getProtocol(); + } + +} diff --git a/src/main/java/com/keuin/crosslink/api/request/JsonReqHandler.java b/src/main/java/com/keuin/crosslink/api/request/JsonReqHandler.java new file mode 100644 index 0000000..302ee65 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/api/request/JsonReqHandler.java @@ -0,0 +1,44 @@ +package com.keuin.crosslink.api.request; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import org.jetbrains.annotations.NotNull; + +import java.io.IOException; +import java.util.Objects; +import java.util.function.Function; + +public abstract class JsonReqHandler implements HttpHandler { + private final Function<JsonHttpExchange, Integer> precondition; + + public JsonReqHandler() { + precondition = (exc) -> -1; // always true + } + + protected JsonReqHandler(Function<JsonHttpExchange, Integer> precondition) { + this.precondition = precondition; + } + + protected JsonReqHandler(@NotNull String method) { + Objects.requireNonNull(method); + this.precondition = (exc) -> method.equals(exc.getRequestMethod()) ? -1 : 400; + } + + @Override + public void handle(HttpExchange exchange) throws IOException { + try (var exc = new JsonHttpExchange(exchange)) { + var v = precondition.apply(exc); + if (v > 0) { + // precondition failed with a http response code + // user defined handler is not called + exc.setResponseCode(v); + return; + } + handle(exc); + } + } + + protected void handle(JsonHttpExchange exchange) throws IOException { + + } +} diff --git a/src/main/java/com/keuin/crosslink/config/ConfigLoadException.java b/src/main/java/com/keuin/crosslink/config/ConfigLoadException.java new file mode 100644 index 0000000..f41fdd5 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/config/ConfigLoadException.java @@ -0,0 +1,4 @@ +package com.keuin.crosslink.config; + +public class ConfigLoadException extends Exception { +} diff --git a/src/main/java/com/keuin/crosslink/config/GlobalConfigManager.java b/src/main/java/com/keuin/crosslink/config/GlobalConfigManager.java new file mode 100644 index 0000000..26996c2 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/config/GlobalConfigManager.java @@ -0,0 +1,50 @@ +package com.keuin.crosslink.config; + +import org.jetbrains.annotations.NotNull; + +import java.io.File; + +public class GlobalConfigManager { + /** + * Load config from disk. + * If loaded successfully, the global 'loaded' status will be set to true. + * @throws ConfigLoadException failed to load. The 'loaded' status will be set to false. + */ + public static void initializeGlobalManager(File configFile) throws ConfigLoadException { + // TODO read config from disk, create the singleton object +// throw new RuntimeException(); + } + + public static @NotNull GlobalConfigManager getInstance() { + // TODO get the singleton object +// throw new RuntimeException(); + throw new RuntimeException("GlobalConfigManager is not initialized"); + } + + /** + * Get an immutable view of the global config. + * A view is a consistent, but not up-to-date snapshot. + * + * @return the config view. + */ + public IConfigView getConfig() { + // TODO + throw new RuntimeException("Global config is not loaded"); + } + + public boolean isLoaded() { + throw new RuntimeException(); + } + + /** + * Reload the config file from disk. + * If loaded successfully, the global 'loaded' status will be set to true. + * @throws ConfigLoadException failed to reload. + * If previously loaded, the global config won't be modified. + * Otherwise, the 'loaded' status will be set to false. + */ + public void reload() throws ConfigLoadException { + // TODO +// throw new RuntimeException(); + } +} diff --git a/src/main/java/com/keuin/crosslink/config/IConfigView.java b/src/main/java/com/keuin/crosslink/config/IConfigView.java new file mode 100644 index 0000000..a3cab1b --- /dev/null +++ b/src/main/java/com/keuin/crosslink/config/IConfigView.java @@ -0,0 +1,5 @@ +package com.keuin.crosslink.config; + +public interface IConfigView { + int pingTimeoutMillis(); +} diff --git a/src/main/java/com/keuin/crosslink/config/section/ConfigMessaging.java b/src/main/java/com/keuin/crosslink/config/section/ConfigMessaging.java new file mode 100644 index 0000000..75a16ec --- /dev/null +++ b/src/main/java/com/keuin/crosslink/config/section/ConfigMessaging.java @@ -0,0 +1,4 @@ +package com.keuin.crosslink.config.section; + +public class ConfigMessaging { +} diff --git a/src/main/java/com/keuin/crosslink/data/PlayerInfo.java b/src/main/java/com/keuin/crosslink/data/PlayerInfo.java new file mode 100644 index 0000000..6be3f6f --- /dev/null +++ b/src/main/java/com/keuin/crosslink/data/PlayerInfo.java @@ -0,0 +1,24 @@ +package com.keuin.crosslink.data; + +import com.velocitypowered.api.proxy.Player; +import net.md_5.bungee.api.connection.ProxiedPlayer; + +import java.util.UUID; + +public record PlayerInfo(String name, UUID uuid, String serverName) { + public static PlayerInfo fromBungeePlayer(ProxiedPlayer bungeePlayer) { + return new PlayerInfo( + bungeePlayer.getName(), + bungeePlayer.getUniqueId(), + bungeePlayer.getServer().getInfo().getName() + ); + } + + public static PlayerInfo fromVelocityPlayer(Player player) { + return new PlayerInfo( + player.getUsername(), + player.getUniqueId(), + player.getCurrentServer().map((conn) -> conn.getServerInfo().getName()).orElse("null") + ); + } +} diff --git a/src/main/java/com/keuin/crosslink/data/ServerInfo.java b/src/main/java/com/keuin/crosslink/data/ServerInfo.java new file mode 100644 index 0000000..8bdc126 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/data/ServerInfo.java @@ -0,0 +1,4 @@ +package com.keuin.crosslink.data; + +public record ServerInfo(String name, ServerStatus status) { +} diff --git a/src/main/java/com/keuin/crosslink/data/ServerStatus.java b/src/main/java/com/keuin/crosslink/data/ServerStatus.java new file mode 100644 index 0000000..cc9c147 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/data/ServerStatus.java @@ -0,0 +1,18 @@ +package com.keuin.crosslink.data; + +/** + * Server running status. + * Copied from legacy BungeeCross code. + */ +public enum ServerStatus { + ONLINE("UP"), OFFLINE("DOWN"), TIMED_OUT("TIMED OUT"); + private final String value; + + ServerStatus(String value) { + this.value = value; + } + + public String getValue() { + return value; + } +}
\ No newline at end of file diff --git a/src/main/java/com/keuin/crosslink/messaging/action/BaseFilterAction.java b/src/main/java/com/keuin/crosslink/messaging/action/BaseFilterAction.java new file mode 100644 index 0000000..427a428 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/messaging/action/BaseFilterAction.java @@ -0,0 +1,33 @@ +package com.keuin.crosslink.messaging.action; + +import com.keuin.crosslink.messaging.action.result.IActionResult; +import com.keuin.crosslink.messaging.message.IMessage; +import com.keuin.crosslink.util.LoggerNaming; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Objects; +import java.util.function.Predicate; + +public class BaseFilterAction implements IAction { + private static final Logger logger = + LoggerFactory.getLogger(LoggerNaming.name().of("actions").of("filter").toString()); + private final Predicate<IMessage> filter; + + public BaseFilterAction(Predicate<IMessage> filter) { + this.filter = filter; + } + + @Override + public @NotNull IActionResult process(@NotNull IMessage message) { + Objects.requireNonNull(message); + if (filter.test(message)) { + logger.debug("Message " + message + " passed filter."); + return IActionResult.normal(message); + } else { + logger.debug("Message " + message + " is filtered out."); + return IActionResult.filtered(); + } + } +} diff --git a/src/main/java/com/keuin/crosslink/messaging/action/BaseReplaceAction.java b/src/main/java/com/keuin/crosslink/messaging/action/BaseReplaceAction.java new file mode 100644 index 0000000..4932fac --- /dev/null +++ b/src/main/java/com/keuin/crosslink/messaging/action/BaseReplaceAction.java @@ -0,0 +1,32 @@ +package com.keuin.crosslink.messaging.action; + +import com.keuin.crosslink.messaging.action.result.IActionResult; +import com.keuin.crosslink.messaging.message.IMessage; +import com.keuin.crosslink.util.LoggerNaming; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Objects; +import java.util.function.UnaryOperator; + +public class BaseReplaceAction implements IAction { + + private final UnaryOperator<IMessage> replacer; + private static final Logger logger = + LoggerFactory.getLogger(LoggerNaming.name().of("actions").of("replace").toString()); + + /** + * Create a replacement action based on given replacer. + * Note that the replacer should always return a non-null value. + */ + public BaseReplaceAction(UnaryOperator<IMessage> replacer) { + this.replacer = replacer; + } + + @Override + public @NotNull IActionResult process(@NotNull IMessage message) { + logger.debug("Replace message " + message); + return IActionResult.normal(Objects.requireNonNull(replacer.apply(message))); + } +} diff --git a/src/main/java/com/keuin/crosslink/messaging/action/DropAction.java b/src/main/java/com/keuin/crosslink/messaging/action/DropAction.java new file mode 100644 index 0000000..d4d7815 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/messaging/action/DropAction.java @@ -0,0 +1,24 @@ +package com.keuin.crosslink.messaging.action; + +import com.keuin.crosslink.messaging.action.result.IActionResult; +import com.keuin.crosslink.messaging.message.IMessage; +import com.keuin.crosslink.util.LoggerNaming; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class DropAction implements IAction { + private final Logger logger = + LoggerFactory.getLogger(LoggerNaming.name().of("actions").of("drop").toString()); + + @Override + public @NotNull IActionResult process(@NotNull IMessage message) { + logger.debug("Drop message " + message); + return IActionResult.dropped(); + } + + @Override + public String toString() { + return "DropAction{}"; + } +} diff --git a/src/main/java/com/keuin/crosslink/messaging/action/FormatAction.java b/src/main/java/com/keuin/crosslink/messaging/action/FormatAction.java new file mode 100644 index 0000000..cc8b554 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/messaging/action/FormatAction.java @@ -0,0 +1,38 @@ +package com.keuin.crosslink.messaging.action; + +import com.keuin.crosslink.messaging.message.IMessage; +import com.keuin.crosslink.messaging.util.Messaging; +import net.kyori.adventure.text.format.Style; +import net.kyori.adventure.text.format.TextColor; +import net.kyori.adventure.text.format.TextDecoration; + +public class FormatAction extends BaseReplaceAction { + public FormatAction(TextColor color) { + super((message) -> { + // FIXME non-text-based message may lose information? + var formatted = Messaging.duplicate(message.kyoriMessage()); + return IMessage.create(message.source(), message.sender(), formatted.color(color)); + }); + } + public FormatAction(TextDecoration decor) { + super((message) -> { + // FIXME non-text-based message may lose information? + // FIXME clear other decorations + var formatted = Messaging.duplicate(message.kyoriMessage()); + return IMessage.create(message.source(), message.sender(), formatted.decorate(decor)); + }); + } + public FormatAction(Style style) { + super((message) -> { + // FIXME non-text-based message may lose information? + // FIXME clear other styles + var formatted = Messaging.duplicate(message.kyoriMessage()); + return IMessage.create(message.source(), message.sender(), formatted.style(style)); + }); + } + + @Override + public String toString() { + return "FormatAction{}"; + } +} diff --git a/src/main/java/com/keuin/crosslink/messaging/action/IAction.java b/src/main/java/com/keuin/crosslink/messaging/action/IAction.java new file mode 100644 index 0000000..b232aaf --- /dev/null +++ b/src/main/java/com/keuin/crosslink/messaging/action/IAction.java @@ -0,0 +1,40 @@ +package com.keuin.crosslink.messaging.action; + +import com.keuin.crosslink.messaging.action.result.IActionResult; +import com.keuin.crosslink.messaging.message.IMessage; +import org.jetbrains.annotations.NotNull; + +import java.util.Objects; + +/** + * An immutable, constant behaving optional mutator on {@link IMessage} instances. + * Specific action will be taken on given message and the result will be returned. + */ +public interface IAction { + @NotNull IActionResult process(@NotNull IMessage message); + + static @NotNull IAction compounded(@NotNull IAction... actions) { + return new IAction() { + @Override + public @NotNull IActionResult process(@NotNull IMessage message) { + Objects.requireNonNull(message); + var msg = IActionResult.normal(message); + for (IAction action : actions) { + if (msg.isDropped() || msg.isFiltered()) { + return msg; + } + // not dropped and not filtered + // move on + // not null guaranteed by post condition of method "getResult" + msg = action.process(Objects.requireNonNull(msg.getResult())); + } + return msg; + } + + @Override + public String toString() { + return String.format("CompoundedAction{actions=%s}", (Object) actions); + } + }; + } +} diff --git a/src/main/java/com/keuin/crosslink/messaging/action/Re2placeAction.java b/src/main/java/com/keuin/crosslink/messaging/action/Re2placeAction.java new file mode 100644 index 0000000..f3811fb --- /dev/null +++ b/src/main/java/com/keuin/crosslink/messaging/action/Re2placeAction.java @@ -0,0 +1,21 @@ +package com.keuin.crosslink.messaging.action; + +import com.keuin.crosslink.messaging.message.IMessage; + +import java.util.regex.Pattern; + +// regexp replace +public class Re2placeAction extends BaseReplaceAction { + public Re2placeAction(Pattern from, String to) { + super((message) -> { + // FIXME keep color information + var content = from.matcher(message.pureString()).replaceAll(to); + return IMessage.create(message.source(), message.sender(), content); + }); + } + + @Override + public String toString() { + return "Re2placeAction{}"; + } +} diff --git a/src/main/java/com/keuin/crosslink/messaging/action/ReFilterAction.java b/src/main/java/com/keuin/crosslink/messaging/action/ReFilterAction.java new file mode 100644 index 0000000..3a06e01 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/messaging/action/ReFilterAction.java @@ -0,0 +1,14 @@ +package com.keuin.crosslink.messaging.action; + +import java.util.regex.Pattern; + +public class ReFilterAction extends BaseFilterAction { + public ReFilterAction(Pattern pattern) { + super((message) -> pattern.matcher(message.pureString()).matches()); + } + + @Override + public String toString() { + return "ReFilterAction{}"; + } +} diff --git a/src/main/java/com/keuin/crosslink/messaging/action/RouteAction.java b/src/main/java/com/keuin/crosslink/messaging/action/RouteAction.java new file mode 100644 index 0000000..0015638 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/messaging/action/RouteAction.java @@ -0,0 +1,47 @@ +package com.keuin.crosslink.messaging.action; + +import com.keuin.crosslink.messaging.action.result.IActionResult; +import com.keuin.crosslink.messaging.endpoint.IEndpoint; +import com.keuin.crosslink.messaging.message.IMessage; +import com.keuin.crosslink.util.LoggerNaming; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Objects; +import java.util.Set; +import java.util.function.Supplier; + +public class RouteAction implements IAction { + private static final Logger logger = + LoggerFactory.getLogger(LoggerNaming.name().of("actions").of("route").toString()); + private final Supplier<Set<IEndpoint>> destinations; + private final boolean allowBackFlow; + + public RouteAction(Supplier<Set<IEndpoint>> destinations, boolean allowBackFlow) { + this.allowBackFlow = allowBackFlow; + this.destinations = destinations; // late evaluated destinations, ensuring the result to be up-to-date + } + + @Override + public @NotNull IActionResult process(@NotNull IMessage message) { + // FIXME implement equals() and hashCode() for all IEndpoint subclasses + var dest = destinations.get().stream(); + if (!allowBackFlow) { + dest = dest.filter((ep) -> !ep.equals(message.source())); + } + dest.forEach((ep) -> { + logger.debug("Route message " + message + " to endpoint " + ep + ", backflow=" + allowBackFlow); + ep.sendMessage(message); + }); + return IActionResult.normal(Objects.requireNonNull(message)); + } + + @Override + public String toString() { + return "RouteAction{" + + "destinations=" + destinations + + ", allowBackFlow=" + allowBackFlow + + '}'; + } +} diff --git a/src/main/java/com/keuin/crosslink/messaging/action/result/IActionResult.java b/src/main/java/com/keuin/crosslink/messaging/action/result/IActionResult.java new file mode 100644 index 0000000..3820f35 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/messaging/action/result/IActionResult.java @@ -0,0 +1,95 @@ +package com.keuin.crosslink.messaging.action.result; + +import com.keuin.crosslink.messaging.message.IMessage; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Objects; + +/** + * Process result of an action. The intermediate state in the middle of a chain. + */ +public interface IActionResult { + boolean isDropped(); + + /** + * If the message is filtered out by a filter action or by the rule itself (such as the "from" filter in rule) + * + * @return if the message is filtered out. + */ + boolean isFiltered(); + + /** + * Null if and only if isDropped or isFiltered returns true. + */ + @Nullable IMessage getResult(); // TODO is it better to make this always not null? + + /** + * Returns if the message has not been dropped or filtered out. + */ + default boolean isValid() { + var valid = !isDropped() && !isFiltered(); + if (valid) { + Objects.requireNonNull(getResult()); + } + return valid; + } + + static IActionResult dropped() { + return new IActionResult() { + @Override + public boolean isDropped() { + return true; + } + + @Override + public boolean isFiltered() { + return false; + } + + @Override + public IMessage getResult() { + return null; + } + }; + } + + static IActionResult filtered() { + return new IActionResult() { + @Override + public boolean isDropped() { + return false; + } + + @Override + public boolean isFiltered() { + return true; + } + + @Override + public IMessage getResult() { + return null; + } + }; + } + + static IActionResult normal(@NotNull IMessage message) { + Objects.requireNonNull(message); + return new IActionResult() { + @Override + public boolean isDropped() { + return false; + } + + @Override + public boolean isFiltered() { + return false; + } + + @Override + public IMessage getResult() { + return message; + } + }; + } +} diff --git a/src/main/java/com/keuin/crosslink/messaging/config/ConfigSyntaxError.java b/src/main/java/com/keuin/crosslink/messaging/config/ConfigSyntaxError.java new file mode 100644 index 0000000..6191484 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/messaging/config/ConfigSyntaxError.java @@ -0,0 +1,18 @@ +package com.keuin.crosslink.messaging.config; + +public class ConfigSyntaxError extends Exception { + public ConfigSyntaxError() { + } + + public ConfigSyntaxError(String message) { + super(message); + } + + public ConfigSyntaxError(String message, Throwable cause) { + super(message, cause); + } + + public ConfigSyntaxError(Throwable cause) { + super(cause); + } +} diff --git a/src/main/java/com/keuin/crosslink/messaging/config/remote/IRemoteEndpointFactory.java b/src/main/java/com/keuin/crosslink/messaging/config/remote/IRemoteEndpointFactory.java new file mode 100644 index 0000000..9310dbd --- /dev/null +++ b/src/main/java/com/keuin/crosslink/messaging/config/remote/IRemoteEndpointFactory.java @@ -0,0 +1,12 @@ +package com.keuin.crosslink.messaging.config.remote; + +import com.fasterxml.jackson.databind.JsonNode; +import com.keuin.crosslink.messaging.endpoint.IEndpoint; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +// Creates a specific type of remote endpoint, based on given configuration. +public interface IRemoteEndpointFactory { + @NotNull String type(); + @Nullable IEndpoint create(@NotNull JsonNode config) throws InvalidEndpointConfigurationException; +} diff --git a/src/main/java/com/keuin/crosslink/messaging/config/remote/InvalidEndpointConfigurationException.java b/src/main/java/com/keuin/crosslink/messaging/config/remote/InvalidEndpointConfigurationException.java new file mode 100644 index 0000000..376547e --- /dev/null +++ b/src/main/java/com/keuin/crosslink/messaging/config/remote/InvalidEndpointConfigurationException.java @@ -0,0 +1,22 @@ +package com.keuin.crosslink.messaging.config.remote; + +/** + * The given JSON config node is invalid to this factory. + */ +public class InvalidEndpointConfigurationException extends Exception { + public InvalidEndpointConfigurationException() { + super(); + } + + public InvalidEndpointConfigurationException(String message) { + super(message); + } + + public InvalidEndpointConfigurationException(String message, Throwable cause) { + super(message, cause); + } + + public InvalidEndpointConfigurationException(Throwable cause) { + super(cause); + } +} diff --git a/src/main/java/com/keuin/crosslink/messaging/config/remote/InvalidTypeOfEndpointException.java b/src/main/java/com/keuin/crosslink/messaging/config/remote/InvalidTypeOfEndpointException.java new file mode 100644 index 0000000..5b27247 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/messaging/config/remote/InvalidTypeOfEndpointException.java @@ -0,0 +1,19 @@ +package com.keuin.crosslink.messaging.config.remote; + +public class InvalidTypeOfEndpointException extends InvalidEndpointConfigurationException { + public InvalidTypeOfEndpointException() { + super(); + } + + public InvalidTypeOfEndpointException(String message) { + super(message); + } + + public InvalidTypeOfEndpointException(String message, Throwable cause) { + super(message, cause); + } + + public InvalidTypeOfEndpointException(Throwable cause) { + super(cause); + } +} diff --git a/src/main/java/com/keuin/crosslink/messaging/config/remote/RemoteEndpointFactory.java b/src/main/java/com/keuin/crosslink/messaging/config/remote/RemoteEndpointFactory.java new file mode 100644 index 0000000..b73fba5 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/messaging/config/remote/RemoteEndpointFactory.java @@ -0,0 +1,47 @@ +package com.keuin.crosslink.messaging.config.remote; + +import com.fasterxml.jackson.databind.JsonNode; +import com.keuin.crosslink.messaging.endpoint.IEndpoint; +import com.keuin.crosslink.util.LoggerNaming; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Optional; + +/** + * User should use this factory to create endpoints from a general JSON node. + * This factory can create all supported configurable types of remote endpoints. + */ +public class RemoteEndpointFactory { + private static final Logger logger = + LoggerFactory.getLogger(LoggerNaming.name().of("config").of("remotes").toString()); + private static final IRemoteEndpointFactory[] factories = new IRemoteEndpointFactory[]{new TelegramEndpointFactory()}; + + /** + * Create an {@link IEndpoint} instance based on given JSON config node. + * If success, return the created instance. + * If the given config node does not specify a valid type of endpoint, + * an exception {@link InvalidTypeOfEndpointException} will be thrown. + * If the config node specifies a valid type of endpoint but is invalid, + * an exception {@link InvalidEndpointConfigurationException} will be thrown. + * Note that if the endpoint is not configured to be enabled, null will be returned. + * + * @param config the config to create an endpoint from. + * @return the created endpoint. + */ + public static @Nullable IEndpoint create(@NotNull JsonNode config) throws InvalidEndpointConfigurationException { + var type = Optional.ofNullable(config.get("type")).map(JsonNode::textValue).orElse(null); + for (IRemoteEndpointFactory f : factories) { + if (f.type().equals(type)) { + var ep = f.create(config); + if (ep != null) { + logger.debug("Create remote endpoint " + ep); + } + return ep; + } + } + throw new InvalidTypeOfEndpointException("Unknown type: " + type); + } +} diff --git a/src/main/java/com/keuin/crosslink/messaging/config/remote/TelegramEndpointFactory.java b/src/main/java/com/keuin/crosslink/messaging/config/remote/TelegramEndpointFactory.java new file mode 100644 index 0000000..f6e2818 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/messaging/config/remote/TelegramEndpointFactory.java @@ -0,0 +1,44 @@ +package com.keuin.crosslink.messaging.config.remote; + +import com.fasterxml.jackson.databind.JsonNode; +import com.keuin.crosslink.messaging.endpoint.IEndpoint; +import com.keuin.crosslink.messaging.endpoint.remote.telegram.TelegramGroupEndpoint; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Optional; + +public class TelegramEndpointFactory implements IRemoteEndpointFactory { + @Override + public @NotNull String type() { + return "telegram"; + } + + @Override + public @Nullable IEndpoint create(@NotNull JsonNode config) throws InvalidEndpointConfigurationException { + // read id, not optional + var id = Optional.ofNullable(config.get("id")).map(JsonNode::textValue).orElse(null); + if (id == null || id.isEmpty()) throw new InvalidEndpointConfigurationException("Invalid \"id\""); + + // read enabled, optional (true by default) + if (!config.get("enabled").isBoolean()) throw new InvalidEndpointConfigurationException("Invalid \"enabled\""); + var enabled = Optional.ofNullable(config.get("enabled")).map(JsonNode::booleanValue).orElse(true); + + // read token, not optional + var token = Optional.ofNullable(config.get("token")).map(JsonNode::textValue).orElse(null); + if (token == null || token.isEmpty()) throw new InvalidEndpointConfigurationException("Invalid \"token\""); + + // read chat id + var nodeChatId = config.get("chat_id"); + var chatId = Optional.ofNullable(nodeChatId).map(JsonNode::longValue).orElse(null); + if (chatId == null) throw new InvalidEndpointConfigurationException("Invalid \"chat_id\""); + if (!nodeChatId.isIntegralNumber() || nodeChatId.isBoolean() + || nodeChatId.isFloatingPointNumber() || nodeChatId.isTextual()) { + throw new InvalidEndpointConfigurationException("Invalid \"chat_id\""); + } + + if (!enabled) return null; // not enabled, give a null value + + return new TelegramGroupEndpoint(token, id, chatId); + } +} diff --git a/src/main/java/com/keuin/crosslink/messaging/config/router/IRouterConfigurer.java b/src/main/java/com/keuin/crosslink/messaging/config/router/IRouterConfigurer.java new file mode 100644 index 0000000..359ede4 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/messaging/config/router/IRouterConfigurer.java @@ -0,0 +1,18 @@ +package com.keuin.crosslink.messaging.config.router; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.keuin.crosslink.messaging.config.ConfigSyntaxError; +import com.keuin.crosslink.messaging.router.IRouterConfigurable; + +public interface IRouterConfigurer { + /** + * Parse and configure the router with internal configuration string. + * @throws JsonProcessingException cannot parse JSON string. + * @throws ConfigSyntaxError config content is invalid. + */ + void configure(IRouterConfigurable router) throws JsonProcessingException, ConfigSyntaxError; + +// @NotNull IRouterConfigurable getRouter(); + + +} diff --git a/src/main/java/com/keuin/crosslink/messaging/config/router/RouterConfigurer.java b/src/main/java/com/keuin/crosslink/messaging/config/router/RouterConfigurer.java new file mode 100644 index 0000000..578fcd5 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/messaging/config/router/RouterConfigurer.java @@ -0,0 +1,250 @@ +package com.keuin.crosslink.messaging.config.router; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.keuin.crosslink.messaging.action.*; +import com.keuin.crosslink.messaging.config.ConfigSyntaxError; +import com.keuin.crosslink.messaging.endpoint.IEndpoint; +import com.keuin.crosslink.messaging.filter.IFilter; +import com.keuin.crosslink.messaging.filter.ReIdFilter; +import com.keuin.crosslink.messaging.router.IRouterConfigurable; +import com.keuin.crosslink.messaging.rule.IRule; +import com.keuin.crosslink.messaging.rule.ImmutableRule; +import com.keuin.crosslink.messaging.rule.ObjectType; +import com.keuin.crosslink.util.LoggerNaming; +import net.kyori.adventure.text.format.NamedTextColor; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.UnmodifiableView; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.*; +import java.util.function.Supplier; +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; + +/** + * Configure {@link IRouterConfigurable} with supplied JSON config node. + * The config is typically read from config file. + * I know this class is compact and harder to maintain... But it works. + */ +public class RouterConfigurer implements IRouterConfigurer { + private final JsonNode config; + private static final Logger logger = + LoggerFactory.getLogger(LoggerNaming.name().of("config").of("router").toString()); + + public RouterConfigurer(@NotNull JsonNode config) { + Objects.requireNonNull(config); + this.config = config; + } + + private static class ActionConstructionException extends Exception { + public ActionConstructionException() { + } + + public ActionConstructionException(String message) { + super(message); + } + + public ActionConstructionException(String message, Throwable cause) { + super(message, cause); + } + } + + private interface ActionConstructor { + IAction construct(IRouterConfigurable router, JsonNode jsonNode) throws ActionConstructionException; + } + + private static final Map<String, ActionConstructor> actionConstructors = new HashMap<>(); + + static { + // register action decoders + actionConstructors.put("drop", (r, j) -> { + var iter = j.fieldNames(); + while (iter.hasNext()) { + var field = iter.next(); + if (!Objects.equals(field, "type")) { + throw new ActionConstructionException(String.format("Unnecessary field \"%s\"", field)); + } + } + return new DropAction(); + }); + actionConstructors.put("format", (r, j) -> { + var iter = j.fields(); + var actions = new ArrayList<FormatAction>(); + while (iter.hasNext()) { + var field = iter.next(); + switch (field.getKey()) { + case "type": + break; + case "color": + var text = field.getValue().textValue(); + var color = NamedTextColor.NAMES.value(text); + if (color == null) { + throw new ActionConstructionException(String.format("Invalid color \"%s\"", text)); + } + actions.add(new FormatAction(color)); + break; + default: + throw new ActionConstructionException(String.format("Invalid field \"%s\"", field.getKey())); + } + } + return IAction.compounded(actions.toArray(new IAction[0])); + }); + actionConstructors.put("replace", (r, j) -> { + try { + String from = null, to = null; + var iter = j.fields(); + while (iter.hasNext()) { + var field = iter.next(); + switch (field.getKey()) { + case "type": + break; + case "from": + from = field.getValue().textValue(); + break; + case "to": + to = field.getValue().textValue(); + break; + default: + throw new ActionConstructionException(String.format("Invalid field \"%s\"", field.getKey())); + } + } + if (from == null) throw new ActionConstructionException("Missing field \"from\""); + if (to == null) throw new ActionConstructionException("Missing field \"to\""); + var fromPattern = Pattern.compile(from); + return new Re2placeAction(fromPattern, to); + } catch (PatternSyntaxException ex) { + throw new ActionConstructionException("Invalid regexp in field \"from\"", ex); + } + }); + actionConstructors.put("filter", (r, j) -> { + try { + String pattern = null; + var iter = j.fields(); + while (iter.hasNext()) { + var field = iter.next(); + switch (field.getKey()) { + case "type": + break; + case "pattern": + pattern = field.getValue().textValue(); + break; + default: + throw new ActionConstructionException(String.format("Invalid field \"%s\"", field.getKey())); + } + } + if (pattern == null) throw new ActionConstructionException("Missing field \"pattern\""); + var p = Pattern.compile(pattern); + return new ReFilterAction(p); + } catch (PatternSyntaxException ex) { + throw new ActionConstructionException("Invalid regexp in field \"from\"", ex); + } + }); + actionConstructors.put("route", (r, j) -> { + try { + String to = null; + var backFlow = true; // true by default + var iter = j.fields(); + while (iter.hasNext()) { + var field = iter.next(); + switch (field.getKey()) { + case "type": + break; + case "to": + to = field.getValue().textValue(); + break; + case "backflow": + if (field.getValue().isBoolean()) + backFlow = field.getValue().booleanValue(); + else + throw new ActionConstructionException("Field \"backflow\" expects a boolean value"); + break; + default: + throw new ActionConstructionException(String.format("Invalid field \"%s\"", field.getKey())); + } + } + logger.debug("Read entry: route to \"" + to + "\", backflow=" + backFlow); + if (to == null) throw new ActionConstructionException("Missing field \"to\""); + var split = to.split(":"); + if (split.length != 2) throw new ActionConstructionException("Invalid field \"to\": wrong format"); + return new RouteAction(new Supplier<>() { + private final String namespace = split[0]; + private final Pattern idPattern = Pattern.compile(split[1]); + + @Override + public Set<IEndpoint> get() { + return r.resolveEndpoints(namespace, idPattern); + } + + @Override + public String toString() { + return "DestinationResolver{namespace=" + namespace + ", idPattern=" + idPattern + "}"; + } + }, backFlow); + } catch (PatternSyntaxException ex) { + throw new ActionConstructionException("Invalid regexp in field \"to\"", ex); + } + }); + } + + private @NotNull @UnmodifiableView List<IRule> loadRuleChain(@NotNull IRouterConfigurable router, + @NotNull JsonNode config) + throws ConfigSyntaxError { + Objects.requireNonNull(config); + if (!config.isArray()) { + throw new ConfigSyntaxError("Routing rules should be a JSON array"); + } + var ruleCounter = 1; + try { + var ruleList = new ArrayList<IRule>(); + for (var jRule : config) { + Object object = jRule.get("object"); + Object from = jRule.get("from"); + var jActions = jRule.get("actions"); + if (object == null) throw new ConfigSyntaxError("Missing field \"object\""); + if (from == null) throw new ConfigSyntaxError("Missing field \"from\""); + if (jActions == null) throw new ConfigSyntaxError("Missing field \"actions\""); + object = ((JsonNode) object).textValue(); + from = ((JsonNode) from).textValue(); + if (object == null) throw new ConfigSyntaxError("Invalid field \"object\""); + if (from == null) throw new ConfigSyntaxError("Invalid field \"from\""); + object = ObjectType.of((String) object); + if (object == null) + throw new ConfigSyntaxError("Invalid field \"object\", unknown enum"); + var fromFilter = IFilter.fromPatternString((String) from); + var actionCounter = 1; + var actions = new ArrayList<IAction>(); + try { + for (var jAction : jActions) { + if (jAction == null) throw new ConfigSyntaxError("Invalid action %d"); + var aType = jAction.get("type").textValue(); // action type + var constructor = actionConstructors.get(aType); + if (constructor == null) + throw new ConfigSyntaxError(String.format("Invalid action type: %s", aType)); + var iAction = constructor.construct(router, jAction); + actions.add(iAction); + ++actionCounter; + } + } catch (ConfigSyntaxError | ActionConstructionException ex) { + throw new ConfigSyntaxError(ex.getMessage() + ", in action " + actionCounter, ex); + } + ruleList.add(new ImmutableRule((ObjectType) object, fromFilter, actions)); + logger.debug("Load rule: OBJECT: {} | FROM: {} | ACTION: {}", object, from, jActions); + ++ruleCounter; + } + return Collections.unmodifiableList(ruleList); + } catch (ReIdFilter.InvalidPatternStringException ex) { + throw new ConfigSyntaxError(ex); + } catch (ConfigSyntaxError ex) { + throw new ConfigSyntaxError(ex.getMessage() + ", in rule " + ruleCounter, ex); + } + } + + @Override + public void configure(IRouterConfigurable router) throws JsonProcessingException, ConfigSyntaxError { + router.updateRuleChain(loadRuleChain(router, config)); + } + + +} diff --git a/src/main/java/com/keuin/crosslink/messaging/consts/Consts.java b/src/main/java/com/keuin/crosslink/messaging/consts/Consts.java new file mode 100644 index 0000000..b4cdb16 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/messaging/consts/Consts.java @@ -0,0 +1,6 @@ +package com.keuin.crosslink.messaging.consts; + +public class Consts { + public static final String SERVER_DOMAIN = "server"; + public static final String REMOTE_DOMAIN = "remote"; +} diff --git a/src/main/java/com/keuin/crosslink/messaging/endpoint/EndpointNamespace.java b/src/main/java/com/keuin/crosslink/messaging/endpoint/EndpointNamespace.java new file mode 100644 index 0000000..fb3eb12 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/messaging/endpoint/EndpointNamespace.java @@ -0,0 +1,28 @@ +package com.keuin.crosslink.messaging.endpoint; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Objects; + +public enum EndpointNamespace { + SERVER("server"), REMOTE("remote"); + private final String namespace; + + EndpointNamespace(String namespace) { + this.namespace = namespace; + } + + @Override + public String toString() { + return namespace; + } + + public static @Nullable EndpointNamespace of(@NotNull String s) { + Objects.requireNonNull(s); + for (EndpointNamespace v : EndpointNamespace.values()) { + if (v.toString().equals(s)) return v; + } + return null; + } +} diff --git a/src/main/java/com/keuin/crosslink/messaging/endpoint/IEndpoint.java b/src/main/java/com/keuin/crosslink/messaging/endpoint/IEndpoint.java new file mode 100644 index 0000000..fc4e8ab --- /dev/null +++ b/src/main/java/com/keuin/crosslink/messaging/endpoint/IEndpoint.java @@ -0,0 +1,27 @@ +package com.keuin.crosslink.messaging.endpoint; + +import com.keuin.crosslink.messaging.message.IMessage; +import com.keuin.crosslink.messaging.router.IRouter; +import org.jetbrains.annotations.NotNull; + +public interface IEndpoint { + void sendMessage(IMessage message); + void setRouter(IRouter router); + void close(); + + /** + * Get the identifier of this endpoint. + * @return the identifier. + */ + @NotNull String id(); + + @NotNull EndpointNamespace namespace(); + + default @NotNull String friendlyName() { + return id(); + } + + default String namespacedId() { + return String.format("%s:%s", namespace(), id()); + } +} diff --git a/src/main/java/com/keuin/crosslink/messaging/endpoint/local/BungeeServerChatEndpoint.java b/src/main/java/com/keuin/crosslink/messaging/endpoint/local/BungeeServerChatEndpoint.java new file mode 100644 index 0000000..a221f02 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/messaging/endpoint/local/BungeeServerChatEndpoint.java @@ -0,0 +1,104 @@ +package com.keuin.crosslink.messaging.endpoint.local; + +import com.keuin.crosslink.messaging.endpoint.EndpointNamespace; +import com.keuin.crosslink.messaging.endpoint.IEndpoint; +import com.keuin.crosslink.messaging.message.IMessage; +import com.keuin.crosslink.messaging.router.IRouter; +import com.keuin.crosslink.messaging.sender.ISender; +import com.keuin.crosslink.plugin.bungee.BungeeMainWrapper; +import com.keuin.crosslink.util.LoggerNaming; +import net.md_5.bungee.api.ProxyServer; +import net.md_5.bungee.api.config.ServerInfo; +import net.md_5.bungee.api.connection.ProxiedPlayer; +import net.md_5.bungee.api.event.ChatEvent; +import net.md_5.bungee.api.plugin.Listener; +import net.md_5.bungee.event.EventHandler; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Objects; + +public class BungeeServerChatEndpoint implements IEndpoint { + private final Logger logger; + private final ServerInfo server; + private final ProxyServer proxy; + private final Listener chatListener; + private final String id; + private IRouter router = null; + + // We have to make this public, otherwise the BungeeCord event dispatcher + // cannot access this and an exception will be thrown + public class ChatListener implements Listener { + @EventHandler + public void onChat(ChatEvent event) { + Objects.requireNonNull(event); + var player = (ProxiedPlayer) event.getSender(); + if (!server.equals(player.getServer().getInfo())) return; // the message does not come from this endpoint + var textMessage = event.getMessage(); + if (textMessage.startsWith("/")) return; // do not repeat commands + var sender = ISender.create(player.getName(), player.getUniqueId()); + var message = IMessage.create(BungeeServerChatEndpoint.this, sender, textMessage); + logger.info("Received chat message from game: " + message); + onMessage(message); + } + } + + public BungeeServerChatEndpoint(@NotNull ServerInfo server, + @NotNull ProxyServer proxy, + @NotNull BungeeMainWrapper plugin) { + this.logger = + LoggerFactory.getLogger(LoggerNaming.name().of("endpoint").of("bungee").of(server.getName()).toString()); + this.server = Objects.requireNonNull(server); + this.proxy = Objects.requireNonNull(proxy); + this.id = Objects.requireNonNull(server.getName()); + this.chatListener = new ChatListener(); + proxy.getPluginManager().registerListener(plugin, chatListener); + } + + @Override + public void sendMessage(IMessage message) { + proxy.getPlayers().forEach((player) -> { + var info = player.getServer().getInfo(); + if (Objects.equals(info.getName(), server.getName()) && + Objects.equals(info.getSocketAddress(), server.getSocketAddress())) { + player.sendMessage(message.bungeeDisplay()); + } + }); + } + + @Override + public void setRouter(IRouter router) { + this.router = router; + } + + @Override + public void close() { + proxy.getPluginManager().unregisterListener(chatListener); + } + + @Override + public @NotNull String id() { + return id; + } + + @Override + public @NotNull EndpointNamespace namespace() { + return EndpointNamespace.SERVER; + } + + private void onMessage(@NotNull IMessage message) { + var rt = router; + if (rt == null) { + throw new IllegalStateException("Current endpoint hasn't bound to any router"); + } + rt.sendMessage(message); + } + + @Override + public String toString() { + return "BungeeServerChatEndpoint{" + + "id='" + id + '\'' + + '}'; + } +} diff --git a/src/main/java/com/keuin/crosslink/messaging/endpoint/local/VelocityServerChatEndpoint.java b/src/main/java/com/keuin/crosslink/messaging/endpoint/local/VelocityServerChatEndpoint.java new file mode 100644 index 0000000..b490121 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/messaging/endpoint/local/VelocityServerChatEndpoint.java @@ -0,0 +1,91 @@ +package com.keuin.crosslink.messaging.endpoint.local; + +import com.keuin.crosslink.messaging.endpoint.EndpointNamespace; +import com.keuin.crosslink.messaging.endpoint.IEndpoint; +import com.keuin.crosslink.messaging.message.IMessage; +import com.keuin.crosslink.messaging.router.IRouter; +import com.keuin.crosslink.messaging.sender.ISender; +import com.keuin.crosslink.plugin.velocity.VelocityMainWrapper; +import com.velocitypowered.api.event.EventHandler; +import com.velocitypowered.api.event.player.PlayerChatEvent; +import com.velocitypowered.api.proxy.ProxyServer; +import com.velocitypowered.api.proxy.ServerConnection; +import com.velocitypowered.api.proxy.server.RegisteredServer; +import org.jetbrains.annotations.NotNull; + +import java.util.Objects; + +public class VelocityServerChatEndpoint implements IEndpoint { + private final RegisteredServer server; + private final ProxyServer proxy; + private final VelocityMainWrapper plugin; + private IRouter router = null; + private final String id; + + // adapter registered on velocity event dispatcher + private final EventHandler<PlayerChatEvent> eventHandler; + + public VelocityServerChatEndpoint(RegisteredServer server, ProxyServer proxy, VelocityMainWrapper plugin) { + this.server = server; + this.proxy = proxy; + this.plugin = plugin; + this.id = server.getServerInfo().getName(); + this.eventHandler = (event) -> { + Objects.requireNonNull(event); + if (!event.getPlayer().getCurrentServer() + .map((ps) -> ps.getServerInfo().equals(server.getServerInfo())) + .orElse(false)) return; // this message does not come from the server this endpoint bound to, skip + var textMessage = event.getMessage(); + if (textMessage.startsWith("/")) return; // do not repeat commands + var sender = ISender.create(event.getPlayer().getUsername(), event.getPlayer().getUniqueId()); + var message = IMessage.create(VelocityServerChatEndpoint.this, sender, textMessage); + onMessage(message); + }; + proxy.getEventManager().register(plugin, PlayerChatEvent.class, eventHandler); + } + + @Override + public void sendMessage(IMessage message) { + proxy.getAllPlayers().forEach((player) -> { + var info = player.getCurrentServer().map(ServerConnection::getServerInfo).orElse(null); + if (Objects.equals(info, server.getServerInfo())) { + player.sendMessage(message.velocityDisplay()); + } + }); + } + + @Override + public void setRouter(IRouter router) { + this.router = router; + } + + @Override + public void close() { + proxy.getEventManager().unregister(plugin, eventHandler); + } + + @Override + public @NotNull String id() { + return id; + } + + @Override + public @NotNull EndpointNamespace namespace() { + return EndpointNamespace.SERVER; + } + + private void onMessage(@NotNull IMessage message) { + var rt = router; + if (rt == null) { + throw new IllegalStateException("No route to endpoint"); + } + rt.sendMessage(message); + } + + @Override + public String toString() { + return "VelocityServerChatEndpoint{" + + "id='" + id + '\'' + + '}'; + } +} diff --git a/src/main/java/com/keuin/crosslink/messaging/endpoint/remote/telegram/TelegramGroupEndpoint.java b/src/main/java/com/keuin/crosslink/messaging/endpoint/remote/telegram/TelegramGroupEndpoint.java new file mode 100644 index 0000000..9e6af55 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/messaging/endpoint/remote/telegram/TelegramGroupEndpoint.java @@ -0,0 +1,117 @@ +package com.keuin.crosslink.messaging.endpoint.remote.telegram; + +import com.keuin.crosslink.messaging.endpoint.EndpointNamespace; +import com.keuin.crosslink.messaging.endpoint.IEndpoint; +import com.keuin.crosslink.messaging.message.IMessage; +import com.keuin.crosslink.messaging.router.IRouter; +import com.keuin.crosslink.messaging.sender.ISender; +import com.keuin.crosslink.util.LoggerNaming; +import com.pengrad.telegrambot.TelegramBot; +import com.pengrad.telegrambot.UpdatesListener; +import com.pengrad.telegrambot.model.Update; +import com.pengrad.telegrambot.request.SendMessage; +import okhttp3.OkHttpClient; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.net.InetSocketAddress; +import java.net.Proxy; +import java.nio.ByteBuffer; +import java.util.List; +import java.util.Objects; +import java.util.UUID; + +public class TelegramGroupEndpoint implements IEndpoint { + // TODO make this compatible with both chat and group + private final Logger logger; + private final TelegramBot bot; + private final String endpointId; + private final long chatId; + private IRouter router = null; + + public TelegramGroupEndpoint(@NotNull String token, @NotNull String endpointId, long chatId) { + this(token, endpointId, chatId, null); + } + + public TelegramGroupEndpoint(@NotNull String token, + @NotNull String endpointId, + long chatId, + @Nullable String proxyUrl) { + Objects.requireNonNull(token); + Objects.requireNonNull(endpointId); + this.bot = new TelegramBot + .Builder(token) + .okHttpClient(new OkHttpClient.Builder() + .proxy(new Proxy(Proxy.Type.SOCKS, new InetSocketAddress("localhost", 10808))) + .build()) + .build(); + this.endpointId = endpointId; + this.chatId = chatId; + this.logger = LoggerFactory.getLogger(LoggerNaming.name().of("endpoint").of("telegram").of(endpointId).toString()); + bot.setUpdatesListener(this::onUpdate); + } + + private int onUpdate(List<Update> updates) { + var lastId = UpdatesListener.CONFIRMED_UPDATES_NONE; + for (Update u : updates) { + lastId = u.updateId(); + if (u.message().chat().id() != chatId) continue; + if (u.message() == null) continue; + var message = u.editedMessage(); + if (message == null) message = u.message(); + if (message == null) continue; + var msgText = message.text(); + if (msgText == null) continue; + if (router == null) { + logger.error("No router associated with this endpoint. Message is dropped."); + continue; + } + // TODO support other types of messages (currently only plaintext is supported) + var buf = ByteBuffer.allocate(Long.BYTES); + buf.putLong(u.message().from().id()); + var sender = ISender.create(u.message().from().username(), UUID.nameUUIDFromBytes(buf.array())); + var msgObj = IMessage.create(this, sender, msgText); + logger.info("Received plain text message from telegram: " + msgObj); + router.sendMessage(msgObj); +// logger.debug(u.toString()); + } + // return id of last processed update or confirm them all + return lastId; + } + + @Override + public void sendMessage(IMessage message) { + bot.execute(new SendMessage(chatId, message.plainTextDisplay())); + } + + + @Override + public void setRouter(IRouter router) { + this.router = router; + } + + public void close() { + bot.removeGetUpdatesListener(); + bot.shutdown(); + } + + @Override + public @NotNull String id() { + return endpointId; + } + + @Override + public @NotNull EndpointNamespace namespace() { + return EndpointNamespace.REMOTE; + } + + @Override + public String toString() { + return "TelegramGroupEndpoint{" + + "id='" + endpointId + '\'' + + ", chatId=" + chatId + + '}'; + } +} diff --git a/src/main/java/com/keuin/crosslink/messaging/filter/IFilter.java b/src/main/java/com/keuin/crosslink/messaging/filter/IFilter.java new file mode 100644 index 0000000..1f4fd43 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/messaging/filter/IFilter.java @@ -0,0 +1,32 @@ +package com.keuin.crosslink.messaging.filter; + +import com.keuin.crosslink.messaging.endpoint.EndpointNamespace; +import com.keuin.crosslink.messaging.endpoint.IEndpoint; +import com.keuin.crosslink.messaging.util.Messaging; +import org.jetbrains.annotations.NotNull; + +import java.util.Objects; +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; + +/** + * Select endpoints. + */ +public interface IFilter { + boolean filter(@NotNull IEndpoint id); + + static @NotNull IFilter fromPatternString(@NotNull String pattern) throws ReIdFilter.InvalidPatternStringException { + try { + var parts = Messaging.splitIdSelector(pattern); + if (parts == null) throw new ReIdFilter.InvalidPatternStringException("Invalid pattern"); + var ns = EndpointNamespace.of(parts[0]); + if (ns == null) { + throw new ReIdFilter.InvalidPatternStringException(String.format("Invalid namespace %s", parts[0])); + } + var p = Pattern.compile(parts[1]); + return new ReIdFilter(ns, p); + } catch (PatternSyntaxException ex) { + throw new ReIdFilter.InvalidPatternStringException("Invalid pattern regular expression", ex); + } + } +} diff --git a/src/main/java/com/keuin/crosslink/messaging/filter/ReIdFilter.java b/src/main/java/com/keuin/crosslink/messaging/filter/ReIdFilter.java new file mode 100644 index 0000000..59be0e0 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/messaging/filter/ReIdFilter.java @@ -0,0 +1,48 @@ +package com.keuin.crosslink.messaging.filter; + +import com.keuin.crosslink.messaging.endpoint.EndpointNamespace; +import com.keuin.crosslink.messaging.endpoint.IEndpoint; +import org.jetbrains.annotations.NotNull; + +import java.util.Objects; +import java.util.regex.Pattern; + +public class ReIdFilter implements IFilter { + public static class InvalidPatternStringException extends Exception { + public InvalidPatternStringException(String message) { + super(message); + } + + public InvalidPatternStringException(String message, Throwable cause) { + super(message, cause); + } + } + + private final EndpointNamespace namespace; + private final Pattern idPattern; + + public ReIdFilter(@NotNull EndpointNamespace namespace, @NotNull Pattern idPattern) { + Objects.requireNonNull(namespace); + Objects.requireNonNull(idPattern); + this.namespace = namespace; + this.idPattern = idPattern; + } + + public @NotNull String getIdPattern() { + return idPattern.pattern(); + } + + @Override + public boolean filter(@NotNull IEndpoint id) { + if (!namespace.equals(id.namespace())) return false; + return idPattern.matcher(id.id()).matches(); + } + + @Override + public String toString() { + return "ReIdFilter{" + + "namespace=" + namespace + + ", idPattern=" + idPattern + + '}'; + } +} diff --git a/src/main/java/com/keuin/crosslink/messaging/message/ComponentBackedMessage.java b/src/main/java/com/keuin/crosslink/messaging/message/ComponentBackedMessage.java new file mode 100644 index 0000000..4353c84 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/messaging/message/ComponentBackedMessage.java @@ -0,0 +1,50 @@ +package com.keuin.crosslink.messaging.message; + +import com.keuin.crosslink.messaging.endpoint.IEndpoint; +import com.keuin.crosslink.messaging.sender.ISender; +import com.keuin.crosslink.messaging.util.Messaging; +import com.keuin.crosslink.util.LazyEvaluated; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; +import org.jetbrains.annotations.NotNull; + +import java.util.Objects; + +public class ComponentBackedMessage implements IMessage { + private final IEndpoint source; + private final ISender sender; + private final Component component; + private final LazyEvaluated<String> lazyString; + + public ComponentBackedMessage(@NotNull IEndpoint source, @NotNull ISender sender, @NotNull Component component) { + Objects.requireNonNull(source); + Objects.requireNonNull(sender); + Objects.requireNonNull(component); + this.source = source; + this.sender = sender; + this.component = Messaging.duplicate(component); + this.lazyString = new LazyEvaluated<>(() -> PlainTextComponentSerializer.plainText().serialize(component)); + } + + @Override + public @NotNull ISender sender() { + return sender; + } + + @Override + public @NotNull IEndpoint source() { + return source; + } + + @Override + public @NotNull String pureString() { + return lazyString.get(); + } + + @Override + public Component kyoriMessage() { + return Messaging.duplicate(component); + } + + // FIXME implement bungeeMessage +} diff --git a/src/main/java/com/keuin/crosslink/messaging/message/IMessage.java b/src/main/java/com/keuin/crosslink/messaging/message/IMessage.java new file mode 100644 index 0000000..d2340b8 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/messaging/message/IMessage.java @@ -0,0 +1,95 @@ +package com.keuin.crosslink.messaging.message; + + +import com.keuin.crosslink.messaging.endpoint.IEndpoint; +import com.keuin.crosslink.messaging.sender.ISender; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.JoinConfiguration; +import net.kyori.adventure.text.format.Style; +import net.kyori.adventure.text.format.TextDecoration; +import net.md_5.bungee.api.chat.BaseComponent; +import net.md_5.bungee.api.chat.ComponentBuilder; +import org.jetbrains.annotations.NotNull; + +import java.util.Objects; + +/** + * Immutable. + */ +public interface IMessage { + @NotNull ISender sender(); + + @NotNull IEndpoint source(); + + @NotNull String pureString(); + + default Component kyoriMessage() { + // FIXME keep color data + return Component.text().content(pureString()).build(); + } + + static IMessage create(@NotNull IMessage message) { + Objects.requireNonNull(message); + return IMessage.create(message.source(), message.sender(), message.kyoriMessage()); + } + + /** + * Create a text message with given pure text content. + */ + static IMessage create(@NotNull IEndpoint source, @NotNull ISender sender, @NotNull String content) { + Objects.requireNonNull(source); + Objects.requireNonNull(sender); + Objects.requireNonNull(content); + return new TextBackedMessage(source, sender, content); + } + + static IMessage create(IEndpoint source, ISender sender, Component component) { + Objects.requireNonNull(component); + Objects.requireNonNull(sender); + return new ComponentBackedMessage(source, sender, component); + } + + default BaseComponent[] bungeeMessage() { + // FIXME keep color data + return new ComponentBuilder().append(pureString()).create(); + } + + default Component velocityMessage() { + return kyoriMessage(); + } + + /** + * Get the component with sender id and message content. + * Suitable for displaying in BungeeCord sub-servers directly. + */ + default BaseComponent[] bungeeDisplay() { + return new ComponentBuilder() + .append(new ComponentBuilder(String.format("<%s@%s>", sender().plainTextId(), source().friendlyName())) + .italic(true).create()) + .append(new ComponentBuilder(" ").italic(false).create()) + .append(bungeeMessage()) + .create(); + } + + /** + * Get the component with sender id and message content. + * Suitable for displaying in Velocity sub-servers directly. + */ + default Component velocityDisplay() { + var cfg = JoinConfiguration.builder().separator(Component.text(" ")).build(); + return Component.join(cfg, + Component.text() + .content(String.format("<%s@%s>", sender().plainTextId(), source().friendlyName())) + .style(Style.style(TextDecoration.ITALIC)).build(), velocityMessage() + ); + } + + /** + * Get the plain text form of this message, containing sender and source information. + * This can be used to display this message in a plain text environment, such as command line, or text file. + * @return the plain text form of this message. + */ + default String plainTextDisplay() { + return String.format("<%s@%s> %s", sender().plainTextId(), source().friendlyName(), pureString()); + } +} diff --git a/src/main/java/com/keuin/crosslink/messaging/message/TextBackedMessage.java b/src/main/java/com/keuin/crosslink/messaging/message/TextBackedMessage.java new file mode 100644 index 0000000..576bb91 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/messaging/message/TextBackedMessage.java @@ -0,0 +1,53 @@ +package com.keuin.crosslink.messaging.message; + +import com.keuin.crosslink.messaging.endpoint.IEndpoint; +import com.keuin.crosslink.messaging.sender.ISender; +import net.kyori.adventure.text.Component; +import org.jetbrains.annotations.NotNull; + +import java.util.Objects; + +class TextBackedMessage implements IMessage { + + private final IEndpoint source; + private final ISender sender; + private final String content; + + TextBackedMessage(@NotNull IEndpoint source, @NotNull ISender sender, @NotNull String content) { + Objects.requireNonNull(source); + Objects.requireNonNull(sender); + Objects.requireNonNull(content); + this.source = source; + this.sender = sender; + this.content = content; + } + + @Override + public @NotNull ISender sender() { + return sender; + } + + @Override + public @NotNull IEndpoint source() { + return source; + } + + @Override + public @NotNull String pureString() { + return content; + } + + @Override + public Component kyoriMessage() { + return Component.text(content); + } + + @Override + public String toString() { + return "TextBackedMessage{" + + "source=" + source + + ", sender=" + sender + + ", content='" + content + '\'' + + '}'; + } +} diff --git a/src/main/java/com/keuin/crosslink/messaging/router/ConcreteRouter.java b/src/main/java/com/keuin/crosslink/messaging/router/ConcreteRouter.java new file mode 100644 index 0000000..6433f8e --- /dev/null +++ b/src/main/java/com/keuin/crosslink/messaging/router/ConcreteRouter.java @@ -0,0 +1,78 @@ +package com.keuin.crosslink.messaging.router; + +import com.keuin.crosslink.messaging.endpoint.IEndpoint; +import com.keuin.crosslink.messaging.message.IMessage; +import com.keuin.crosslink.messaging.rule.IRule; +import com.keuin.crosslink.util.LoggerNaming; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.*; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +public class ConcreteRouter implements IRouter { + private final Map<String, Map<String, IEndpoint>> endpoints = new HashMap<>(); // namespace / id / endpoint + private final AtomicBoolean isOpened = new AtomicBoolean(true); + private volatile List<IRule> ruleChain = Collections.emptyList(); + private static final Logger logger = + LoggerFactory.getLogger(LoggerNaming.name().of("router").of("impl").toString()); + + @Override + public synchronized boolean addEndpoint(@NotNull IEndpoint endpoint) { + var ns = endpoint.namespace().toString(); + if (!endpoints.containsKey(ns)) { + endpoints.put(ns, new HashMap<>()); + } + var map = endpoints.get(ns); + if (map.containsKey(endpoint.id())) { + logger.error("Endpoint {} is already added into router.", endpoint.namespacedId()); + return false; // already exists + } + endpoint.setRouter(this); + logger.debug("Added endpoint \"" + endpoint.namespacedId() + "\"."); + map.put(endpoint.id(), endpoint); + return true; + } + + @Override + public @NotNull Set<IEndpoint> resolveEndpoints(@NotNull String namespace, @NotNull Pattern idPattern) { + Objects.requireNonNull(namespace); + Objects.requireNonNull(idPattern); + return Optional.ofNullable(endpoints.get(namespace)) + .map(m -> m.entrySet().stream() + .filter((ent) -> idPattern.matcher(ent.getKey()).matches()) + .map(Map.Entry::getValue) + .collect(Collectors.toUnmodifiableSet()) + ).orElse(Collections.emptySet()); + } + + @Override + public void updateRuleChain(@NotNull List<IRule> newChain) { + this.ruleChain = List.copyOf(newChain); + } + + @Override + public void sendMessage(IMessage message) { + logger.debug("Routing message " + message); + if (!isOpened.get()) { + throw new IllegalStateException("Router is closed"); + } + for (IRule rule : ruleChain) { + logger.debug("Applying rule " + rule + " on message " + message); + var result = rule.process(message); + if (result.isDropped()) { + // the message is dropped when processed by this rule + // stop processing and do not pass to next rules + break; + } + } + } + + @Override + public void close() throws Exception { + isOpened.set(false); + } +} diff --git a/src/main/java/com/keuin/crosslink/messaging/router/IRouter.java b/src/main/java/com/keuin/crosslink/messaging/router/IRouter.java new file mode 100644 index 0000000..3e5dbd6 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/messaging/router/IRouter.java @@ -0,0 +1,18 @@ +package com.keuin.crosslink.messaging.router; + +import com.keuin.crosslink.messaging.message.IMessage; + +public interface IRouter extends AutoCloseable, IRouterConfigurable { + /** + * Put a message into router. Called by implementation of endpoints. + * Router will scan the rule chain and pass the message to the rules one by one. + * Rules can filter, manipulate, and route messages. + * One message may have zero, one, or multiple final destinations. + * + * @param message the message to be routed. + */ + void sendMessage(IMessage message); + + @Override + void close() throws Exception; +} diff --git a/src/main/java/com/keuin/crosslink/messaging/router/IRouterConfigurable.java b/src/main/java/com/keuin/crosslink/messaging/router/IRouterConfigurable.java new file mode 100644 index 0000000..d1871b9 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/messaging/router/IRouterConfigurable.java @@ -0,0 +1,44 @@ +package com.keuin.crosslink.messaging.router; + +import com.keuin.crosslink.messaging.endpoint.IEndpoint; +import com.keuin.crosslink.messaging.rule.IRule; +import org.jetbrains.annotations.NotNull; + +import java.util.List; +import java.util.Set; +import java.util.regex.Pattern; + +/** + * Contains methods that are essential to router configuring, while not used by general routing routines. + */ +public interface IRouterConfigurable { + class ConfigLoadException extends Exception { + public ConfigLoadException() { + } + + public ConfigLoadException(String message) { + super(message); + } + + public ConfigLoadException(String message, Throwable cause) { + super(message, cause); + } + + public ConfigLoadException(Throwable cause) { + super(cause); + } + } + + boolean addEndpoint(@NotNull IEndpoint endpoint); + + /** + * Get endpoints satisfying given conditions on namespace and id. + * + * @param namespace namespace of endpoints. Only endpoints with this namespace will be returned. + * @param idPattern regexp pattern to match id. Only endpoints with id matching this pattern will be returned. + * @return all matched endpoints. + */ + @NotNull Set<IEndpoint> resolveEndpoints(@NotNull String namespace, @NotNull Pattern idPattern); + + void updateRuleChain(@NotNull List<IRule> newChain); +} diff --git a/src/main/java/com/keuin/crosslink/messaging/rule/IRule.java b/src/main/java/com/keuin/crosslink/messaging/rule/IRule.java new file mode 100644 index 0000000..3a67bf8 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/messaging/rule/IRule.java @@ -0,0 +1,26 @@ +package com.keuin.crosslink.messaging.rule; + +import com.keuin.crosslink.messaging.action.IAction; +import com.keuin.crosslink.messaging.action.result.IActionResult; +import com.keuin.crosslink.messaging.filter.IFilter; +import com.keuin.crosslink.messaging.message.IMessage; +import org.jetbrains.annotations.NotNull; + +import java.util.List; + +public interface IRule { + /** + * Process a message and decide whether to send to the next rule. + * + * @param message the message. + * @return true if send to next rule, false if discarded. + */ + IActionResult process(@NotNull IMessage message); + + @NotNull ObjectType object(); + + @NotNull IFilter from(); + + @NotNull List<IAction> actions(); + +} diff --git a/src/main/java/com/keuin/crosslink/messaging/rule/ImmutableRule.java b/src/main/java/com/keuin/crosslink/messaging/rule/ImmutableRule.java new file mode 100644 index 0000000..d57d1a5 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/messaging/rule/ImmutableRule.java @@ -0,0 +1,61 @@ +package com.keuin.crosslink.messaging.rule; + +import com.keuin.crosslink.messaging.action.IAction; +import com.keuin.crosslink.messaging.action.result.IActionResult; +import com.keuin.crosslink.messaging.filter.IFilter; +import com.keuin.crosslink.messaging.message.IMessage; +import org.jetbrains.annotations.NotNull; + +import java.util.List; +import java.util.Objects; + +public class ImmutableRule implements IRule { + private final ObjectType object; + private final IFilter from; + private final List<IAction> actions; + + public ImmutableRule(@NotNull ObjectType object, @NotNull IFilter from, @NotNull List<IAction> actions) { + Objects.requireNonNull(object); + Objects.requireNonNull(from); + Objects.requireNonNull(actions); + this.object = object; + this.from = from; + this.actions = List.copyOf(actions); + } + + @Override + public IActionResult process(@NotNull IMessage message) { + if (!from.filter(message.source())) return IActionResult.filtered(); // "form" does not match, pass through + var result = IActionResult.normal(Objects.requireNonNull(message)); + for (IAction action : actions) { + if (result.isFiltered() || result.isDropped()) break; + result = action.process(Objects.requireNonNull(result.getResult())); + Objects.requireNonNull(result); + } + return result; + } + + @Override + public @NotNull ObjectType object() { + return object; + } + + @Override + public @NotNull IFilter from() { + return from; + } + + @Override + public @NotNull List<IAction> actions() { + return actions; + } + + @Override + public String toString() { + return "ImmutableRule{" + + "object=" + object + + ", from=" + from + + ", actions=" + actions + + '}'; + } +} diff --git a/src/main/java/com/keuin/crosslink/messaging/rule/ObjectType.java b/src/main/java/com/keuin/crosslink/messaging/rule/ObjectType.java new file mode 100644 index 0000000..e224fc6 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/messaging/rule/ObjectType.java @@ -0,0 +1,26 @@ +package com.keuin.crosslink.messaging.rule; + +import org.jetbrains.annotations.Nullable; + +import java.util.Objects; + +public enum ObjectType { + CHAT_MESSAGE("chat_message"); + private final String name; + + ObjectType(String name) { + this.name = name; + } + + public String getName() { + return name; + } + + public static @Nullable ObjectType of(String s) { + Objects.requireNonNull(s); + for (ObjectType v : ObjectType.values()) { + if (v.name.equals(s)) return v; + } + return null; + } +} diff --git a/src/main/java/com/keuin/crosslink/messaging/sender/GamePlayerSender.java b/src/main/java/com/keuin/crosslink/messaging/sender/GamePlayerSender.java new file mode 100644 index 0000000..be9b91e --- /dev/null +++ b/src/main/java/com/keuin/crosslink/messaging/sender/GamePlayerSender.java @@ -0,0 +1,37 @@ +package com.keuin.crosslink.messaging.sender; + +import org.jetbrains.annotations.NotNull; + +import java.util.Objects; +import java.util.UUID; + +class GamePlayerSender implements UniquelyIdentifiedSender { + + private final UUID uuid; + private final String id; + + GamePlayerSender(@NotNull UUID uuid, @NotNull String id) { + Objects.requireNonNull(uuid); + Objects.requireNonNull(id); + this.uuid = uuid; + this.id = id; + } + + @Override + public @NotNull String plainTextId() { + return id; + } + + @Override + public @NotNull UUID uuid() { + return uuid; + } + + @Override + public String toString() { + return "GamePlayerSender{" + + "uuid=" + uuid + + ", id='" + id + '\'' + + '}'; + } +} diff --git a/src/main/java/com/keuin/crosslink/messaging/sender/ISender.java b/src/main/java/com/keuin/crosslink/messaging/sender/ISender.java new file mode 100644 index 0000000..3dd17d2 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/messaging/sender/ISender.java @@ -0,0 +1,13 @@ +package com.keuin.crosslink.messaging.sender; + +import org.jetbrains.annotations.NotNull; + +import java.util.UUID; + +public interface ISender { + @NotNull String plainTextId(); + + static ISender create(String id, UUID uuid) { + return new GamePlayerSender(uuid, id); + } +} diff --git a/src/main/java/com/keuin/crosslink/messaging/sender/UniquelyIdentifiedSender.java b/src/main/java/com/keuin/crosslink/messaging/sender/UniquelyIdentifiedSender.java new file mode 100644 index 0000000..69b4fda --- /dev/null +++ b/src/main/java/com/keuin/crosslink/messaging/sender/UniquelyIdentifiedSender.java @@ -0,0 +1,9 @@ +package com.keuin.crosslink.messaging.sender; + +import org.jetbrains.annotations.NotNull; + +import java.util.UUID; + +public interface UniquelyIdentifiedSender extends ISender { + @NotNull UUID uuid(); +} diff --git a/src/main/java/com/keuin/crosslink/messaging/util/Messaging.java b/src/main/java/com/keuin/crosslink/messaging/util/Messaging.java new file mode 100644 index 0000000..ff7a600 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/messaging/util/Messaging.java @@ -0,0 +1,30 @@ +package com.keuin.crosslink.messaging.util; + +import com.keuin.crosslink.messaging.filter.ReIdFilter; +import net.kyori.adventure.text.Component; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Objects; +import java.util.regex.Pattern; + +public class Messaging { + public static @NotNull Component duplicate(@NotNull Component source) { + // FIXME non-text-based message may lose information? + Objects.requireNonNull(source); + return Component.text().append(source).build(); + } + + public static @Nullable String[] splitIdSelector(@NotNull String pattern) { + Objects.requireNonNull(pattern); + if (Pattern.compile("\\s").matcher(pattern).find()) { + return null; + } + var parts = pattern.split(":"); + if (parts.length != 2 || pattern.startsWith(":") || pattern.endsWith(":")) { + // the heading or trailing ':' does not count, so here we got >= 3 ':' in the string + return null; + } + return parts; + } +} diff --git a/src/main/java/com/keuin/crosslink/plugin/bungee/BungeeAccessor.java b/src/main/java/com/keuin/crosslink/plugin/bungee/BungeeAccessor.java new file mode 100644 index 0000000..957927e --- /dev/null +++ b/src/main/java/com/keuin/crosslink/plugin/bungee/BungeeAccessor.java @@ -0,0 +1,57 @@ +package com.keuin.crosslink.plugin.bungee; + +import com.google.inject.Inject; +import com.keuin.crosslink.config.GlobalConfigManager; +import com.keuin.crosslink.data.PlayerInfo; +import com.keuin.crosslink.data.ServerInfo; +import com.keuin.crosslink.messaging.endpoint.IEndpoint; +import com.keuin.crosslink.messaging.endpoint.local.BungeeServerChatEndpoint; +import com.keuin.crosslink.plugin.bungee.checker.BungeeServerStatusChecker; +import com.keuin.crosslink.plugin.common.ICoreAccessor; + +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.function.Consumer; +import java.util.stream.Collectors; + +public class BungeeAccessor implements ICoreAccessor { + + private final BungeeMainWrapper plugin; + + @Inject + public BungeeAccessor(BungeeMainWrapper plugin) { + this.plugin = plugin; + } + + @Override + public List<PlayerInfo> getOnlinePlayers() { + var players = plugin.getProxy().getPlayers(); + return players.stream().map(PlayerInfo::fromBungeePlayer).toList(); + } + + @Override + public List<PlayerInfo> getOnlinePlayers(String serverName) { + Objects.requireNonNull(serverName); + return getOnlinePlayers().stream().filter((player) -> serverName.equals(player.serverName())).toList(); + } + + @Override + public void getServerInfo(Consumer<List<ServerInfo>> callback) { + var checker = new BungeeServerStatusChecker( + plugin.getProxy().getServers().values(), + plugin, + GlobalConfigManager.getInstance().getConfig().pingTimeoutMillis() + ); + checker.ping((infoMap) -> callback.accept(infoMap.entrySet().stream().map( + (ent) -> new ServerInfo(ent.getKey().getName(), ent.getValue()) + ).toList())); + } + + @Override + public Set<IEndpoint> getServerEndpoints() { + return plugin.getProxy().getServers().values().stream() + .map((si) -> new BungeeServerChatEndpoint(si, plugin.getProxy(), plugin)) + .collect(Collectors.toUnmodifiableSet()); + } +} diff --git a/src/main/java/com/keuin/crosslink/plugin/bungee/BungeeMainWrapper.java b/src/main/java/com/keuin/crosslink/plugin/bungee/BungeeMainWrapper.java new file mode 100644 index 0000000..e3f9824 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/plugin/bungee/BungeeMainWrapper.java @@ -0,0 +1,46 @@ +package com.keuin.crosslink.plugin.bungee; + +import com.google.inject.Guice; +import com.google.inject.Injector; +import com.keuin.crosslink.plugin.bungee.module.BungeeAccessorModule; +import com.keuin.crosslink.plugin.bungee.module.BungeeApiServerModule; +import com.keuin.crosslink.plugin.common.PluginMain; +import com.keuin.crosslink.plugin.common.ProxyType; +import com.keuin.crosslink.plugin.common.environ.PluginEnvironment; +import com.keuin.crosslink.plugin.common.module.CommonApiServerProvider; +import com.keuin.crosslink.plugin.common.module.CommonIRouterModule; +import com.keuin.crosslink.plugin.common.module.CommonPluginEnvironProvider; +import com.keuin.crosslink.util.LoggerNaming; +import net.md_5.bungee.api.plugin.Plugin; +import org.slf4j.LoggerFactory; + +public final class BungeeMainWrapper extends Plugin { + + private final Injector injector = Guice.createInjector( + new BungeeAccessorModule(this), + new BungeeApiServerModule(), + new CommonIRouterModule(), + new CommonPluginEnvironProvider(new PluginEnvironment( + ProxyType.BUNGEECORD, + LoggerFactory.getLogger(LoggerNaming.name().toString()), + getDataFolder().toPath())), + new CommonApiServerProvider() + ); + private final PluginMain plugin = injector.getInstance(PluginMain.class); + + @Override + public void onLoad() { + // print startup message + // do nothing here + } + + @Override + public void onEnable() { + plugin.enable(); + } + + @Override + public void onDisable() { + plugin.disable(); + } +}
\ No newline at end of file diff --git a/src/main/java/com/keuin/crosslink/plugin/bungee/checker/BungeeServerStatusChecker.java b/src/main/java/com/keuin/crosslink/plugin/bungee/checker/BungeeServerStatusChecker.java new file mode 100644 index 0000000..7849f88 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/plugin/bungee/checker/BungeeServerStatusChecker.java @@ -0,0 +1,43 @@ +package com.keuin.crosslink.plugin.bungee.checker; + + +import com.keuin.crosslink.data.ServerStatus; +import com.keuin.crosslink.plugin.common.BaseServerChecker; +import net.md_5.bungee.api.config.ServerInfo; +import net.md_5.bungee.api.plugin.Plugin; +import org.jetbrains.annotations.NotNull; + +import java.util.Collection; +import java.util.function.Consumer; + +public class BungeeServerStatusChecker extends BaseServerChecker<ServerInfo> { + + private final Plugin plugin; + + public BungeeServerStatusChecker(@NotNull Collection<ServerInfo> servers, @NotNull Plugin plugin, int timeoutMillis) { + super(servers, timeoutMillis); + this.plugin = plugin; + } + + @Override + protected void scheduleTask(Runnable task) { + plugin.getProxy().getScheduler().runAsync(plugin, task); + } + + @Override + protected void pingServer(ServerInfo server, Consumer<ServerStatus> callback) { + server.ping((result, error) -> { + var isUp = result != null && error == null; + var builder = new StringBuilder(); + builder.append(String.format("Server %s is %s.", server.getName(), isUp ? "up" : "down")).append(". "); + if (result != null) { + builder.append("MOTD: ").append(result.getDescriptionComponent().toPlainText()).append(" "); + } + if (error != null) { + builder.append("Error: ").append(error.getClass().getName()).append(" ").append(error.getMessage()); + } + logger.debug(builder.toString()); + callback.accept(isUp ? ServerStatus.ONLINE : ServerStatus.OFFLINE); + }); + } +}
\ No newline at end of file diff --git a/src/main/java/com/keuin/crosslink/plugin/bungee/module/BungeeAccessorModule.java b/src/main/java/com/keuin/crosslink/plugin/bungee/module/BungeeAccessorModule.java new file mode 100644 index 0000000..3cd120d --- /dev/null +++ b/src/main/java/com/keuin/crosslink/plugin/bungee/module/BungeeAccessorModule.java @@ -0,0 +1,18 @@ +package com.keuin.crosslink.plugin.bungee.module; + +import com.google.inject.AbstractModule; +import com.google.inject.Provides; +import com.keuin.crosslink.plugin.bungee.BungeeMainWrapper; + +public class BungeeAccessorModule extends AbstractModule { + private final BungeeMainWrapper plugin; + + public BungeeAccessorModule(BungeeMainWrapper plugin) { + this.plugin = plugin; + } + + @Provides + BungeeMainWrapper getPlugin() { + return this.plugin; + } +} diff --git a/src/main/java/com/keuin/crosslink/plugin/bungee/module/BungeeApiServerModule.java b/src/main/java/com/keuin/crosslink/plugin/bungee/module/BungeeApiServerModule.java new file mode 100644 index 0000000..c8f3b4d --- /dev/null +++ b/src/main/java/com/keuin/crosslink/plugin/bungee/module/BungeeApiServerModule.java @@ -0,0 +1,12 @@ +package com.keuin.crosslink.plugin.bungee.module; + +import com.google.inject.AbstractModule; +import com.keuin.crosslink.plugin.bungee.BungeeAccessor; +import com.keuin.crosslink.plugin.common.ICoreAccessor; + +public class BungeeApiServerModule extends AbstractModule { + @Override + protected void configure() { + bind(ICoreAccessor.class).to(BungeeAccessor.class); + } +} diff --git a/src/main/java/com/keuin/crosslink/plugin/common/BaseServerChecker.java b/src/main/java/com/keuin/crosslink/plugin/common/BaseServerChecker.java new file mode 100644 index 0000000..2031a2a --- /dev/null +++ b/src/main/java/com/keuin/crosslink/plugin/common/BaseServerChecker.java @@ -0,0 +1,106 @@ +package com.keuin.crosslink.plugin.common; + +import com.keuin.crosslink.data.ServerStatus; +import com.keuin.crosslink.util.LoggerNaming; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; + +/** + * Get online status of given sub servers. + * Copied from legacy BungeeCross code and generalized. + */ +public abstract class BaseServerChecker<S> { + + protected static final Logger logger = + LoggerFactory.getLogger(LoggerNaming.name().of("common").of("server_checker").toString()); + private final Set<S> servers; + + private final AtomicBoolean isRunning = new AtomicBoolean(false); + private final Map<S, ServerStatus> pingResult = new ConcurrentHashMap<>(); + private final AtomicInteger pingCountdown; + private final int timeoutMillis; + + protected BaseServerChecker(@NotNull Collection<S> servers, + int timeoutMillis) { + this.servers = new HashSet<>(servers); + this.timeoutMillis = timeoutMillis; + this.pingCountdown = new AtomicInteger(this.servers.size()); + this.servers.forEach(s -> pingResult.put(s, ServerStatus.TIMED_OUT)); + for (S server : this.servers) { + Objects.requireNonNull(server); + // initialize with TIMED OUT status, + // which is default when no response received in waiting time range + pingResult.put(server, ServerStatus.TIMED_OUT); + } + } + + protected abstract void scheduleTask(Runnable task); + + // The implementation should fire an asynchronous routine to ping, then call the callback when finishes. + // In one word, this routine not block. + protected abstract void pingServer(S server, Consumer<ServerStatus> callback); + + /** + * Perform ping asynchronously. + * + * @param callback the callback. Will be invoked asynchronously. + */ + public final void ping(Consumer<Map<S, ServerStatus>> callback) { + if (isRunning.getAndSet(true)) { + // already running, do not start new threads + throw new IllegalStateException("The ServerStatusChecker is already started"); + } + logger.debug("Start pinging."); + + final Object finishEvent = new Object(); +// final Consumer<Runnable> scheduler = (Runnable consumer) -> +// plugin.getProxy().getScheduler().runAsync(plugin, consumer); + + // async ping + scheduleTask(() -> servers.forEach(server -> { + logger.debug("Async ping server " + server); + pingServer(server, (status) -> { + logger.debug("Result: server " + server + ", status " + status); + Objects.requireNonNull(status); + this.pingResult.put(server, status); + var remaining = pingCountdown.decrementAndGet(); + logger.debug("Not responded servers: {}.", remaining); + if (remaining == 0) { + synchronized (finishEvent) { + finishEvent.notifyAll(); + } + } + }); + })); + + // async wait and invoke callback + scheduleTask(() -> { + // wait until all server respond + // or timed out + var startTime = System.currentTimeMillis(); + int remaining; + while ((remaining = pingCountdown.get()) != 0 + && (System.currentTimeMillis() - startTime <= timeoutMillis)) { + logger.debug("Waiting for finish. remaining={}.", remaining); + try { + synchronized (finishEvent) { + finishEvent.wait(1000, 0); + } + } catch (InterruptedException ignored) { + } + } + + // all server have responded, or at least one server has timed out + // copy to ignore further result + callback.accept(new HashMap<>(pingResult)); + }); + } + +} diff --git a/src/main/java/com/keuin/crosslink/plugin/common/ICoreAccessor.java b/src/main/java/com/keuin/crosslink/plugin/common/ICoreAccessor.java new file mode 100644 index 0000000..4f0f0e0 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/plugin/common/ICoreAccessor.java @@ -0,0 +1,19 @@ +package com.keuin.crosslink.plugin.common; + +import com.keuin.crosslink.data.PlayerInfo; +import com.keuin.crosslink.data.ServerInfo; +import com.keuin.crosslink.messaging.endpoint.IEndpoint; + +import java.util.List; +import java.util.Set; +import java.util.function.Consumer; + +public interface ICoreAccessor { + List<PlayerInfo> getOnlinePlayers(); + + List<PlayerInfo> getOnlinePlayers(String serverName); + + void getServerInfo(Consumer<List<ServerInfo>> callback); + + Set<IEndpoint> getServerEndpoints(); +} diff --git a/src/main/java/com/keuin/crosslink/plugin/common/PluginMain.java b/src/main/java/com/keuin/crosslink/plugin/common/PluginMain.java new file mode 100644 index 0000000..288af95 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/plugin/common/PluginMain.java @@ -0,0 +1,156 @@ +package com.keuin.crosslink.plugin.common; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.inject.Inject; +import com.keuin.crosslink.api.IApiServer; +import com.keuin.crosslink.api.error.ApiStartupException; +import com.keuin.crosslink.messaging.config.ConfigSyntaxError; +import com.keuin.crosslink.messaging.config.remote.InvalidEndpointConfigurationException; +import com.keuin.crosslink.messaging.config.remote.RemoteEndpointFactory; +import com.keuin.crosslink.messaging.config.router.RouterConfigurer; +import com.keuin.crosslink.messaging.endpoint.IEndpoint; +import com.keuin.crosslink.messaging.router.IRouter; +import com.keuin.crosslink.messaging.router.IRouterConfigurable; +import com.keuin.crosslink.plugin.common.environ.PluginEnvironment; +import com.keuin.crosslink.util.LoggerNaming; +import com.keuin.crosslink.util.StartupMessagePrinter; +import com.keuin.crosslink.util.version.NewVersionChecker; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.util.HashSet; +import java.util.Optional; + +public final class PluginMain { + private final PluginEnvironment environment; + private final IApiServer apiServer; + private final ICoreAccessor coreAccessor; + private final IRouter messageRouter; + private final Logger logger; + + @Inject + public PluginMain(ICoreAccessor coreAccessor, + IRouter messageRouter, + IApiServer apiServer, + PluginEnvironment pluginEnvironment) { + this.coreAccessor = coreAccessor; + this.messageRouter = messageRouter; + this.apiServer = apiServer; + this.environment = pluginEnvironment; + this.logger = environment.logger(); + // print startup message + StartupMessagePrinter.print(logger::info, pluginEnvironment.proxyType().getName()); + logger.debug("Debug logging is enabled. You may see logs more than usual."); + NewVersionChecker.checkNewVersionAsync( + LoggerFactory.getLogger(LoggerNaming.name().of("update").toString())::info, true); + // the plugin is not constructed here, so don't register event listener here + } + + public void enable() { + final ObjectMapper mapper = new ObjectMapper(); + mapper.configure(JsonParser.Feature.ALLOW_COMMENTS, true); + // TODO refactor setup and teardown routine, split into hooks + // load config + // TODO global config (such as API config) + logger.info("Loading message routing configuration."); + JsonNode messagingConfig = null, routingConfig = null, remoteConfig = null; + try (var fis = new FileInputStream(new File(environment.pluginDataPath().toFile(), "messaging.json"))) { + messagingConfig = Optional.ofNullable(mapper.readTree(fis)).orElse(mapper.readTree("{}")); + routingConfig = Optional.ofNullable(messagingConfig.get("routing")).orElse(mapper.readTree("[]")); + remoteConfig = Optional.ofNullable(messagingConfig.get("remotes")).orElse(mapper.readTree("[]")); + } catch (IOException ex) { + logger.error("Failed to load message routing configuration", ex); + throw new RuntimeException(ex); + } + + // initialize message routing + logger.info("Initializing message routing."); + var endpoints = new HashSet<IEndpoint>(); + try { + try { + logger.debug("Loading rule chain."); + var rc = new RouterConfigurer(routingConfig); + rc.configure(messageRouter); + logger.debug("Message router is configured successfully."); + } catch (JsonProcessingException | ConfigSyntaxError ex) { + throw new IRouterConfigurable.ConfigLoadException(ex); + } + //noinspection CollectionAddAllCanBeReplacedWithConstructor + endpoints.addAll(coreAccessor.getServerEndpoints()); + } catch (IRouter.ConfigLoadException ex) { + logger.error("Failed to read routing config", ex); + throw new RuntimeException(ex); + } + + try { + logger.debug("Loading remote endpoints."); + if (!remoteConfig.isArray()) { + logger.error("Failed to load remote endpoints: remotes should be a JSON array."); + throw new RuntimeException("Invalid remotes type"); + } + for (JsonNode remote : remoteConfig) { + var ep = RemoteEndpointFactory.create(remote); + if (ep != null) { + logger.debug("Add remote endpoint: " + ep); + endpoints.add(ep); + } + } + } catch (InvalidEndpointConfigurationException ex) { + logger.error("Invalid remote endpoint", ex); + throw new RuntimeException(ex); + } + + for (IEndpoint ep : endpoints) { + if (!messageRouter.addEndpoint(ep)) { + logger.error("Cannot add endpoint " + ep); + throw new RuntimeException("Cannot add endpoint " + ep); + } + } + logger.info(String.format("Added %d sub-server(s) to message router.", endpoints.size())); + + logger.info("Starting API server."); + try (var fis = new FileInputStream(new File(environment.pluginDataPath().toFile(), "api.json"))) { + var apiConfig = Optional.ofNullable(mapper.readTree(fis)).orElse(mapper.readTree("{}")); + var host = Optional.ofNullable(apiConfig.get("host")).map(JsonNode::textValue).orElse(null); + if (host == null + || host.isEmpty() + || (Character.digit(host.charAt(0), 16) == -1 && (host.charAt(0) != ':'))) { + throw new ApiStartupException("Invalid inet host to listen on"); + } + int port = Optional.ofNullable(apiConfig.get("port")).map(JsonNode::intValue).orElse(-1); + if (port <= 0) throw new ApiStartupException("Invalid port to listen on"); + // now the host is guaranteed to be an IP address string, so no DNS lookup will be performed + apiServer.startup(new InetSocketAddress(InetAddress.getByName(host), port)); + } catch (IOException ex) { + logger.error("Failed to load message routing configuration", ex); + throw new RuntimeException(ex); + } catch (ApiStartupException ex) { + logger.error("Failed to start API server", ex); + return; + } + logger.info("CrossLink is enabled."); + } + + public void disable() { + logger.info("Stopping API server."); + apiServer.shutdown(); + logger.info("CrossLink is disabled."); + } + + // may throw unchecked exception + public void reload() { + // TODO make api server and router reloadable + } + + private String capital(String s) { + return s.substring(0, 1).toUpperCase() + s.substring(1); + } +} diff --git a/src/main/java/com/keuin/crosslink/plugin/common/ProxyType.java b/src/main/java/com/keuin/crosslink/plugin/common/ProxyType.java new file mode 100644 index 0000000..0270da1 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/plugin/common/ProxyType.java @@ -0,0 +1,14 @@ +package com.keuin.crosslink.plugin.common; + +public enum ProxyType { + BUNGEECORD("BungeeCord"), VELOCITY("Velocity"); + private final String name; + + ProxyType(String name) { + this.name = name; + } + + public String getName() { + return name; + } +} diff --git a/src/main/java/com/keuin/crosslink/plugin/common/environ/PluginEnvironment.java b/src/main/java/com/keuin/crosslink/plugin/common/environ/PluginEnvironment.java new file mode 100644 index 0000000..0864102 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/plugin/common/environ/PluginEnvironment.java @@ -0,0 +1,9 @@ +package com.keuin.crosslink.plugin.common.environ; + +import com.keuin.crosslink.plugin.common.ProxyType; +import org.slf4j.Logger; + +import java.nio.file.Path; + +public record PluginEnvironment(ProxyType proxyType, Logger logger, Path pluginDataPath) { +}
\ No newline at end of file diff --git a/src/main/java/com/keuin/crosslink/plugin/common/module/CommonApiServerProvider.java b/src/main/java/com/keuin/crosslink/plugin/common/module/CommonApiServerProvider.java new file mode 100644 index 0000000..6498d7f --- /dev/null +++ b/src/main/java/com/keuin/crosslink/plugin/common/module/CommonApiServerProvider.java @@ -0,0 +1,12 @@ +package com.keuin.crosslink.plugin.common.module; + +import com.google.inject.AbstractModule; +import com.keuin.crosslink.api.ApiServer; +import com.keuin.crosslink.api.IApiServer; + +public class CommonApiServerProvider extends AbstractModule { + @Override + protected void configure() { + bind(IApiServer.class).to(ApiServer.class); + } +} diff --git a/src/main/java/com/keuin/crosslink/plugin/common/module/CommonIRouterModule.java b/src/main/java/com/keuin/crosslink/plugin/common/module/CommonIRouterModule.java new file mode 100644 index 0000000..f5de45d --- /dev/null +++ b/src/main/java/com/keuin/crosslink/plugin/common/module/CommonIRouterModule.java @@ -0,0 +1,12 @@ +package com.keuin.crosslink.plugin.common.module; + +import com.google.inject.AbstractModule; +import com.keuin.crosslink.messaging.router.ConcreteRouter; +import com.keuin.crosslink.messaging.router.IRouter; + +public class CommonIRouterModule extends AbstractModule { + @Override + protected void configure() { + bind(IRouter.class).to(ConcreteRouter.class); + } +} diff --git a/src/main/java/com/keuin/crosslink/plugin/common/module/CommonPluginEnvironProvider.java b/src/main/java/com/keuin/crosslink/plugin/common/module/CommonPluginEnvironProvider.java new file mode 100644 index 0000000..361e6a4 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/plugin/common/module/CommonPluginEnvironProvider.java @@ -0,0 +1,18 @@ +package com.keuin.crosslink.plugin.common.module; + +import com.google.inject.AbstractModule; +import com.google.inject.Provides; +import com.keuin.crosslink.plugin.common.environ.PluginEnvironment; + +public class CommonPluginEnvironProvider extends AbstractModule { + private final PluginEnvironment pluginEnvironment; + + public CommonPluginEnvironProvider(PluginEnvironment pluginEnvironment) { + this.pluginEnvironment = pluginEnvironment; + } + + @Provides + PluginEnvironment getPluginEnvironment() { + return this.pluginEnvironment; + } +} diff --git a/src/main/java/com/keuin/crosslink/plugin/velocity/VelocityAccessor.java b/src/main/java/com/keuin/crosslink/plugin/velocity/VelocityAccessor.java new file mode 100644 index 0000000..f541139 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/plugin/velocity/VelocityAccessor.java @@ -0,0 +1,54 @@ +package com.keuin.crosslink.plugin.velocity; + +import com.google.inject.Inject; +import com.keuin.crosslink.data.PlayerInfo; +import com.keuin.crosslink.data.ServerInfo; +import com.keuin.crosslink.messaging.endpoint.IEndpoint; +import com.keuin.crosslink.messaging.endpoint.local.VelocityServerChatEndpoint; +import com.keuin.crosslink.plugin.common.ICoreAccessor; +import com.keuin.crosslink.plugin.velocity.checker.VelocityServerStatusChecker; + +import java.util.List; +import java.util.Set; +import java.util.function.Consumer; +import java.util.stream.Collectors; + +public class VelocityAccessor implements ICoreAccessor { + private final VelocityMainWrapper plugin; + + @Inject + public VelocityAccessor(VelocityMainWrapper plugin) { + this.plugin = plugin; + // TODO handle shutdown event + } + + @Override + public List<PlayerInfo> getOnlinePlayers() { + return plugin.getProxy().getAllPlayers().stream().map(PlayerInfo::fromVelocityPlayer).toList(); + } + + @Override + public List<PlayerInfo> getOnlinePlayers(String serverName) { + return getOnlinePlayers().stream().filter((player) -> serverName.equals(player.serverName())).toList(); + } + + @Override + public void getServerInfo(Consumer<List<ServerInfo>> callback) { + var checker = new VelocityServerStatusChecker( + plugin.getProxy().getAllServers(), + plugin, + //GlobalConfigManager.getInstance().getConfig().pingTimeoutMillis() + 2000 + ); + checker.ping((infoMap) -> callback.accept(infoMap.entrySet().stream().map( + (ent) -> new ServerInfo(ent.getKey().getServerInfo().getName(), ent.getValue()) + ).toList())); + } + + @Override + public Set<IEndpoint> getServerEndpoints() { + return plugin.getProxy().getAllServers().stream() + .map((si) -> new VelocityServerChatEndpoint(si, plugin.getProxy(), plugin)) + .collect(Collectors.toUnmodifiableSet()); + } +} diff --git a/src/main/java/com/keuin/crosslink/plugin/velocity/VelocityMainWrapper.java b/src/main/java/com/keuin/crosslink/plugin/velocity/VelocityMainWrapper.java new file mode 100644 index 0000000..f6a7cc5 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/plugin/velocity/VelocityMainWrapper.java @@ -0,0 +1,62 @@ +package com.keuin.crosslink.plugin.velocity; + +import com.google.inject.Guice; +import com.google.inject.Inject; +import com.keuin.crosslink.plugin.common.PluginMain; +import com.keuin.crosslink.plugin.common.ProxyType; +import com.keuin.crosslink.plugin.common.environ.PluginEnvironment; +import com.keuin.crosslink.plugin.common.module.CommonApiServerProvider; +import com.keuin.crosslink.plugin.common.module.CommonIRouterModule; +import com.keuin.crosslink.plugin.common.module.CommonPluginEnvironProvider; +import com.keuin.crosslink.plugin.velocity.module.VelocityAccessorModule; +import com.keuin.crosslink.plugin.velocity.module.VelocityApiServerModule; +import com.keuin.crosslink.util.LoggerNaming; +import com.velocitypowered.api.event.Subscribe; +import com.velocitypowered.api.event.proxy.ProxyInitializeEvent; +import com.velocitypowered.api.event.proxy.ProxyReloadEvent; +import com.velocitypowered.api.event.proxy.ProxyShutdownEvent; +import com.velocitypowered.api.plugin.Plugin; +import com.velocitypowered.api.plugin.annotation.DataDirectory; +import com.velocitypowered.api.proxy.ProxyServer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.nio.file.Path; + +// Velocity plugin main class +// Initializes the core accessor and manages its life cycle (such as disabling the accessor when server is down) +@Plugin(id = "crosslink", name = "CrossLink", version = "1.0-SNAPSHOT", + description = "Link your grouped servers with external world.", authors = {"Keuin"}) +public final class VelocityMainWrapper { + private final ProxyServer proxy; + private final PluginMain plugin; + + @Subscribe + public void onProxyInitialization(ProxyInitializeEvent event) { + // reload event + proxy.getEventManager().register( + this, ProxyReloadEvent.class, (ev) -> plugin.reload()); + // shutdown event + proxy.getEventManager().register( + this, ProxyShutdownEvent.class, (ev) -> plugin.disable()); + plugin.enable(); + } + + @Inject + public VelocityMainWrapper(ProxyServer proxy, Logger logger, @DataDirectory Path pluginDataPath) { + this.proxy = proxy; + var injector = Guice.createInjector( + new VelocityAccessorModule(this), + new VelocityApiServerModule(), + new CommonIRouterModule(), + new CommonPluginEnvironProvider(new PluginEnvironment( + ProxyType.VELOCITY, LoggerFactory.getLogger(LoggerNaming.name().toString()), pluginDataPath)), + new CommonApiServerProvider() + ); + this.plugin = injector.getInstance(PluginMain.class); + } + + public ProxyServer getProxy() { + return proxy; + } +}
\ No newline at end of file diff --git a/src/main/java/com/keuin/crosslink/plugin/velocity/checker/VelocityServerStatusChecker.java b/src/main/java/com/keuin/crosslink/plugin/velocity/checker/VelocityServerStatusChecker.java new file mode 100644 index 0000000..0619c69 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/plugin/velocity/checker/VelocityServerStatusChecker.java @@ -0,0 +1,46 @@ +package com.keuin.crosslink.plugin.velocity.checker; + +import com.keuin.crosslink.data.ServerStatus; +import com.keuin.crosslink.plugin.common.BaseServerChecker; +import com.keuin.crosslink.plugin.velocity.VelocityMainWrapper; +import com.velocitypowered.api.proxy.server.RegisteredServer; +import org.jetbrains.annotations.NotNull; + +import java.util.Collection; +import java.util.Objects; +import java.util.function.Consumer; + +/** + * Ping some given velocity sub servers. + * Based on the shared asynchronous task firing code from legacy BungeeCross codebase. + */ +public class VelocityServerStatusChecker extends BaseServerChecker<RegisteredServer> { + + private final VelocityMainWrapper plugin; + + public VelocityServerStatusChecker(@NotNull Collection<RegisteredServer> servers, @NotNull VelocityMainWrapper plugin, int timeoutMillis) { + super(servers, timeoutMillis); + this.plugin = plugin; + } + + @Override + protected void scheduleTask(Runnable task) { + plugin.getProxy().getScheduler().buildTask(plugin, task).schedule(); + } + + @Override + protected void pingServer(RegisteredServer server, Consumer<ServerStatus> callback) { + server.ping().whenComplete((ping, ex) -> { + var status = ServerStatus.ONLINE; + if (ex != null) { + logger.warn(String.format("An exception occurred while pinging server %s", + server.getServerInfo().getName()), ex); + status = ServerStatus.OFFLINE; + } else { + // the implementation assures that ping is not null if no exception was thrown + Objects.requireNonNull(ping); + } + callback.accept(status); + }); + } +} diff --git a/src/main/java/com/keuin/crosslink/plugin/velocity/module/VelocityAccessorModule.java b/src/main/java/com/keuin/crosslink/plugin/velocity/module/VelocityAccessorModule.java new file mode 100644 index 0000000..6baaacf --- /dev/null +++ b/src/main/java/com/keuin/crosslink/plugin/velocity/module/VelocityAccessorModule.java @@ -0,0 +1,18 @@ +package com.keuin.crosslink.plugin.velocity.module; + +import com.google.inject.AbstractModule; +import com.google.inject.Provides; +import com.keuin.crosslink.plugin.velocity.VelocityMainWrapper; + +public class VelocityAccessorModule extends AbstractModule { + private final VelocityMainWrapper plugin; + + public VelocityAccessorModule(VelocityMainWrapper plugin) { + this.plugin = plugin; + } + + @Provides + VelocityMainWrapper getPlugin() { + return this.plugin; + } +} diff --git a/src/main/java/com/keuin/crosslink/plugin/velocity/module/VelocityApiServerModule.java b/src/main/java/com/keuin/crosslink/plugin/velocity/module/VelocityApiServerModule.java new file mode 100644 index 0000000..c50e145 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/plugin/velocity/module/VelocityApiServerModule.java @@ -0,0 +1,12 @@ +package com.keuin.crosslink.plugin.velocity.module; + +import com.google.inject.AbstractModule; +import com.keuin.crosslink.plugin.common.ICoreAccessor; +import com.keuin.crosslink.plugin.velocity.VelocityAccessor; + +public class VelocityApiServerModule extends AbstractModule { + @Override + protected void configure() { + bind(ICoreAccessor.class).to(VelocityAccessor.class); + } +} diff --git a/src/main/java/com/keuin/crosslink/util/AsciiArtPrinter.java b/src/main/java/com/keuin/crosslink/util/AsciiArtPrinter.java new file mode 100644 index 0000000..f7bad05 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/util/AsciiArtPrinter.java @@ -0,0 +1,33 @@ +package com.keuin.crosslink.util; + +import java.util.List; +import java.util.Random; +import java.util.function.Consumer; + +final class AsciiArtPrinter { + private static final Random random = new Random(); + private static final List<List<String>> ASCII_ARTS = List.of( + List.of( + " _____ _ _ _ ", + "/ __ \\ | | (_) | | ", + "| / \\/_ __ ___ ___ ___ | | _ _ __ | | __", + "| | | '__/ _ \\/ __/ __|| | | | '_ \\| |/ /", + "| \\__/\\ | | (_) \\__ \\__ \\| |___| | | | | < ", + " \\____/_| \\___/|___/___/\\_____/_|_| |_|_|\\_\\" + ), + List.of( + " ___ __ _ _ ", + " / __\\ __ ___ ___ ___ / /(_)_ __ | | __", + " / / | '__/ _ \\/ __/ __| / / | | '_ \\| |/ /", + "/ /__| | | (_) \\__ \\__ \\/ /__| | | | | < ", + "\\____/_| \\___/|___/___/\\____/_|_| |_|_|\\_\\" + ) + ); + + + public static void print(Consumer<String> linePrinter) { + linePrinter.accept(""); + ASCII_ARTS.get(random.nextInt(ASCII_ARTS.size())).forEach(linePrinter); + linePrinter.accept(""); + } +} diff --git a/src/main/java/com/keuin/crosslink/util/EggFactory.java b/src/main/java/com/keuin/crosslink/util/EggFactory.java new file mode 100644 index 0000000..4dfa779 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/util/EggFactory.java @@ -0,0 +1,17 @@ +package com.keuin.crosslink.util; + +import net.time4j.PlainDate; +import net.time4j.calendar.ChineseCalendar; +import org.jetbrains.annotations.NotNull; + +import java.util.Optional; + +final class EggFactory { + public static Optional<String> getEgg(@NotNull PlainDate today) { + var lunarDate = today.transform(ChineseCalendar.class); + if (lunarDate.getDayOfYear() == 1) { + return Optional.of("Today is Chinese New Year. 新年快乐!"); + } + return Optional.empty(); + } +} diff --git a/src/main/java/com/keuin/crosslink/util/HttpQuery.java b/src/main/java/com/keuin/crosslink/util/HttpQuery.java new file mode 100644 index 0000000..af06e4d --- /dev/null +++ b/src/main/java/com/keuin/crosslink/util/HttpQuery.java @@ -0,0 +1,22 @@ +package com.keuin.crosslink.util; + +import java.util.Collections; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +public class HttpQuery { + // Copied from https://stackoverflow.com/a/63976481 (modified) + public static Map<String, String> getParamMap(String query) { + // query is null if not provided (e.g. localhost/path ) + // query is empty if '?' is supplied (e.g. localhost/path? ) + if (query == null || query.isEmpty()) return Collections.emptyMap(); + + return Stream.of(query.split("&")) + .filter(s -> !s.isEmpty()) + .map(kv -> kv.split("=", 2)) + .collect(Collectors.toMap( + x -> x[0], + x -> ((x.length == 2) ? x[1] : ""))); + } +} diff --git a/src/main/java/com/keuin/crosslink/util/LazyEvaluated.java b/src/main/java/com/keuin/crosslink/util/LazyEvaluated.java new file mode 100644 index 0000000..587a918 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/util/LazyEvaluated.java @@ -0,0 +1,25 @@ +package com.keuin.crosslink.util; + +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Supplier; + +public class LazyEvaluated<T> implements Supplier<T> { + + private final Supplier<T> supplier; + private T value = null; + private final AtomicBoolean evaluated = new AtomicBoolean(false); + + public LazyEvaluated(Supplier<T> supplier) { + this.supplier = supplier; + } + + @Override + public T get() { + if (!evaluated.get()) { + value = supplier.get(); + evaluated.set(true); + } + return value; + } + +} diff --git a/src/main/java/com/keuin/crosslink/util/LoggerNaming.java b/src/main/java/com/keuin/crosslink/util/LoggerNaming.java new file mode 100644 index 0000000..4f460a9 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/util/LoggerNaming.java @@ -0,0 +1,31 @@ +package com.keuin.crosslink.util; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +public class LoggerNaming { + public static class NamedNode { + private final List<String> names; + + public NamedNode(List<String> previousNames) { + Objects.requireNonNull(previousNames); + this.names = List.copyOf(previousNames); + } + + public NamedNode of(String sectionName) { + var list = new ArrayList<>(names); + list.add(sectionName); + return new NamedNode(list); + } + + @Override + public String toString() { + return String.join(".", names); + } + } + + public static NamedNode name() { + return new NamedNode(List.of("crosslink")); + } +} diff --git a/src/main/java/com/keuin/crosslink/util/StartupMessagePrinter.java b/src/main/java/com/keuin/crosslink/util/StartupMessagePrinter.java new file mode 100644 index 0000000..b359df9 --- /dev/null +++ b/src/main/java/com/keuin/crosslink/util/StartupMessagePrinter.java @@ -0,0 +1,13 @@ +package com.keuin.crosslink.util; + +import net.time4j.SystemClock; + +import java.util.function.Consumer; + +public final class StartupMessagePrinter { + public static void print(Consumer<String> linePrinter, String mode) { + AsciiArtPrinter.print(linePrinter); + linePrinter.accept(String.format("CrossLink is loading in %s mode.", mode)); + EggFactory.getEgg(SystemClock.inLocalView().today()).ifPresent(linePrinter); + } +} diff --git a/src/main/java/com/keuin/crosslink/util/version/NewVersionChecker.java b/src/main/java/com/keuin/crosslink/util/version/NewVersionChecker.java new file mode 100644 index 0000000..fb1008e --- /dev/null +++ b/src/main/java/com/keuin/crosslink/util/version/NewVersionChecker.java @@ -0,0 +1,95 @@ +package com.keuin.crosslink.util.version; + +import com.fasterxml.jackson.databind.ObjectMapper; +import okhttp3.OkHttpClient; +import okhttp3.Request; + +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.Objects; +import java.util.Optional; +import java.util.function.Consumer; + +public class NewVersionChecker { + + public static void checkNewVersionAsync(Consumer<String> linePrinter) { + checkNewVersionAsync(linePrinter, true); + } + + public static void checkNewVersionAsync(Consumer<String> linePrinter, boolean skipPreRelease) { + // do not block the main routine, so check in another thread + new Thread(() -> { + try { + if (VersionInfo.DIRTY != 0) { + // dirty build is never released, so is not comparable + linePrinter.accept("You are running a test build of CrossLink, which is unstable. " + + "Update checking is disabled. Please switch to official release if possible."); + } + final var req = new Request.Builder() + .header("Accept", "application/vnd.github.v3+json") + .url("https://api.github.com/repos/keuin/crosslink/releases") + .build(); + final var client = new OkHttpClient(); + final var mainResp = client.newCall(req).execute(); + final var mapper = new ObjectMapper(); + final var json = mapper.readTree(Objects.requireNonNull(mainResp.body()).byteStream()); + for (var ver : json) { + if (ver == null) continue; + final var commit = ver.get("target_commitish").textValue(); + if (VersionInfo.GIT_SHA.equalsIgnoreCase(commit)) { + linePrinter.accept("Current version is the latest version."); + return; // current version is the latest version + } + if (ver.get("draft").booleanValue()) continue; + if (ver.get("prerelease").booleanValue() && skipPreRelease) continue; + + // this is a new version, notify the user + final var pageUrl = ver.get("html_url").textValue(); + final var tag = ver.get("tag_name").textValue(); + final var name = ver.get("name").textValue(); + final var publishDate = Instant + .parse(ver.get("published_at").textValue()) + .atZone(ZoneId.of("UTC")).toLocalDateTime(); + final var currentVersionDate = Instant + .parse(VersionInfo.BUILD_DATE).atZone(ZoneId.of("UTC")).toLocalDateTime(); + final var fmt = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm.ss"); + + // get detail message here + var detailMessage = ""; + try { + var detailResp = client.newCall(new Request.Builder() + .header("Accept", "application/vnd.github.v3+json") + .url(ver.get("url").textValue()).build()).execute(); + var detail = mapper.readTree(Objects.requireNonNull(detailResp.body()).byteStream()); + detailMessage = Optional.ofNullable(detail.get("body").textValue()).orElse(""); + } catch (Exception ignored) { + } + + linePrinter.accept("=".repeat(32)); + linePrinter.accept("New version of CrossLink is available!"); + linePrinter.accept(""); + linePrinter.accept(String.format("Current Version: %s, Build Time: %s", + VersionInfo.VERSION, currentVersionDate.format(fmt))); + linePrinter.accept(String.format("New Version: %s, Description: %s", tag, name)); + linePrinter.accept(String.format("Release Date: %s", publishDate.format(fmt))); + linePrinter.accept(String.format("Git Commit: %s", commit)); + linePrinter.accept(String.format("URL: %s", pageUrl)); + if (!detailMessage.isEmpty()) { + linePrinter.accept("Updates:"); + for (String s : detailMessage.split("\n")) { + linePrinter.accept(s); + } + } + linePrinter.accept("If you want to disable update checker, " + + "edit \"general.json\" and set \"check_update\" to false."); + linePrinter.accept("=".repeat(32)); + return; + } + } catch (Exception ex) { + linePrinter.accept("Cannot check new version from GitHub."); +// throw new RuntimeException(ex); + } + }).start(); + } +} |