diff --git a/api/src/main/java/com/discordsrv/api/discord/entity/channel/DiscordGuildChannel.java b/api/src/main/java/com/discordsrv/api/discord/entity/channel/DiscordGuildChannel.java index b70c38ce..332cff10 100644 --- a/api/src/main/java/com/discordsrv/api/discord/entity/channel/DiscordGuildChannel.java +++ b/api/src/main/java/com/discordsrv/api/discord/entity/channel/DiscordGuildChannel.java @@ -29,6 +29,8 @@ import com.discordsrv.api.placeholder.annotation.Placeholder; import com.discordsrv.api.placeholder.annotation.PlaceholderPrefix; import org.jetbrains.annotations.NotNull; +import java.util.concurrent.CompletableFuture; + @PlaceholderPrefix("channel_") public interface DiscordGuildChannel extends DiscordChannel, Snowflake { @@ -55,4 +57,10 @@ public interface DiscordGuildChannel extends DiscordChannel, Snowflake { @NotNull @Placeholder("jump_url") String getJumpUrl(); + + /** + * Deletes the channel. + * @return a future completing upon deletion + */ + CompletableFuture delete(); } diff --git a/common/src/main/java/com/discordsrv/common/AbstractDiscordSRV.java b/common/src/main/java/com/discordsrv/common/AbstractDiscordSRV.java index 75896739..05e55325 100644 --- a/common/src/main/java/com/discordsrv/common/AbstractDiscordSRV.java +++ b/common/src/main/java/com/discordsrv/common/AbstractDiscordSRV.java @@ -109,6 +109,7 @@ import java.net.URLClassLoader; import java.net.UnknownHostException; import java.nio.file.Path; import java.time.Duration; +import java.time.ZonedDateTime; import java.util.*; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; @@ -164,6 +165,8 @@ public abstract class AbstractDiscordSRV< private UpdateChecker updateChecker; protected VersionInfo versionInfo; + private final ZonedDateTime initializeTime = ZonedDateTime.now(); + private OkHttpClient httpClient; private final ObjectMapper objectMapper = new ObjectMapper() .configure(DeserializationFeature.FAIL_ON_IGNORED_PROPERTIES, false) @@ -540,6 +543,10 @@ public abstract class AbstractDiscordSRV< // Lifecycle + public ZonedDateTime getInitializeTime() { + return initializeTime; + } + @Override public final void runEnable() { try { diff --git a/common/src/main/java/com/discordsrv/common/DiscordSRV.java b/common/src/main/java/com/discordsrv/common/DiscordSRV.java index 01a6edb7..acabe5bd 100644 --- a/common/src/main/java/com/discordsrv/common/DiscordSRV.java +++ b/common/src/main/java/com/discordsrv/common/DiscordSRV.java @@ -62,6 +62,7 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import java.nio.file.Path; +import java.time.ZonedDateTime; import java.util.List; import java.util.Locale; import java.util.Set; @@ -176,6 +177,7 @@ public interface DiscordSRV extends DiscordSRVApi { List runReload(Set flags, boolean silent); CompletableFuture invokeDisable(); boolean isServerStarted(); + ZonedDateTime getInitializeTime(); @Nullable default GameCommandExecutionHelper executeHelper() { diff --git a/common/src/main/java/com/discordsrv/common/config/main/generic/ThreadConfig.java b/common/src/main/java/com/discordsrv/common/config/main/generic/ThreadConfig.java index e3090de6..1a93fe31 100644 --- a/common/src/main/java/com/discordsrv/common/config/main/generic/ThreadConfig.java +++ b/common/src/main/java/com/discordsrv/common/config/main/generic/ThreadConfig.java @@ -48,14 +48,13 @@ public class ThreadConfig { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; ThreadConfig that = (ThreadConfig) o; - return unarchiveExisting == that.unarchiveExisting - && privateThread == that.privateThread + return privateThread == that.privateThread && Objects.equals(channelId, that.channelId) && Objects.equals(threadName, that.threadName); } @Override public int hashCode() { - return Objects.hash(channelId, threadName, unarchiveExisting, privateThread); + return Objects.hash(channelId, threadName, privateThread); } } diff --git a/common/src/main/java/com/discordsrv/common/core/placeholder/context/GlobalDateFormattingContext.java b/common/src/main/java/com/discordsrv/common/core/placeholder/context/GlobalDateFormattingContext.java index 50c811b6..3cc821b1 100644 --- a/common/src/main/java/com/discordsrv/common/core/placeholder/context/GlobalDateFormattingContext.java +++ b/common/src/main/java/com/discordsrv/common/core/placeholder/context/GlobalDateFormattingContext.java @@ -20,41 +20,57 @@ package com.discordsrv.common.core.placeholder.context; import com.discordsrv.api.placeholder.annotation.Placeholder; import com.discordsrv.api.placeholder.annotation.PlaceholderRemainder; +import com.discordsrv.api.placeholder.format.FormattedText; import com.discordsrv.common.DiscordSRV; import com.github.benmanes.caffeine.cache.LoadingCache; +import java.time.DateTimeException; import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoField; +import java.time.temporal.TemporalAccessor; import java.util.concurrent.TimeUnit; public class GlobalDateFormattingContext { private static final String TIMESTAMP_IDENTIFIER = "timestamp"; + private final DiscordSRV discordSRV; private final LoadingCache cache; public GlobalDateFormattingContext(DiscordSRV discordSRV) { + this.discordSRV = discordSRV; this.cache = discordSRV.caffeineBuilder() .expireAfterAccess(30, TimeUnit.SECONDS) .build(DateTimeFormatter::ofPattern); } @Placeholder("date") - public String formatDate(ZonedDateTime time, @PlaceholderRemainder String format) { + public CharSequence formatDate(TemporalAccessor time, @PlaceholderRemainder String format) { if (format.startsWith(TIMESTAMP_IDENTIFIER)) { String style = format.substring(TIMESTAMP_IDENTIFIER.length()); - if (!style.isEmpty() && !style.startsWith(":")) { + if ((!style.isEmpty() && !style.startsWith(":")) || !time.isSupported(ChronoField.INSTANT_SECONDS)) { return null; } - return ""; + return FormattedText.of(""); } DateTimeFormatter formatter = cache.get(format); if (formatter == null) { throw new IllegalStateException("Illegal state"); } - return formatter.format(time); + + try { + return formatter.format(time); + } catch (DateTimeException e) { + return e.getMessage(); + } + } + + @Placeholder(value = "start_date", relookup = "date") + public ZonedDateTime getStartDate() { + return discordSRV.getInitializeTime(); } } 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 a12ce6b9..771530e4 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 @@ -157,4 +157,8 @@ public abstract class AbstractDiscordGuildMessageChannel delete() { + return discordSRV.discordAPI().mapExceptions(() -> channel.delete().submit()); + } } diff --git a/common/src/main/java/com/discordsrv/common/discord/api/entity/channel/DiscordForumChannelImpl.java b/common/src/main/java/com/discordsrv/common/discord/api/entity/channel/DiscordForumChannelImpl.java index c1f6ffec..67bf9fff 100644 --- a/common/src/main/java/com/discordsrv/common/discord/api/entity/channel/DiscordForumChannelImpl.java +++ b/common/src/main/java/com/discordsrv/common/discord/api/entity/channel/DiscordForumChannelImpl.java @@ -76,6 +76,11 @@ public class DiscordForumChannelImpl implements DiscordForumChannel { return channel.getJumpUrl(); } + @Override + public CompletableFuture delete() { + return discordSRV.discordAPI().mapExceptions(() -> channel.delete().submit()); + } + @Override public @NotNull List getActiveThreads() { List threads = channel.getThreadChannels(); diff --git a/common/src/main/java/com/discordsrv/common/discord/api/entity/guild/DiscordGuildMemberImpl.java b/common/src/main/java/com/discordsrv/common/discord/api/entity/guild/DiscordGuildMemberImpl.java index d765e533..1099eb21 100644 --- a/common/src/main/java/com/discordsrv/common/discord/api/entity/guild/DiscordGuildMemberImpl.java +++ b/common/src/main/java/com/discordsrv/common/discord/api/entity/guild/DiscordGuildMemberImpl.java @@ -36,6 +36,7 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import java.time.OffsetDateTime; +import java.time.ZonedDateTime; import java.util.ArrayList; import java.util.List; import java.util.concurrent.CompletableFuture; diff --git a/common/src/main/java/com/discordsrv/common/feature/console/ConsoleModule.java b/common/src/main/java/com/discordsrv/common/feature/console/ConsoleModule.java index 5d84a6f0..bfdf249c 100644 --- a/common/src/main/java/com/discordsrv/common/feature/console/ConsoleModule.java +++ b/common/src/main/java/com/discordsrv/common/feature/console/ConsoleModule.java @@ -29,6 +29,7 @@ import com.discordsrv.common.core.logging.NamedLogger; import com.discordsrv.common.core.logging.backend.LoggingBackend; import com.discordsrv.common.core.module.type.AbstractModule; import com.discordsrv.common.feature.console.entry.LogEntry; +import com.discordsrv.common.helper.TemporaryLocalData; import com.discordsrv.common.logging.LogAppender; import com.discordsrv.common.logging.LogLevel; import org.jetbrains.annotations.NotNull; @@ -104,6 +105,13 @@ public class ConsoleModule extends AbstractModule implements LogAppe handlers.add(new SingleConsoleHandler(discordSRV, logger(), config)); } + + TemporaryLocalData.Model temporaryData = discordSRV.temporaryLocalData().get(); + synchronized (temporaryData) { + temporaryData.consoleThreadRotationIds.keySet() + .removeIf(key -> handlers.stream().noneMatch(handler -> handler.getKey().equals(key))); + } + logger().debug(handlers.size() + " console handlers active"); } diff --git a/common/src/main/java/com/discordsrv/common/feature/console/SingleConsoleHandler.java b/common/src/main/java/com/discordsrv/common/feature/console/SingleConsoleHandler.java index ef7140c0..f4198707 100644 --- a/common/src/main/java/com/discordsrv/common/feature/console/SingleConsoleHandler.java +++ b/common/src/main/java/com/discordsrv/common/feature/console/SingleConsoleHandler.java @@ -19,6 +19,7 @@ package com.discordsrv.common.feature.console; import com.discordsrv.api.discord.entity.DiscordUser; +import com.discordsrv.api.discord.entity.channel.DiscordChannel; import com.discordsrv.api.discord.entity.channel.DiscordGuildChannel; import com.discordsrv.api.discord.entity.channel.DiscordGuildMessageChannel; import com.discordsrv.api.discord.entity.channel.DiscordMessageChannel; @@ -38,6 +39,7 @@ import com.discordsrv.common.core.logging.Logger; import com.discordsrv.common.feature.console.entry.LogEntry; import com.discordsrv.common.feature.console.entry.LogMessage; import com.discordsrv.common.feature.console.message.ConsoleMessage; +import com.discordsrv.common.helper.TemporaryLocalData; import com.discordsrv.common.logging.LogLevel; import net.dv8tion.jda.api.entities.Message; import org.apache.commons.lang3.StringUtils; @@ -45,7 +47,7 @@ import org.apache.commons.lang3.exception.ExceptionUtils; import org.apache.commons.lang3.tuple.Pair; import java.time.Duration; -import java.time.OffsetDateTime; +import java.time.ZonedDateTime; import java.util.*; import java.util.concurrent.*; import java.util.concurrent.atomic.AtomicLong; @@ -61,6 +63,7 @@ public class SingleConsoleHandler { private final DiscordSRV discordSRV; private final Logger logger; private ConsoleConfig config; + private String key; private Queue messageQueue; private Deque> sendQueue; private Future queueProcessingFuture; @@ -68,6 +71,7 @@ public class SingleConsoleHandler { // Editing private List messageCache; + private final AtomicLong mostRecentMessageChannelId = new AtomicLong(0); private final AtomicLong mostRecentMessageId = new AtomicLong(0); // Sending @@ -177,6 +181,10 @@ public class SingleConsoleHandler { return config; } + public String getKey() { + return key; + } + public void setConfig(ConsoleConfig config) { if (queueProcessingFuture != null) { queueProcessingFuture.cancel(false); @@ -184,6 +192,11 @@ public class SingleConsoleHandler { this.config = config; + DestinationConfig.Single destination = config.channel; + this.key = Long.toUnsignedString(config.channel.channelId) + + "-" + destination.thread.threadName + + "-" + config.channel.thread.privateThread; + boolean sendOn = config.appender.outputMode != ConsoleConfig.OutputMode.OFF; if (sendOn) { if (messageQueue == null) { @@ -206,6 +219,8 @@ public class SingleConsoleHandler { if (messageCache != null) { this.messageCache = null; } + mostRecentMessageChannelId.set(0); + mostRecentMessageId.set(0); } timeQueueProcess(); @@ -512,12 +527,23 @@ public class SingleConsoleHandler { } sendFuture = sendFuture - .thenCompose(__ -> discordSRV.destinations().lookupDestination( - config.channel.asDestination(), - true, - true, - OffsetDateTime.now()) - ) + .thenCompose(__ -> { + if (mostRecentMessageId.get() != 0) { + long channelId = mostRecentMessageChannelId.get(); + DiscordMessageChannel channel = discordSRV.discordAPI().getMessageChannelById(channelId); + if (channel instanceof DiscordGuildMessageChannel) { + return CompletableFuture.completedFuture( + Collections.singletonList((DiscordGuildMessageChannel) channel) + ); + } + } + + return discordSRV.destinations().lookupDestination( + config.channel.asDestination(), + true, + true, + ZonedDateTime.now()); + }) .thenCompose(channels -> { if (channels.isEmpty()) { // Nowhere to send to @@ -525,6 +551,43 @@ public class SingleConsoleHandler { } DiscordGuildMessageChannel channel = channels.iterator().next(); + + int amountOfChannels = config.threadsToKeepInRotation; + if (amountOfChannels > 0) { + TemporaryLocalData.Model temporaryData = discordSRV.temporaryLocalData().get(); + + List channelsToDelete = null; + synchronized (temporaryData) { + Map> rotationIds = temporaryData.consoleThreadRotationIds; + List channelIds = rotationIds.computeIfAbsent(key, k -> new ArrayList<>(amountOfChannels)); + + if (channelIds.isEmpty() || channelIds.get(0) != channel.getId()) { + channelIds.add(0, channel.getId()); + } + if (channelIds.size() > amountOfChannels) { + rotationIds.put(key, channelIds.subList(0, amountOfChannels)); + channelsToDelete = channelIds.subList(amountOfChannels, channelIds.size()); + } + } + discordSRV.temporaryLocalData().saveLater(); + + if (channelsToDelete != null) { + for (Long channelId : channelsToDelete) { + DiscordChannel channelToDelete = discordSRV.discordAPI().getChannelById(channelId); + if (channelToDelete instanceof DiscordGuildChannel) { + ((DiscordGuildChannel) channelToDelete).delete(); + } + } + } + } + + return CompletableFuture.completedFuture(channel); + }) + .thenCompose(channel -> { + if (channel == null) { + return null; + } + synchronized (mostRecentMessageId) { long messageId = mostRecentMessageId.get(); if (messageId != 0) { @@ -533,6 +596,9 @@ public class SingleConsoleHandler { } return channel.editMessageById(messageId, sendableMessage); } + + // Rotation may cause channel to change + mostRecentMessageChannelId.set(channel.getId()); } return channel.sendMessage(sendableMessage); @@ -546,6 +612,10 @@ public class SingleConsoleHandler { sentFirstBatch = true; return msg; }).exceptionally(ex -> { + synchronized (mostRecentMessageId) { + mostRecentMessageId.set(0); + } + String error = "Failed to send message to console channel"; String messageContent = sendableMessage.getContent(); if (messageContent != null && messageContent.contains(error)) { diff --git a/common/src/main/java/com/discordsrv/common/helper/DestinationLookupHelper.java b/common/src/main/java/com/discordsrv/common/helper/DestinationLookupHelper.java index 9a7b98b9..b87447cd 100644 --- a/common/src/main/java/com/discordsrv/common/helper/DestinationLookupHelper.java +++ b/common/src/main/java/com/discordsrv/common/helper/DestinationLookupHelper.java @@ -92,8 +92,9 @@ public class DestinationLookupHelper { } String threadName = discordSRV.placeholderService().replacePlaceholders(threadConfig.threadName, threadNameContext); + boolean privateThread = threadConfig.privateThread && !(threadContainer instanceof DiscordForumChannel); - DiscordThreadChannel existingThread = findThread(threadContainer.getActiveThreads(), threadName); + DiscordThreadChannel existingThread = findThread(threadContainer.getActiveThreads(), threadName, privateThread); if (existingThread != null && !existingThread.isArchived()) { futures.add(CompletableFuture.completedFuture(existingThread)); continue; @@ -103,7 +104,7 @@ public class DestinationLookupHelper { continue; } - String threadKey = Long.toUnsignedString(channelId) + ":" + threadName + "/" + threadConfig.privateThread; + String threadKey = Long.toUnsignedString(channelId) + ":" + threadName + "/" + privateThread; CompletableFuture future; synchronized (threadActions) { @@ -113,26 +114,26 @@ public class DestinationLookupHelper { future = existingFuture; } else if (!threadConfig.unarchiveExisting) { // Unarchiving not allowed, create new - future = createThread(threadContainer, threadName, threadConfig.privateThread, logFailures); + future = createThread(threadContainer, threadName, privateThread, logFailures); } else if (existingThread != null) { // Unarchive existing thread future = unarchiveThread(existingThread, logFailures); } else { // Lookup threads CompletableFuture> threads = - threadConfig.privateThread + privateThread ? threadContainer.retrieveArchivedPrivateThreads() : threadContainer.retrieveArchivedPublicThreads(); future = threads.thenCompose(archivedThreads -> { - DiscordThreadChannel archivedThread = findThread(archivedThreads, threadName); + DiscordThreadChannel archivedThread = findThread(archivedThreads, threadName, privateThread); if (archivedThread != null) { // Unarchive existing thread return unarchiveThread(archivedThread, logFailures); } // Create thread - return createThread(threadContainer, threadName, threadConfig.privateThread, logFailures); + return createThread(threadContainer, threadName, privateThread, logFailures); }).exceptionally(t -> { if (logFailures) { logger.error("Failed to lookup threads in channel #" + threadContainer.getName(), t); @@ -167,9 +168,9 @@ public class DestinationLookupHelper { }); } - private DiscordThreadChannel findThread(Collection threads, String threadName) { + private DiscordThreadChannel findThread(Collection threads, String threadName, boolean privateThread) { for (DiscordThreadChannel thread : threads) { - if (thread.getName().equals(threadName)) { + if (thread.getName().equals(threadName) && thread.isPublic() != privateThread) { return thread; } } @@ -183,7 +184,6 @@ public class DestinationLookupHelper { boolean logFailures ) { boolean forum = threadContainer instanceof DiscordForumChannel; - if (forum) privateThread = false; Permission createPermission; if (forum) { diff --git a/common/src/main/java/com/discordsrv/common/helper/TemporaryLocalData.java b/common/src/main/java/com/discordsrv/common/helper/TemporaryLocalData.java index 3871e0a8..5644a2c0 100644 --- a/common/src/main/java/com/discordsrv/common/helper/TemporaryLocalData.java +++ b/common/src/main/java/com/discordsrv/common/helper/TemporaryLocalData.java @@ -1,3 +1,21 @@ +/* + * This file is part of DiscordSRV, licensed under the GPLv3 License + * Copyright (c) 2016-2024 Austin "Scarsz" Shapiro, Henri "Vankka" Schubin and DiscordSRV contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + package com.discordsrv.common.helper; import com.discordsrv.common.DiscordSRV; @@ -8,6 +26,7 @@ import java.io.*; import java.nio.file.Files; import java.nio.file.Path; import java.time.Duration; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.Future; @@ -90,7 +109,7 @@ public class TemporaryLocalData { /** * {@link com.discordsrv.common.feature.console.SingleConsoleHandler} thread rotation. */ - public Map> consoleThreadRotationIds; + public Map> consoleThreadRotationIds = new HashMap<>(); }