diff --git a/api/src/main/java/com/discordsrv/api/discord/entity/message/ReceivedDiscordMessage.java b/api/src/main/java/com/discordsrv/api/discord/entity/message/ReceivedDiscordMessage.java index e03a143d..fb1740c4 100644 --- a/api/src/main/java/com/discordsrv/api/discord/entity/message/ReceivedDiscordMessage.java +++ b/api/src/main/java/com/discordsrv/api/discord/entity/message/ReceivedDiscordMessage.java @@ -159,6 +159,15 @@ public interface ReceivedDiscordMessage extends Snowflake { */ CompletableFuture 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 reply(@NotNull SendableDiscordMessage message); + class Attachment { private final String fileName; diff --git a/api/src/main/java/com/discordsrv/api/discord/entity/message/SendableDiscordMessage.java b/api/src/main/java/com/discordsrv/api/discord/entity/message/SendableDiscordMessage.java index cbcb3e32..f44af7a8 100644 --- a/api/src/main/java/com/discordsrv/api/discord/entity/message/SendableDiscordMessage.java +++ b/api/src/main/java/com/discordsrv/api/discord/entity/message/SendableDiscordMessage.java @@ -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 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 diff --git a/api/src/main/java/com/discordsrv/api/discord/entity/message/impl/SendableDiscordMessageImpl.java b/api/src/main/java/com/discordsrv/api/discord/entity/message/impl/SendableDiscordMessageImpl.java index d66dd286..59169831 100644 --- a/api/src/main/java/com/discordsrv/api/discord/entity/message/impl/SendableDiscordMessageImpl.java +++ b/api/src/main/java/com/discordsrv/api/discord/entity/message/impl/SendableDiscordMessageImpl.java @@ -52,6 +52,9 @@ public class SendableDiscordMessageImpl implements SendableDiscordMessage { private final String webhookUsername; private final String webhookAvatarUrl; private final Map 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 allowedMentions, String webhookUsername, String webhookAvatarUrl, - Map attachments + Map 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 getAttachments() { return attachments; @@ -116,6 +155,9 @@ public class SendableDiscordMessageImpl implements SendableDiscordMessage { private String webhookUsername; private String webhookAvatarUrl; private final Map 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 diff --git a/common/build.gradle b/common/build.gradle index 59e79d04..424b1d43 100644 --- a/common/build.gradle +++ b/common/build.gradle @@ -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) diff --git a/common/src/main/java/com/discordsrv/common/AbstractDiscordSRV.java b/common/src/main/java/com/discordsrv/common/AbstractDiscordSRV.java index 513b765d..5525be06 100644 --- a/common/src/main/java/com/discordsrv/common/AbstractDiscordSRV.java +++ b/common/src/main/java/com/discordsrv/common/AbstractDiscordSRV.java @@ -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); diff --git a/common/src/main/java/com/discordsrv/common/component/ComponentFactory.java b/common/src/main/java/com/discordsrv/common/component/ComponentFactory.java index 60465bfa..dcd2dda2 100644 --- a/common/src/main/java/com/discordsrv/common/component/ComponentFactory.java +++ b/common/src/main/java/com/discordsrv/common/component/ComponentFactory.java @@ -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; } + } diff --git a/common/src/main/java/com/discordsrv/common/config/main/ConsoleConfig.java b/common/src/main/java/com/discordsrv/common/config/main/ConsoleConfig.java new file mode 100644 index 00000000..9064fb3a --- /dev/null +++ b/common/src/main/java/com/discordsrv/common/config/main/ConsoleConfig.java @@ -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 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 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 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(); + } + } +} diff --git a/common/src/main/java/com/discordsrv/common/config/main/MainConfig.java b/common/src/main/java/com/discordsrv/common/config/main/MainConfig.java index 096dbe69..48b02279 100644 --- a/common/src/main/java/com/discordsrv/common/config/main/MainConfig.java +++ b/common/src/main/java/com/discordsrv/common/config/main/MainConfig.java @@ -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 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(); diff --git a/common/src/main/java/com/discordsrv/common/config/main/generic/DestinationConfig.java b/common/src/main/java/com/discordsrv/common/config/main/generic/DestinationConfig.java index 8f3b3f90..2db48b10 100644 --- a/common/src/main/java/com/discordsrv/common/config/main/generic/DestinationConfig.java +++ b/common/src/main/java/com/discordsrv/common/config/main/generic/DestinationConfig.java @@ -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 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; + } + } } diff --git a/common/src/main/java/com/discordsrv/common/config/main/generic/GameCommandFilterConfig.java b/common/src/main/java/com/discordsrv/common/config/main/generic/GameCommandFilterConfig.java index 4a331daf..4b45ad7d 100644 --- a/common/src/main/java/com/discordsrv/common/config/main/generic/GameCommandFilterConfig.java +++ b/common/src/main/java/com/discordsrv/common/config/main/generic/GameCommandFilterConfig.java @@ -103,7 +103,7 @@ public class GameCommandFilterConfig { } } if (!match) { - return true; + return false; } for (String configCommand : commands) { diff --git a/common/src/main/java/com/discordsrv/common/console/ConsoleModule.java b/common/src/main/java/com/discordsrv/common/console/ConsoleModule.java new file mode 100644 index 00000000..a19bd62d --- /dev/null +++ b/common/src/main/java/com/discordsrv/common/console/ConsoleModule.java @@ -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 implements LogAppender { + + private LoggingBackend backend; + private final List handlers = new ArrayList<>(); + + public ConsoleModule(DiscordSRV discordSRV) { + super(discordSRV, new NamedLogger(discordSRV, "CONSOLE")); + } + + @Override + public @NotNull Collection requiredIntents() { + return Collections.singletonList(DiscordGatewayIntent.MESSAGE_CONTENT); + } + + @Override + public void enable() { + backend = discordSRV.console().loggingBackend(); + backend.addAppender(this); + } + + @Override + public void reload(Consumer resultConsumer) { + for (SingleConsoleHandler handler : handlers) { + handler.shutdown(); + } + handlers.clear(); + + List 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); + } + } +} diff --git a/common/src/main/java/com/discordsrv/common/console/SingleConsoleHandler.java b/common/src/main/java/com/discordsrv/common/console/SingleConsoleHandler.java new file mode 100644 index 00000000..f83a4cf7 --- /dev/null +++ b/common/src/main/java/com/discordsrv/common/console/SingleConsoleHandler.java @@ -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 queue = new LinkedBlockingQueue<>(); + private Future queueProcessingFuture; + + // Editing + private final List 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 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 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 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 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 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 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 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 " "; + } +} diff --git a/common/src/main/java/com/discordsrv/common/console/entry/LogEntry.java b/common/src/main/java/com/discordsrv/common/console/entry/LogEntry.java new file mode 100644 index 00000000..88e1d032 --- /dev/null +++ b/common/src/main/java/com/discordsrv/common/console/entry/LogEntry.java @@ -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 extras = new LinkedHashSet<>(); + extras.add(logTime()); + + return PlaceholderLookupResult.newLookup("date:'" + format + "'", extras); + } +} diff --git a/common/src/main/java/com/discordsrv/common/console/entry/LogMessage.java b/common/src/main/java/com/discordsrv/common/console/entry/LogMessage.java new file mode 100644 index 00000000..5c843cd3 --- /dev/null +++ b/common/src/main/java/com/discordsrv/common/console/entry/LogMessage.java @@ -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; + } +} diff --git a/common/src/main/java/com/discordsrv/common/console/message/ConsoleMessage.java b/common/src/main/java/com/discordsrv/common/console/message/ConsoleMessage.java new file mode 100644 index 00000000..ce05fd90 --- /dev/null +++ b/common/src/main/java/com/discordsrv/common/console/message/ConsoleMessage.java @@ -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 + + "\\[" + + "(?[0-9]{1,3}" + + "(;[0-9]{1,3}" + + "(;[0-9]{1,3}" + + "(?:(?:;[0-9]{1,3}){2})?" + + ")?" + + ")?" + + ")" + + "m" + + "|" + + "(?" + // 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 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; + } + } +} diff --git a/common/src/main/java/com/discordsrv/common/discord/api/DiscordAPIImpl.java b/common/src/main/java/com/discordsrv/common/discord/api/DiscordAPIImpl.java index 7e2d282d..ba459826 100644 --- a/common/src/main/java/com/discordsrv/common/discord/api/DiscordAPIImpl.java +++ b/common/src/main/java/com/discordsrv/common/discord/api/DiscordAPIImpl.java @@ -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> findOrCreateDestinations( + DestinationConfig destination, + boolean unarchive, + boolean create, + boolean log + ) { List 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 findOrCreateThread( - BaseChannelConfig config, - ThreadConfig threadConfig, - DiscordThreadContainer container - ) { - if (!config.channelLocking.threads.unarchive) { + private CompletableFuture findOrCreateThread(boolean unarchive, ThreadConfig threadConfig, DiscordThreadContainer container) { + if (!unarchive) { return container.createThread(threadConfig.threadName, threadConfig.privateThread); } diff --git a/common/src/main/java/com/discordsrv/common/discord/api/entity/channel/AbstractDiscordGuildMessageChannel.java b/common/src/main/java/com/discordsrv/common/discord/api/entity/channel/AbstractDiscordGuildMessageChannel.java index bb8fc49a..dad149e1 100644 --- a/common/src/main/java/com/discordsrv/common/discord/api/entity/channel/AbstractDiscordGuildMessageChannel.java +++ b/common/src/main/java/com/discordsrv/common/discord/api/entity/channel/AbstractDiscordGuildMessageChannel.java @@ -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 future = action.submit() .thenApply(msg -> ReceivedDiscordMessageImpl.fromJDA(discordSRV, msg)); - return discordSRV.discordAPI().mapExceptions(future); } diff --git a/common/src/main/java/com/discordsrv/common/discord/api/entity/message/ReceivedDiscordMessageImpl.java b/common/src/main/java/com/discordsrv/common/discord/api/entity/message/ReceivedDiscordMessageImpl.java index 7321630e..3d2cf1e1 100644 --- a/common/src/main/java/com/discordsrv/common/discord/api/entity/message/ReceivedDiscordMessageImpl.java +++ b/common/src/main/java/com/discordsrv/common/discord/api/entity/message/ReceivedDiscordMessageImpl.java @@ -226,12 +226,12 @@ public class ReceivedDiscordMessageImpl implements ReceivedDiscordMessage { @Override public @NotNull CompletableFuture 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 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)); } // diff --git a/common/src/main/java/com/discordsrv/common/discord/api/entity/message/util/SendableDiscordMessageUtil.java b/common/src/main/java/com/discordsrv/common/discord/api/entity/message/util/SendableDiscordMessageUtil.java index fd81c9f3..98215748 100644 --- a/common/src/main/java/com/discordsrv/common/discord/api/entity/message/util/SendableDiscordMessageUtil.java +++ b/common/src/main/java/com/discordsrv/common/discord/api/entity/message/util/SendableDiscordMessageUtil.java @@ -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(); } diff --git a/common/src/main/java/com/discordsrv/common/logging/backend/impl/Log4JLoggerImpl.java b/common/src/main/java/com/discordsrv/common/logging/backend/impl/Log4JLoggerImpl.java index 5a942e2d..419867fe 100644 --- a/common/src/main/java/com/discordsrv/common/logging/backend/impl/Log4JLoggerImpl.java +++ b/common/src/main/java/com/discordsrv/common/logging/backend/impl/Log4JLoggerImpl.java @@ -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; diff --git a/common/src/main/java/com/discordsrv/common/placeholder/context/GlobalDateFormattingContext.java b/common/src/main/java/com/discordsrv/common/placeholder/context/GlobalDateFormattingContext.java new file mode 100644 index 00000000..f7af4ecf --- /dev/null +++ b/common/src/main/java/com/discordsrv/common/placeholder/context/GlobalDateFormattingContext.java @@ -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 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 ""; + } + + DateTimeFormatter formatter = cache.get(format); + if (formatter == null) { + throw new IllegalStateException("Illegal state"); + } + return formatter.format(time); + } + +} diff --git a/settings.gradle b/settings.gradle index c776dff8..1c0cdf0d 100644 --- a/settings.gradle +++ b/settings.gradle @@ -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')