Merge branch 'console'

This commit is contained in:
Vankka 2023-10-06 22:26:38 +03:00
commit 812f459d93
No known key found for this signature in database
GPG Key ID: 6E50CB7A29B96AD0
23 changed files with 1295 additions and 30 deletions

View File

@ -159,6 +159,15 @@ public interface ReceivedDiscordMessage extends Snowflake {
*/
CompletableFuture<ReceivedDiscordMessage> edit(@NotNull SendableDiscordMessage message);
/**
* Send the provided message in the channel this message was sent in, replying to this message.
*
* @param message the message
* @return a future that will fail if the request fails, otherwise the new message provided by the request response
* @throws IllegalArgumentException if the provided message is a webhook message
*/
CompletableFuture<ReceivedDiscordMessage> reply(@NotNull SendableDiscordMessage message);
class Attachment {
private final String fileName;

View File

@ -109,8 +109,38 @@ public interface SendableDiscordMessage {
return getWebhookUsername() != null;
}
/**
* Gets the raw inputs streams and file names for attachments, for this message.
* @return the map of input streams to file names
*/
Map<InputStream, String> getAttachments();
/**
* If notifications for this message are suppressed.
* @return if sending this message doesn't cause a notification
*/
boolean isSuppressedNotifications();
/**
* If embeds for this message are suppressed.
* @return if embeds for this message are suppressed
*/
boolean isSuppressedEmbeds();
/**
* Gets the id for the message this message is in reply to
* @return the message id
*/
Long getMessageIdToReplyTo();
/**
* Creates a copy of this {@link SendableDiscordMessage} with the specified reply message id.
*
* @param replyingToMessageId the reply message id
* @return a new {@link SendableDiscordMessage} identical to the current instance except for the reply message id
*/
SendableDiscordMessage withReplyingToMessageId(Long replyingToMessageId);
@SuppressWarnings("UnusedReturnValue") // API
interface Builder {
@ -250,6 +280,54 @@ public interface SendableDiscordMessage {
*/
Builder addAttachment(InputStream inputStream, String fileName);
/**
* Sets if this message's notifications will be suppressed.
* @param suppressedNotifications if notifications should be suppressed
* @return this builder, useful for chaining
*/
Builder setSuppressedNotifications(boolean suppressedNotifications);
/**
* Checks if this builder has notifications suppressed.
* @return {@code true} if notifications should be suppressed for this message
*/
boolean isSuppressedNotifications();
/**
* Sets if this message's embeds will be suppressed.
* @param suppressedEmbeds if embeds should be suppressed
* @return this builder, useful for chaining
*/
Builder setSuppressedEmbeds(boolean suppressedEmbeds);
/**
* Checks if this builder has embeds suppressed.
* @return {@code true} if embeds should be suppressed for this message
*/
boolean isSuppressedEmbeds();
/**
* Sets the message this message should be in reply to.
* @param messageId the id for the message this is in reply to
* @return this builder, useful for chaining
*/
Builder setMessageIdToReplyTo(Long messageId);
/**
* Sets the message this message should be in reply to.
* @param message the message this is in reply to
* @return this builder, useful for chaining
*/
default Builder setMessageToReplyTo(@NotNull ReceivedDiscordMessage message) {
return setMessageIdToReplyTo(message.getId());
}
/**
* Gets the id for the message this message is in reply to
* @return the message id
*/
Long getMessageIdToReplyTo();
/**
* Checks if this builder has any sendable content.
* @return {@code true} if there is no sendable content

View File

@ -52,6 +52,9 @@ public class SendableDiscordMessageImpl implements SendableDiscordMessage {
private final String webhookUsername;
private final String webhookAvatarUrl;
private final Map<InputStream, String> attachments;
private final boolean suppressedNotifications;
private final boolean suppressedEmbeds;
private final Long replyingToMessageId;
protected SendableDiscordMessageImpl(
String content,
@ -60,7 +63,10 @@ public class SendableDiscordMessageImpl implements SendableDiscordMessage {
Set<AllowedMention> allowedMentions,
String webhookUsername,
String webhookAvatarUrl,
Map<InputStream, String> attachments
Map<InputStream, String> attachments,
boolean suppressedNotifications,
boolean suppressedEmbeds,
Long replyingToMessageId
) {
this.content = content;
this.embeds = Collections.unmodifiableList(embeds);
@ -69,6 +75,24 @@ public class SendableDiscordMessageImpl implements SendableDiscordMessage {
this.webhookUsername = webhookUsername;
this.webhookAvatarUrl = webhookAvatarUrl;
this.attachments = Collections.unmodifiableMap(attachments);
this.suppressedNotifications = suppressedNotifications;
this.suppressedEmbeds = suppressedEmbeds;
this.replyingToMessageId = replyingToMessageId;
}
public SendableDiscordMessageImpl withReplyingToMessageId(Long replyingToMessageId) {
return new SendableDiscordMessageImpl(
content,
embeds,
actionRows,
allowedMentions,
webhookUsername,
webhookAvatarUrl,
attachments,
suppressedNotifications,
suppressedEmbeds,
replyingToMessageId
);
}
@Override
@ -102,6 +126,21 @@ public class SendableDiscordMessageImpl implements SendableDiscordMessage {
return webhookAvatarUrl;
}
@Override
public boolean isSuppressedNotifications() {
return suppressedNotifications;
}
@Override
public boolean isSuppressedEmbeds() {
return suppressedEmbeds;
}
@Override
public Long getMessageIdToReplyTo() {
return replyingToMessageId;
}
@Override
public Map<InputStream, String> getAttachments() {
return attachments;
@ -116,6 +155,9 @@ public class SendableDiscordMessageImpl implements SendableDiscordMessage {
private String webhookUsername;
private String webhookAvatarUrl;
private final Map<InputStream, String> attachments = new LinkedHashMap<>();
private boolean suppressedNotifications;
private boolean suppressedEmbeds;
private Long replyingToMessageId;
@Override
public String getContent() {
@ -226,9 +268,42 @@ public class SendableDiscordMessageImpl implements SendableDiscordMessage {
return (content == null || content.isEmpty()) && embeds.isEmpty() && attachments.isEmpty() && actionRows.isEmpty();
}
@Override
public Builder setSuppressedNotifications(boolean suppressedNotifications) {
this.suppressedNotifications = suppressedNotifications;
return this;
}
@Override
public boolean isSuppressedEmbeds() {
return suppressedEmbeds;
}
@Override
public Builder setMessageIdToReplyTo(Long messageId) {
replyingToMessageId = messageId;
return this;
}
@Override
public Long getMessageIdToReplyTo() {
return replyingToMessageId;
}
@Override
public Builder setSuppressedEmbeds(boolean suppressedEmbeds) {
this.suppressedEmbeds = suppressedEmbeds;
return this;
}
@Override
public boolean isSuppressedNotifications() {
return suppressedNotifications;
}
@Override
public @NotNull SendableDiscordMessage build() {
return new SendableDiscordMessageImpl(content, embeds, actionRows, allowedMentions, webhookUsername, webhookAvatarUrl, attachments);
return new SendableDiscordMessageImpl(content, embeds, actionRows, allowedMentions, webhookUsername, webhookAvatarUrl, attachments, suppressedNotifications, suppressedEmbeds, replyingToMessageId);
}
@Override

View File

@ -85,12 +85,13 @@ dependencies {
// Logging
compileOnly(libs.log4j.core)
// Adventure, MCDiscordReserializer, EnhancedLegacyText
// Adventure, ANSI (version upgrade for serializer), MCDiscordReserializer, EnhancedLegacyText
runtimeDownloadApi(libs.adventure.api)
runtimeDownloadApi(libs.adventure.serializer.plain)
runtimeDownloadApi(libs.adventure.serializer.legacy)
runtimeDownloadApi(libs.adventure.serializer.gson)
runtimeDownloadApi(libs.adventure.serializer.ansi)
runtimeDownloadApi(libs.kyori.ansi)
runtimeDownloadApi(libs.mcdiscordreserializer)
runtimeDownloadApi(libs.enhancedlegacytext)

View File

@ -42,6 +42,7 @@ import com.discordsrv.common.config.connection.UpdateConfig;
import com.discordsrv.common.config.main.MainConfig;
import com.discordsrv.common.config.main.linking.LinkedAccountConfig;
import com.discordsrv.common.config.messages.MessagesConfig;
import com.discordsrv.common.console.ConsoleModule;
import com.discordsrv.common.debug.data.VersionInfo;
import com.discordsrv.common.dependency.DiscordSRVDependencyManager;
import com.discordsrv.common.discord.api.DiscordAPIEventModule;
@ -72,6 +73,7 @@ import com.discordsrv.common.module.ModuleManager;
import com.discordsrv.common.module.type.AbstractModule;
import com.discordsrv.common.placeholder.DiscordPlaceholdersImpl;
import com.discordsrv.common.placeholder.PlaceholderServiceImpl;
import com.discordsrv.common.placeholder.context.GlobalDateFormattingContext;
import com.discordsrv.common.placeholder.context.GlobalTextHandlingContext;
import com.discordsrv.common.placeholder.result.ComponentResultStringifier;
import com.discordsrv.common.profile.ProfileManager;
@ -559,8 +561,10 @@ public abstract class AbstractDiscordSRV<
// Placeholder result stringifiers & global contexts
placeholderService().addResultMapper(new ComponentResultStringifier(this));
placeholderService().addGlobalContext(new GlobalTextHandlingContext(this));
placeholderService().addGlobalContext(new GlobalDateFormattingContext(this));
// Modules
registerModule(ConsoleModule::new);
registerModule(ChannelLockingModule::new);
registerModule(TimedUpdaterModule::new);
registerModule(DiscordCommandModule::new);

View File

@ -68,19 +68,19 @@ public class ComponentFactory implements MinecraftComponentFactory {
MinecraftSerializerOptions.defaults()
.addRenderer(new DiscordSRVMinecraftRenderer(discordSRV))
);
this.discordSerializer = new DiscordSerializer(
DiscordSerializerOptions.defaults()
.withTranslationProvider(this::provideTranslation)
);
ComponentFlattener flattener = ComponentFlattener.basic().toBuilder()
.mapper(TranslatableComponent.class, this::provideTranslation)
.build();
this.discordSerializer = new DiscordSerializer(
DiscordSerializerOptions.defaults()
.withFlattener(flattener)
);
this.plainSerializer = PlainTextComponentSerializer.builder()
.flattener(flattener)
.build();
this.ansiSerializer = ANSIComponentSerializer.builder()
.colorLevel(ColorLevel.INDEXED_16)
.colorLevel(ColorLevel.INDEXED_8)
.flattener(flattener)
.build();
}
@ -137,4 +137,5 @@ public class ComponentFactory implements MinecraftComponentFactory {
public TranslationRegistry translationRegistry() {
return translationRegistry;
}
}

View File

@ -0,0 +1,125 @@
package com.discordsrv.common.config.main;
import com.discordsrv.common.config.main.generic.DestinationConfig;
import com.discordsrv.common.config.main.generic.GameCommandFilterConfig;
import org.spongepowered.configurate.objectmapping.ConfigSerializable;
import org.spongepowered.configurate.objectmapping.meta.Comment;
import java.util.*;
@ConfigSerializable
public class ConsoleConfig {
@Comment("The console channel or thread")
public DestinationConfig.Single channel = new DestinationConfig.Single();
public Appender appender = new Appender();
public Execution commandExecution = new Execution();
@ConfigSerializable
public static class Appender {
@Comment("The format for log lines")
public String lineFormat = "[%log_time:'ccc HH:mm:ss zzz'%] [%log_level%] [%logger_name%] %message%";
@Comment("The mode for the console output, available options are:\n"
+ "- ansi: A colored ansi code block\n"
+ "- log: A \"accesslog\" code block\n"
+ "- diff: A \"diff\" code block highlighting warnings and errors with different colors\n"
+ "- plain: Plain text code block\n"
+ "- plain_content: Plain text")
public OutputMode outputMode = OutputMode.ANSI;
@Comment("In \"diff\" mode, should exception lines have the prefix character as well")
public boolean diffExceptions = true;
@Comment("If urls should have embeds disabled")
public boolean disableLinkEmbeds = true;
@Comment("Avoids sending new messages by editing the most recent message until it reaches it's maximum length")
public boolean useEditing = true;
@Comment("If console messages should be silent, not causing a notification")
public boolean silentMessages = true;
@Comment("A list of log levels to whitelist or blacklist")
public Levels levels = new Levels();
public static class Levels {
public List<String> levels = new ArrayList<>(Arrays.asList("DEBUG", "TRACE"));
public boolean blacklist = true;
}
@Comment("A list of logger names to whitelist or blacklist, use \"NONE\" for log messages with no logger name")
public Loggers loggers = new Loggers();
public static class Loggers {
public List<String> loggers = new ArrayList<>(Collections.singletonList("ExcludedLogger"));
public boolean blacklist = true;
}
}
@ConfigSerializable
public static class Execution {
public Execution() {
filters.add(
new GameCommandFilterConfig(
new ArrayList<>(),
false,
new ArrayList<>(Arrays.asList("list", "whitelist"))
)
);
filters.add(
new GameCommandFilterConfig(
new ArrayList<>(),
true,
new ArrayList<>(Arrays.asList(
"?",
"op",
"deop",
"execute"
))
)
);
}
@Comment("At least one condition has to match to allow execution")
public List<GameCommandFilterConfig> filters = new ArrayList<>();
@Comment("If a command is inputted starting with /, a warning response will be given if this is enabled")
public boolean enableSlashWarning = true;
}
public enum OutputMode {
ANSI("```ansi\n", "```"),
LOG("```accesslog\n", "```"),
DIFF("```diff\n", "```"),
MARKDOWN("", ""),
PLAIN("```\n", "```"),
PLAIN_CONTENT("", "");
private final String prefix;
private final String suffix;
OutputMode(String prefix, String suffix) {
this.prefix = prefix;
this.suffix = suffix;
}
public String prefix() {
return prefix;
}
public String suffix() {
return suffix;
}
public int blockLength() {
return prefix().length() + suffix().length();
}
}
}

View File

@ -31,9 +31,7 @@ import com.discordsrv.common.config.main.linking.LinkedAccountConfig;
import org.spongepowered.configurate.objectmapping.ConfigSerializable;
import org.spongepowered.configurate.objectmapping.meta.Comment;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.*;
@ConfigSerializable
public abstract class MainConfig implements Config {
@ -88,6 +86,9 @@ public abstract class MainConfig implements Config {
@Comment("Discord command configuration")
public DiscordCommandConfig discordCommand = new DiscordCommandConfig();
@Comment("Options for console channel(s) and/or thread(s)")
public List<ConsoleConfig> console = new ArrayList<>(Collections.singleton(new ConsoleConfig()));
@Comment("Configuration for the %1 placeholder. The below options will be attempted in the order they are in")
@Constants.Comment("%discord_invite%")
public DiscordInviteConfig invite = new DiscordInviteConfig();

View File

@ -1,6 +1,7 @@
package com.discordsrv.common.config.main.generic;
import com.discordsrv.common.config.configurate.annotation.Constants;
import org.apache.commons.lang3.StringUtils;
import org.spongepowered.configurate.objectmapping.ConfigSerializable;
import org.spongepowered.configurate.objectmapping.meta.Comment;
import org.spongepowered.configurate.objectmapping.meta.Setting;
@ -20,4 +21,29 @@ public class DestinationConfig {
@Comment("The threads that this in-game channel will forward to in Discord (this can be used instead of or with the %1 option)")
@Constants.Comment("channel-ids")
public List<ThreadConfig> threads = new ArrayList<>(Collections.singletonList(new ThreadConfig()));
@ConfigSerializable
public static class Single {
@Setting("channel-id")
public Long channelId = 0L;
@Setting("thread-name")
@Comment("If specified this destination will be a thread in the provided channel-id's channel, if left blank the destination will be the channel")
public String threadName = "";
public boolean privateThread = false;
public DestinationConfig asDestination() {
DestinationConfig config = new DestinationConfig();
if (StringUtils.isEmpty(threadName)) {
config.channelIds.add(channelId);
} else {
ThreadConfig threadConfig = new ThreadConfig();
threadConfig.channelId = channelId;
threadConfig.threadName = threadName;
threadConfig.privateThread = privateThread;
config.threads.add(threadConfig);
}
return config;
}
}
}

View File

@ -103,7 +103,7 @@ public class GameCommandFilterConfig {
}
}
if (!match) {
return true;
return false;
}
for (String configCommand : commands) {

View File

@ -0,0 +1,74 @@
package com.discordsrv.common.console;
import com.discordsrv.api.DiscordSRVApi;
import com.discordsrv.api.discord.connection.details.DiscordGatewayIntent;
import com.discordsrv.common.DiscordSRV;
import com.discordsrv.common.config.main.ConsoleConfig;
import com.discordsrv.common.console.entry.LogEntry;
import com.discordsrv.common.logging.LogAppender;
import com.discordsrv.common.logging.LogLevel;
import com.discordsrv.common.logging.NamedLogger;
import com.discordsrv.common.logging.backend.LoggingBackend;
import com.discordsrv.common.module.type.AbstractModule;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.function.Consumer;
public class ConsoleModule extends AbstractModule<DiscordSRV> implements LogAppender {
private LoggingBackend backend;
private final List<SingleConsoleHandler> handlers = new ArrayList<>();
public ConsoleModule(DiscordSRV discordSRV) {
super(discordSRV, new NamedLogger(discordSRV, "CONSOLE"));
}
@Override
public @NotNull Collection<DiscordGatewayIntent> requiredIntents() {
return Collections.singletonList(DiscordGatewayIntent.MESSAGE_CONTENT);
}
@Override
public void enable() {
backend = discordSRV.console().loggingBackend();
backend.addAppender(this);
}
@Override
public void reload(Consumer<DiscordSRVApi.ReloadResult> resultConsumer) {
for (SingleConsoleHandler handler : handlers) {
handler.shutdown();
}
handlers.clear();
List<ConsoleConfig> configs = discordSRV.config().console;
for (ConsoleConfig config : configs) {
handlers.add(new SingleConsoleHandler(discordSRV, logger(), config));
}
}
@Override
public void disable() {
if (backend != null) {
backend.removeAppender(this);
}
}
@Override
public void append(
@Nullable String loggerName,
@NotNull LogLevel logLevel,
@Nullable String message,
@Nullable Throwable throwable
) {
LogEntry entry = new LogEntry(loggerName, logLevel, message, throwable);
for (SingleConsoleHandler handler : handlers) {
handler.queue(entry);
}
}
}

View File

@ -0,0 +1,404 @@
package com.discordsrv.common.console;
import com.discordsrv.api.discord.entity.DiscordUser;
import com.discordsrv.api.discord.entity.channel.DiscordGuildChannel;
import com.discordsrv.api.discord.entity.channel.DiscordGuildMessageChannel;
import com.discordsrv.api.discord.entity.channel.DiscordMessageChannel;
import com.discordsrv.api.discord.entity.channel.DiscordThreadChannel;
import com.discordsrv.api.discord.entity.guild.DiscordGuildMember;
import com.discordsrv.api.discord.entity.message.ReceivedDiscordMessage;
import com.discordsrv.api.discord.entity.message.SendableDiscordMessage;
import com.discordsrv.api.discord.events.message.DiscordMessageReceiveEvent;
import com.discordsrv.api.event.bus.Subscribe;
import com.discordsrv.api.placeholder.provider.SinglePlaceholder;
import com.discordsrv.common.DiscordSRV;
import com.discordsrv.common.command.game.GameCommandExecutionHelper;
import com.discordsrv.common.config.main.ConsoleConfig;
import com.discordsrv.common.config.main.generic.DestinationConfig;
import com.discordsrv.common.config.main.generic.GameCommandFilterConfig;
import com.discordsrv.common.console.entry.LogEntry;
import com.discordsrv.common.console.entry.LogMessage;
import com.discordsrv.common.console.message.ConsoleMessage;
import com.discordsrv.common.logging.LogLevel;
import com.discordsrv.common.logging.Logger;
import net.dv8tion.jda.api.entities.Message;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.exception.ExceptionUtils;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Future;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;
/**
* The log appending and command handling for a single console channel.
*/
public class SingleConsoleHandler {
private static final int MESSAGE_MAX_LENGTH = Message.MAX_CONTENT_LENGTH;
private final DiscordSRV discordSRV;
private final Logger logger;
private final ConsoleConfig config;
private final Queue<LogEntry> queue = new LinkedBlockingQueue<>();
private Future<?> queueProcessingFuture;
// Editing
private final List<LogMessage> messageCache;
private Long mostRecentMessageId;
// Preventing concurrent sends
private final Object sendLock = new Object();
private CompletableFuture<?> sendFuture;
// Don't annoy console users twice about using /
private final Set<Long> warnedSlashUsageUserIds = new HashSet<>();
public SingleConsoleHandler(DiscordSRV discordSRV, Logger logger, ConsoleConfig config) {
this.discordSRV = discordSRV;
this.logger = logger;
this.config = config;
this.messageCache = config.appender.useEditing ? new ArrayList<>() : null;
timeQueueProcess();
discordSRV.eventBus().subscribe(this);
}
@Subscribe
public void onDiscordMessageReceived(DiscordMessageReceiveEvent event) {
DiscordMessageChannel messageChannel = event.getChannel();
DiscordGuildChannel channel = messageChannel instanceof DiscordGuildChannel ? (DiscordGuildChannel) messageChannel : null;
if (channel == null) {
return;
}
ReceivedDiscordMessage message = event.getMessage();
if (message.isFromSelf()) {
return;
}
String command = event.getMessage().getContent();
if (command == null) {
return;
}
DestinationConfig.Single destination = config.channel;
String threadName = destination.threadName;
DiscordGuildChannel checkChannel;
if (StringUtils.isNotEmpty(threadName)) {
if (!(channel instanceof DiscordThreadChannel)) {
return;
}
if (!channel.getName().equals(threadName)) {
return;
}
checkChannel = ((DiscordThreadChannel) channel).getParentChannel();
} else {
checkChannel = channel;
}
if (checkChannel.getId() != destination.channelId) {
return;
}
DiscordUser user = message.getAuthor();
DiscordGuildMember member = message.getMember();
GameCommandExecutionHelper helper = discordSRV.executeHelper();
if (command.startsWith("/") && config.commandExecution.enableSlashWarning) {
long userId = user.getId();
boolean newUser;
synchronized (warnedSlashUsageUserIds) {
newUser = !warnedSlashUsageUserIds.contains(userId);
if (newUser) {
warnedSlashUsageUserIds.add(userId);
}
}
if (newUser) {
// TODO: translation
message.reply(
SendableDiscordMessage.builder()
.setContent("Your command was prefixed with `/`, but normally commands in the Minecraft server console should **not** begin with `/`")
.build()
);
}
}
boolean pass = false;
for (GameCommandFilterConfig filter : config.commandExecution.filters) {
if (filter.isAcceptableCommand(member, user, command, false, helper)) {
pass = true;
break;
}
}
if (!pass) {
if (!user.isBot()) {
// TODO: translation
message.reply(
SendableDiscordMessage.builder()
.setContent("You are not allowed to run that command")
.build()
);
}
return;
}
// Split message when editing
if (messageCache != null) {
messageCache.clear();
}
mostRecentMessageId = null;
// Run the command
discordSRV.console().runCommand(command);
}
public void queue(LogEntry entry) {
queue.offer(entry);
}
public void shutdown() {
discordSRV.eventBus().unsubscribe(this);
queueProcessingFuture.cancel(false);
queue.clear();
if (messageCache != null) {
messageCache.clear();
}
mostRecentMessageId = null;
}
private void timeQueueProcess() {
this.queueProcessingFuture = discordSRV.scheduler().runLater(this::processQueue, 2, TimeUnit.SECONDS);
}
private void processQueue() {
try {
ConsoleConfig.Appender appenderConfig = config.appender;
ConsoleConfig.OutputMode outputMode = appenderConfig.outputMode;
Queue<LogMessage> currentBuffer = new LinkedBlockingQueue<>();
LogEntry entry;
while ((entry = queue.poll()) != null) {
String level = entry.level().name();
if (appenderConfig.levels.levels.contains(level) == appenderConfig.levels.blacklist) {
// Ignored level
continue;
}
String loggerName = entry.loggerName();
if (StringUtils.isEmpty(loggerName)) loggerName = "NONE";
if (appenderConfig.loggers.loggers.contains(loggerName) == appenderConfig.loggers.blacklist) {
// Ignored logger
continue;
}
List<String> messages = formatEntry(entry, outputMode, config.appender.diffExceptions);
if (messages.size() == 1) {
LogMessage message = new LogMessage(entry, messages.get(0));
currentBuffer.add(message);
} else {
clearBuffer(currentBuffer, outputMode);
for (String message : messages) {
send(message, true, outputMode);
}
}
}
clearBuffer(currentBuffer, outputMode);
} catch (Exception ex) {
logger.error("Failed to process console lines", ex);
}
if (sendFuture != null) {
sendFuture.whenComplete((__, ___) -> {
sendFuture = null;
timeQueueProcess();
});
} else {
timeQueueProcess();
}
}
private void clearBuffer(Queue<LogMessage> currentBuffer, ConsoleConfig.OutputMode outputMode) {
if (currentBuffer.isEmpty()) {
return;
}
int blockLength = outputMode.blockLength();
StringBuilder builder = new StringBuilder();
if (messageCache != null) {
for (LogMessage logMessage : messageCache) {
builder.append(logMessage.formatted());
}
}
LogMessage current;
while ((current = currentBuffer.poll()) != null) {
String formatted = current.formatted();
if (formatted.length() + builder.length() + blockLength > MESSAGE_MAX_LENGTH) {
send(builder.toString(), true, outputMode);
builder.setLength(0);
if (messageCache != null) {
messageCache.clear();
}
}
builder.append(formatted);
if (messageCache != null) {
messageCache.add(current);
}
}
if (builder.length() > 0) {
send(builder.toString(), false, outputMode);
}
}
private void send(String message, boolean isFull, ConsoleConfig.OutputMode outputMode) {
SendableDiscordMessage sendableMessage = SendableDiscordMessage.builder()
.setContent(outputMode.prefix() + message + outputMode.suffix())
.setSuppressedNotifications(config.appender.silentMessages)
.setSuppressedEmbeds(config.appender.disableLinkEmbeds)
.build();
synchronized (sendLock) {
CompletableFuture<?> future = sendFuture != null ? sendFuture : CompletableFuture.completedFuture(null);
sendFuture = future
.thenCompose(__ ->
discordSRV.discordAPI()
.findOrCreateDestinations(config.channel.asDestination(), true, true, true)
)
.thenApply(channels -> {
if (channels.isEmpty()) {
throw new IllegalStateException("No channel");
}
DiscordGuildMessageChannel channel = channels.get(0);
if (mostRecentMessageId != null) {
long messageId = mostRecentMessageId;
if (isFull) {
mostRecentMessageId = null;
}
return channel.editMessageById(messageId, sendableMessage);
}
return channel.sendMessage(sendableMessage)
.whenComplete((receivedMessage, t) -> {
if (receivedMessage != null && messageCache != null) {
mostRecentMessageId = receivedMessage.getId();
}
});
});
}
}
private List<String> formatEntry(LogEntry entry, ConsoleConfig.OutputMode outputMode, boolean diffExceptions) {
int blockLength = outputMode.blockLength();
int maximumPart = MESSAGE_MAX_LENGTH - blockLength - "\n".length();
String parsedMessage;
ConsoleMessage consoleMessage = new ConsoleMessage(discordSRV, entry.message());
switch (outputMode) {
case ANSI:
parsedMessage = consoleMessage.asAnsi();
break;
case MARKDOWN:
parsedMessage = consoleMessage.asMarkdown();
break;
default:
parsedMessage = consoleMessage.asPlain();
break;
}
String message = discordSRV.placeholderService().replacePlaceholders(
config.appender.lineFormat,
entry,
new SinglePlaceholder("message", parsedMessage)
);
Throwable thrown = entry.throwable();
String throwable = thrown != null ? ExceptionUtils.getStackTrace(thrown) : StringUtils.EMPTY;
if (outputMode == ConsoleConfig.OutputMode.DIFF) {
String diff = getLogLevelDiffCharacter(entry.level());
if (!message.isEmpty()) {
message = diff + message.replace("\n", "\n" + diff);
}
String exceptionCharacter = diffExceptions ? diff : "";
if (!throwable.isEmpty()) {
throwable = exceptionCharacter + throwable.replace("\n", "\n" + exceptionCharacter);
}
}
if (!message.isEmpty()) {
message += "\n";
}
if (!throwable.isEmpty()) {
throwable += "\n";
}
List<String> formatted = new ArrayList<>();
// Handle message being longer than a message
message = cutToSizeIfNeeded(message, blockLength, maximumPart, formatted);
// Handle log entry being longer than a message
int totalLength = blockLength + throwable.length() + message.length();
if (totalLength > MESSAGE_MAX_LENGTH) {
StringBuilder builder = new StringBuilder(message);
for (String line : throwable.split("\n")) {
line += "\n";
// Handle a single line of a throwable being longer than a message
line = cutToSizeIfNeeded(line, blockLength, maximumPart, formatted);
if (blockLength + line.length() > MESSAGE_MAX_LENGTH) {
// Need to split here
formatted.add(builder.toString());
builder.setLength(0);
}
builder.append(line);
}
formatted.add(builder.toString());
} else {
formatted.add(message + throwable);
}
return formatted;
}
private String cutToSizeIfNeeded(
String content,
int blockLength,
int maximumPart,
List<String> formatted
) {
while (content.length() + blockLength > MESSAGE_MAX_LENGTH) {
String cutToSize = content.substring(0, maximumPart) + "\n";
if (cutToSize.endsWith("\n\n")) {
// maximumPart excludes the newline at the end of message/line
cutToSize = cutToSize.substring(0, cutToSize.length() - 1);
}
formatted.add(cutToSize);
content = content.substring(maximumPart);
}
return content;
}
private String getLogLevelDiffCharacter(LogLevel level) {
if (level == LogLevel.StandardLogLevel.WARNING) {
return "+ ";
} else if (level == LogLevel.StandardLogLevel.ERROR) {
return "- ";
}
return " ";
}
}

View File

@ -0,0 +1,60 @@
package com.discordsrv.common.console.entry;
import com.discordsrv.api.placeholder.PlaceholderLookupResult;
import com.discordsrv.api.placeholder.annotation.Placeholder;
import com.discordsrv.api.placeholder.annotation.PlaceholderRemainder;
import com.discordsrv.common.logging.LogLevel;
import java.time.ZonedDateTime;
import java.util.LinkedHashSet;
import java.util.Set;
/**
* A raw log entry from a platform logger. May be parsed to become a {@link LogMessage}.
*/
public class LogEntry {
private final String loggerName;
private final LogLevel level;
private final String message;
private final Throwable throwable;
private final ZonedDateTime logTime;
public LogEntry(String loggerName, LogLevel level, String message, Throwable throwable) {
this.loggerName = loggerName;
this.level = level;
this.message = message;
this.throwable = throwable;
this.logTime = ZonedDateTime.now();
}
@Placeholder("logger_name")
public String loggerName() {
return loggerName;
}
@Placeholder("log_level")
public LogLevel level() {
return level;
}
public String message() {
return message;
}
public Throwable throwable() {
return throwable;
}
public ZonedDateTime logTime() {
return logTime;
}
@Placeholder("log_time")
public PlaceholderLookupResult _logTimePlaceholder(@PlaceholderRemainder String format) {
Set<Object> extras = new LinkedHashSet<>();
extras.add(logTime());
return PlaceholderLookupResult.newLookup("date:'" + format + "'", extras);
}
}

View File

@ -0,0 +1,23 @@
package com.discordsrv.common.console.entry;
/**
* A {@link LogEntry} with formatting.
*/
public class LogMessage {
private final LogEntry entry;
private final String formatted;
public LogMessage(LogEntry entry, String formatted) {
this.entry = entry;
this.formatted = formatted;
}
public LogEntry entry() {
return entry;
}
public String formatted() {
return formatted;
}
}

View File

@ -0,0 +1,306 @@
package com.discordsrv.common.console.message;
import com.discordsrv.common.DiscordSRV;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.TextComponent;
import net.kyori.adventure.text.format.*;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Helper class for parsing raw console messages into markdown, ansi or plain content for forwarding to Discord.
*/
public class ConsoleMessage {
private static final String ANSI_ESCAPE = "\u001B";
// Paper uses 007F as an intermediary
private static final String SECTION = "\u007F]";
// Regex pattern for matching ANSI + Legacy
private static final Pattern PATTERN = Pattern.compile(
// ANSI
ANSI_ESCAPE
+ "\\["
+ "(?<ansi>[0-9]{1,3}"
+ "(;[0-9]{1,3}"
+ "(;[0-9]{1,3}"
+ "(?:(?:;[0-9]{1,3}){2})?"
+ ")?"
+ ")?"
+ ")"
+ "m"
+ "|"
+ "(?<legacy>"
// Legacy color/formatting
+ "(?:" + SECTION + "[0-9a-fk-or])"
+ "|"
// Bungee/Spigot legacy
+ "(?:" + SECTION + "x(?:" + SECTION + "[0-9a-f]){6})"
+ ")"
);
private final TextComponent.Builder builder = Component.text();
private final DiscordSRV discordSRV;
public ConsoleMessage(DiscordSRV discordSRV, String input) {
this.discordSRV = discordSRV;
parse(input);
}
public String asMarkdown() {
Component component = builder.build();
return discordSRV.componentFactory().discordSerializer().serialize(component);
}
public String asAnsi() {
Component component = builder.build();
return discordSRV.componentFactory().ansiSerializer().serialize(component) + (ANSI_ESCAPE + "[0m");
}
public String asPlain() {
Component component = builder.build();
return discordSRV.componentFactory().plainSerializer().serialize(component);
}
private void parse(String input) {
Matcher matcher = PATTERN.matcher(input);
Style.Builder style = Style.style();
int lastMatchEnd = 0;
while (matcher.find()) {
int start = matcher.start();
int end = matcher.end();
if (start != lastMatchEnd) {
builder.append(Component.text(input.substring(lastMatchEnd, start), style.build()));
}
String ansi = matcher.group("ansi");
if (ansi != null) {
parseAnsi(ansi, style);
}
String legacy = matcher.group("legacy");
if (legacy != null) {
parseLegacy(legacy, style);
}
lastMatchEnd = end;
}
int length = input.length();
if (lastMatchEnd != length) {
builder.append(Component.text(input.substring(lastMatchEnd, length), style.build()));
}
}
private void parseAnsi(String ansiEscape, Style.Builder style) {
String[] ansiParts = ansiEscape.split(";");
int amount = ansiParts.length;
if (amount == 1 || amount == 2) {
int number = Integer.parseInt(ansiParts[0]);
if ((number >= 30 && number <= 37) || (number >= 90 && number <= 97)) {
style.color(fourBitAnsiColor(number));
return;
}
switch (number) {
case 0:
style.color(null).decorations(EnumSet.allOf(TextDecoration.class), false);
break;
case 1:
style.decoration(TextDecoration.BOLD, true);
break;
case 3:
style.decoration(TextDecoration.ITALIC, true);
break;
case 4:
style.decoration(TextDecoration.UNDERLINED, true);
break;
case 8:
style.decoration(TextDecoration.OBFUSCATED, true);
break;
case 9:
style.decoration(TextDecoration.STRIKETHROUGH, true);
break;
case 22:
style.decoration(TextDecoration.BOLD, false);
break;
case 23:
style.decoration(TextDecoration.ITALIC, false);
break;
case 24:
style.decoration(TextDecoration.UNDERLINED, false);
break;
case 28:
style.decoration(TextDecoration.OBFUSCATED, false);
break;
case 29:
style.decoration(TextDecoration.STRIKETHROUGH, false);
break;
case 39:
style.color(null);
break;
}
} else if (amount == 3 || amount == 5) {
if (Integer.parseInt(ansiParts[0]) != 36 || Integer.parseInt(ansiParts[1]) != 5) {
return;
}
if (amount == 5) {
int red = Integer.parseInt(ansiParts[2]);
int green = Integer.parseInt(ansiParts[3]);
int blue = Integer.parseInt(ansiParts[4]);
style.color(TextColor.color(red, green, blue));
return;
}
int number = Integer.parseInt(ansiParts[2]);
style.color(eightBitAnsiColor(number));
}
}
private enum FourBitColor {
BLACK(30, TextColor.color(0, 0, 0)),
RED(31, TextColor.color(170, 0, 0)),
GREEN(32, TextColor.color(0, 170, 0)),
YELLOW(33, TextColor.color(170, 85, 0)),
BLUE(34, TextColor.color(0, 0, 170)),
MAGENTA(35, TextColor.color(170, 0, 170)),
CYAN(36, TextColor.color(0, 170, 170)),
WHITE(37, TextColor.color(170, 170, 170)),
BRIGHT_BLACK(90, TextColor.color(85, 85, 85)),
BRIGHT_RED(91, TextColor.color(255, 85, 85)),
BRIGHT_GREEN(92, TextColor.color(85, 255, 85)),
BRIGHT_YELLOW(93, TextColor.color(255, 255, 85)),
BRIGHT_BLUE(94, TextColor.color(85, 85, 255)),
BRIGHT_MAGENTA(95, TextColor.color(255, 85, 255)),
BRIGHT_CYAN(96, TextColor.color(85, 255, 255)),
BRIGHT_WHITE(97, TextColor.color(255, 255, 255));
private static final Map<Integer, FourBitColor> byFG = new HashMap<>();
static {
for (FourBitColor value : values()) {
byFG.put(value.fg, value);
}
}
public static FourBitColor getByFG(int fg) {
return byFG.get(fg);
}
private final int fg;
private final TextColor color;
FourBitColor(int fg, TextColor color) {
this.fg = fg;
this.color = color;
}
public TextColor color() {
return color;
}
}
private TextColor fourBitAnsiColor(int color) {
FourBitColor fourBitColor = FourBitColor.getByFG(color);
return fourBitColor != null ? fourBitColor.color() : null;
}
private TextColor[] colors;
private TextColor eightBitAnsiColor(int color) {
if (colors == null) {
TextColor[] colors = new TextColor[256];
FourBitColor[] fourBitColors = FourBitColor.values();
for (int i = 0; i < fourBitColors.length; i++) {
colors[i] = fourBitColors[i].color();
}
// https://gitlab.gnome.org/GNOME/vte/-/blob/19acc51708d9e75ef2b314aa026467570e0bd8ee/src/vte.cc#L2485
for (int i = 16; i < 232; i++) {
int j = i - 16;
int red = j / 36;
int green = (j / 6) % 6;
int blue = j % 6;
red = red == 0 ? 0 : red * 40 + 55;
green = green == 0 ? 0 : green * 40 + 55;
blue = blue == 0 ? 0 : blue * 40 + 55;
colors[i] = TextColor.color(
red | red << 8,
green | green << 8,
blue | blue << 8
);
}
for (int i = 232; i < 256; i++) {
int shade = 8 + (i - 232) * 10;
colors[i] = TextColor.color(shade, shade, shade);
}
this.colors = colors;
}
return color <= colors.length && color >= 0 ? colors[color] : null;
}
private void parseLegacy(String legacy, Style.Builder style) {
if (legacy.length() == 2) {
char character = legacy.toCharArray()[1];
if (character == 'r') {
style.color(null).decorations(EnumSet.allOf(TextDecoration.class), false);
} else {
TextFormat format = getFormat(character);
if (format instanceof TextColor) {
style.color((TextColor) format);
} else if (format instanceof TextDecoration) {
style.decorate((TextDecoration) format);
}
}
} else {
char[] characters = legacy.toCharArray();
StringBuilder hex = new StringBuilder(7).append(TextColor.HEX_PREFIX);
for (int i = 2; i < characters.length; i += 2) {
hex.append(characters[i]);
}
style.color(TextColor.fromHexString(hex.toString()));
}
}
private TextFormat getFormat(char character) {
switch (character) {
case '0': return NamedTextColor.BLACK;
case '1': return NamedTextColor.DARK_BLUE;
case '2': return NamedTextColor.DARK_GREEN;
case '3': return NamedTextColor.DARK_AQUA;
case '4': return NamedTextColor.DARK_RED;
case '5': return NamedTextColor.DARK_PURPLE;
case '6': return NamedTextColor.GOLD;
case '7': return NamedTextColor.GRAY;
case '8': return NamedTextColor.DARK_GRAY;
case '9': return NamedTextColor.BLUE;
case 'a': return NamedTextColor.GREEN;
case 'b': return NamedTextColor.AQUA;
case 'c': return NamedTextColor.RED;
case 'd': return NamedTextColor.LIGHT_PURPLE;
case 'e': return NamedTextColor.YELLOW;
case 'f': return NamedTextColor.WHITE;
case 'k': return TextDecoration.OBFUSCATED;
case 'l': return TextDecoration.BOLD;
case 'm': return TextDecoration.STRIKETHROUGH;
case 'n': return TextDecoration.UNDERLINED;
case 'o': return TextDecoration.ITALIC;
default: return null;
}
}
}

View File

@ -100,7 +100,15 @@ public class DiscordAPIImpl implements DiscordAPI {
boolean create,
boolean log
) {
DestinationConfig destination = config.destination();
return findOrCreateDestinations(config.destination(), config.channelLocking.threads.unarchive, create, log);
}
public CompletableFuture<List<DiscordGuildMessageChannel>> findOrCreateDestinations(
DestinationConfig destination,
boolean unarchive,
boolean create,
boolean log
) {
List<DiscordGuildMessageChannel> channels = new CopyOnWriteArrayList<>();
for (Long channelId : destination.channelIds) {
@ -148,7 +156,7 @@ public class DiscordAPIImpl implements DiscordAPI {
unarchiveOrCreateThread(threadConfig, channel, thread, future);
} else {
// Find or create the thread
future = findOrCreateThread(config, threadConfig, channel);
future = findOrCreateThread(unarchive, threadConfig, channel);
}
DiscordThreadContainer container = channel;
@ -181,12 +189,8 @@ public class DiscordAPIImpl implements DiscordAPI {
return null;
}
private CompletableFuture<DiscordThreadChannel> findOrCreateThread(
BaseChannelConfig config,
ThreadConfig threadConfig,
DiscordThreadContainer container
) {
if (!config.channelLocking.threads.unarchive) {
private CompletableFuture<DiscordThreadChannel> findOrCreateThread(boolean unarchive, ThreadConfig threadConfig, DiscordThreadContainer container) {
if (!unarchive) {
return container.createThread(threadConfig.threadName, threadConfig.privateThread);
}

View File

@ -29,6 +29,7 @@ import net.dv8tion.jda.api.entities.Message;
import net.dv8tion.jda.api.entities.WebhookClient;
import net.dv8tion.jda.api.entities.channel.middleman.GuildMessageChannel;
import net.dv8tion.jda.api.requests.RestAction;
import net.dv8tion.jda.api.requests.restaction.MessageCreateAction;
import net.dv8tion.jda.api.utils.messages.MessageCreateData;
import net.dv8tion.jda.api.utils.messages.MessageCreateRequest;
import net.dv8tion.jda.api.utils.messages.MessageEditData;
@ -89,7 +90,15 @@ public abstract class AbstractDiscordGuildMessageChannel<T extends GuildMessageC
.setAvatarUrl(message.getWebhookAvatarUrl())
);
} else {
createRequest = CompletableFuture.completedFuture(((R) channel.sendMessage(createData)));
MessageCreateAction action = channel.sendMessage(createData)
// JDA doesn't properly grab this from MessageCreateData
.setSuppressEmbeds(createData.isSuppressEmbeds());
Long referencedMessageId = message.getMessageIdToReplyTo();
if (referencedMessageId != null) {
action = action.setMessageReference(referencedMessageId);
}
createRequest = CompletableFuture.completedFuture((R) action);
}
return createRequest

View File

@ -56,9 +56,13 @@ public class DiscordDMChannelImpl extends AbstractDiscordMessageChannel<PrivateC
MessageCreateAction action = channel.sendMessage(SendableDiscordMessageUtil.toJDASend(message));
Long referencedMessageId = message.getMessageIdToReplyTo();
if (referencedMessageId != null) {
action = action.setMessageReference(referencedMessageId);
}
CompletableFuture<ReceivedDiscordMessage> future = action.submit()
.thenApply(msg -> ReceivedDiscordMessageImpl.fromJDA(discordSRV, msg));
return discordSRV.discordAPI().mapExceptions(future);
}

View File

@ -226,12 +226,12 @@ public class ReceivedDiscordMessageImpl implements ReceivedDiscordMessage {
@Override
public @NotNull CompletableFuture<Void> delete() {
DiscordTextChannel textChannel = discordSRV.discordAPI().getTextChannelById(channelId);
if (textChannel == null) {
DiscordMessageChannel messageChannel = discordSRV.discordAPI().getMessageChannelById(channelId);
if (messageChannel == null) {
return CompletableFutureUtil.failed(new RestErrorResponseException(ErrorResponse.UNKNOWN_CHANNEL));
}
return textChannel.deleteMessageById(getId(), fromSelf && webhookMessage);
return messageChannel.deleteMessageById(getId(), fromSelf && webhookMessage);
}
@Override
@ -242,12 +242,26 @@ public class ReceivedDiscordMessageImpl implements ReceivedDiscordMessage {
throw new IllegalArgumentException("Cannot edit a non-webhook message into a webhook message");
}
DiscordTextChannel textChannel = discordSRV.discordAPI().getTextChannelById(channelId);
if (textChannel == null) {
DiscordMessageChannel messageChannel = discordSRV.discordAPI().getMessageChannelById(channelId);
if (messageChannel == null) {
return CompletableFutureUtil.failed(new RestErrorResponseException(ErrorResponse.UNKNOWN_CHANNEL));
}
return textChannel.editMessageById(getId(), message);
return messageChannel.editMessageById(getId(), message);
}
@Override
public CompletableFuture<ReceivedDiscordMessage> reply(@NotNull SendableDiscordMessage message) {
if (message.isWebhookMessage()) {
throw new IllegalStateException("Webhook messages cannot be used as replies");
}
DiscordMessageChannel messageChannel = discordSRV.discordAPI().getMessageChannelById(channelId);
if (messageChannel == null) {
return CompletableFutureUtil.failed(new RestErrorResponseException(ErrorResponse.UNKNOWN_CHANNEL));
}
return messageChannel.sendMessage(message.withReplyingToMessageId(id));
}
//

View File

@ -73,6 +73,7 @@ public final class SendableDiscordMessageUtil {
.setContent(message.getContent())
.setEmbeds(embeds)
.setAllowedMentions(allowedTypes)
.setSuppressEmbeds(message.isSuppressedEmbeds())
.mentionUsers(allowedUsers.stream().mapToLong(l -> l).toArray())
.mentionRoles(allowedRoles.stream().mapToLong(l -> l).toArray())
.setFiles(uploads);
@ -86,6 +87,7 @@ public final class SendableDiscordMessageUtil {
return jdaBuilder(message, new MessageCreateBuilder())
.addComponents(actionRows)
.setSuppressedNotifications(message.isSuppressedNotifications())
.build();
}

View File

@ -149,8 +149,8 @@ public class Log4JLoggerImpl implements Logger, LoggingBackend {
public boolean removeAppender(LogAppender appender) {
if (logger instanceof org.apache.logging.log4j.core.Logger) {
org.apache.logging.log4j.core.Logger loggerImpl = (org.apache.logging.log4j.core.Logger) logger;
Appender log4jAppender = appenders.get(appender);
loggerImpl.addAppender(log4jAppender);
Appender log4jAppender = appenders.remove(appender);
loggerImpl.removeAppender(log4jAppender);
return true;
}
return false;

View File

@ -0,0 +1,42 @@
package com.discordsrv.common.placeholder.context;
import com.discordsrv.api.placeholder.annotation.Placeholder;
import com.discordsrv.api.placeholder.annotation.PlaceholderRemainder;
import com.discordsrv.common.DiscordSRV;
import com.github.benmanes.caffeine.cache.LoadingCache;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.concurrent.TimeUnit;
public class GlobalDateFormattingContext {
private static final String TIMESTAMP_IDENTIFIER = "timestamp";
private final LoadingCache<String, DateTimeFormatter> cache;
public GlobalDateFormattingContext(DiscordSRV discordSRV) {
this.cache = discordSRV.caffeineBuilder()
.expireAfterAccess(30, TimeUnit.SECONDS)
.build(DateTimeFormatter::ofPattern);
}
@Placeholder("date")
public String formatDate(ZonedDateTime time, @PlaceholderRemainder String format) {
if (format.startsWith(TIMESTAMP_IDENTIFIER)) {
String style = format.substring(TIMESTAMP_IDENTIFIER.length());
if (!style.isEmpty() && !style.startsWith(":")) {
return null;
}
return "<t:" + time.toEpochSecond() + style + ">";
}
DateTimeFormatter formatter = cache.get(format);
if (formatter == null) {
throw new IllegalStateException("Illegal state");
}
return formatter.format(time);
}
}

View File

@ -126,6 +126,9 @@ dependencyResolutionManagement {
library('adventure-platform-bungee', 'net.kyori', 'adventure-platform-bungeecord').versionRef('adventure-platform')
library('adventure-serializer-bungee', 'net.kyori', 'adventure-text-serializer-bungeecord').versionRef('adventure-platform')
// Upgrade ansi (used by ansi serializer)
library('kyori-ansi', 'net.kyori', 'ansi').version('1.1.0-SNAPSHOT')
// MCDiscordReserializer & EnhancedLegacyText
library('mcdiscordreserializer', 'dev.vankka', 'mcdiscordreserializer').version('4.4.0-SNAPSHOT')
library('enhancedlegacytext', 'dev.vankka', 'enhancedlegacytext').version('2.0.0-SNAPSHOT')