diff --git a/application/build.gradle b/application/build.gradle index 14cd60d0b5..75ede0d319 100644 --- a/application/build.gradle +++ b/application/build.gradle @@ -50,6 +50,8 @@ dependencies { implementation 'org.apache.logging.log4j:log4j-core:2.19.0' runtimeOnly 'org.apache.logging.log4j:log4j-slf4j18-impl:2.18.0' + implementation 'club.minnced:discord-webhooks:0.8.2' + implementation 'org.jooq:jooq:3.17.2' implementation 'io.mikael:urlbuilder:2.0.9' diff --git a/application/config.json.template b/application/config.json.template index 51faf35082..d7a3563a38 100644 --- a/application/config.json.template +++ b/application/config.json.template @@ -83,5 +83,7 @@ "wsc", "wsf", "wsh" - ] + ], + "logInfoChannelWebhook": "", + "logErrorChannelWebhook": "" } diff --git a/application/src/main/java/org/togetherjava/tjbot/Application.java b/application/src/main/java/org/togetherjava/tjbot/Application.java index 71128aef44..2d9490bf64 100644 --- a/application/src/main/java/org/togetherjava/tjbot/Application.java +++ b/application/src/main/java/org/togetherjava/tjbot/Application.java @@ -12,6 +12,8 @@ import org.togetherjava.tjbot.commands.system.BotCore; import org.togetherjava.tjbot.config.Config; import org.togetherjava.tjbot.db.Database; +import org.togetherjava.tjbot.logging.LogMarkers; +import org.togetherjava.tjbot.logging.discord.DiscordLogging; import java.io.IOException; import java.nio.file.Files; @@ -57,6 +59,7 @@ public static void main(final String[] args) { Thread.setDefaultUncaughtExceptionHandler(Application::onUncaughtException); Runtime.getRuntime().addShutdownHook(new Thread(Application::onShutdown)); + DiscordLogging.startDiscordLogging(config); runBot(config); } @@ -92,7 +95,7 @@ public static void runBot(Config config) { logger.info("Bot is ready"); } catch (InvalidTokenException e) { - logger.error("Failed to login", e); + logger.error(LogMarkers.SENSITIVE, "Failed to login", e); } catch (InterruptedException e) { logger.error("Interrupted while waiting for setup to complete", e); Thread.currentThread().interrupt(); diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/componentids/ComponentIdStore.java b/application/src/main/java/org/togetherjava/tjbot/commands/componentids/ComponentIdStore.java index cbbf106f50..7883c280ae 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/componentids/ComponentIdStore.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/componentids/ComponentIdStore.java @@ -12,6 +12,7 @@ import org.togetherjava.tjbot.db.Database; import org.togetherjava.tjbot.db.generated.tables.ComponentIds; import org.togetherjava.tjbot.db.generated.tables.records.ComponentIdsRecord; +import org.togetherjava.tjbot.logging.LogMarkers; import java.time.Instant; import java.time.temporal.ChronoUnit; @@ -323,8 +324,8 @@ private void logDebugSizeStatistics() { ComponentIds.COMPONENT_IDS.LIFESPAN.eq(lifespan.name()))))); int recordsCount = lifespanToCount.values().stream().mapToInt(Integer::intValue).sum(); - logger.debug("The component id store consists of {} records ({})", recordsCount, - lifespanToCount); + logger.debug(LogMarkers.SENSITIVE, "The component id store consists of {} records ({})", + recordsCount, lifespanToCount); } @Override diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/BanCommand.java b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/BanCommand.java index a92d387c2e..01596d37a1 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/BanCommand.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/BanCommand.java @@ -18,6 +18,7 @@ import org.slf4j.LoggerFactory; import org.togetherjava.tjbot.commands.CommandVisibility; import org.togetherjava.tjbot.commands.SlashCommandAdapter; +import org.togetherjava.tjbot.logging.LogMarkers; import javax.annotation.Nullable; import java.time.Instant; @@ -128,7 +129,8 @@ private static Optional> handleNotAlreadyBannedRespo .setEphemeral(true)); } } - logger.warn("Something unexpected went wrong while trying to ban the user '{}'.", + logger.warn(LogMarkers.SENSITIVE, + "Something unexpected went wrong while trying to ban the user '{}'.", target.getAsTag(), alreadyBannedFailure); return Optional.of(event.reply("Failed to ban the user due to an unexpected problem.") .setEphemeral(true)); @@ -149,7 +151,7 @@ private AuditableRestAction banUser(User target, Member author, int deleteHistoryDays, Guild guild) { String durationMessage = temporaryData == null ? "permanently" : "for " + temporaryData.duration(); - logger.info( + logger.info(LogMarkers.SENSITIVE, "'{}' ({}) banned the user '{}' ({}) {} from guild '{}' and deleted their message history of the last {} days, for reason '{}'.", author.getUser().getAsTag(), author.getId(), target.getAsTag(), target.getId(), durationMessage, guild.getName(), deleteHistoryDays, reason); diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/KickCommand.java b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/KickCommand.java index b237cecd07..dc88789e44 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/KickCommand.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/KickCommand.java @@ -16,6 +16,7 @@ import org.slf4j.LoggerFactory; import org.togetherjava.tjbot.commands.CommandVisibility; import org.togetherjava.tjbot.commands.SlashCommandAdapter; +import org.togetherjava.tjbot.logging.LogMarkers; import javax.annotation.Nullable; import java.util.Objects; @@ -82,7 +83,8 @@ private static RestAction sendDm(ISnowflake target, String reason, Guil private AuditableRestAction kickUser(Member target, Member author, String reason, Guild guild) { - logger.info("'{}' ({}) kicked the user '{}' ({}) from guild '{}' for reason '{}'.", + logger.info(LogMarkers.SENSITIVE, + "'{}' ({}) kicked the user '{}' ({}) from guild '{}' for reason '{}'.", author.getUser().getAsTag(), author.getId(), target.getUser().getAsTag(), target.getId(), guild.getName(), reason); diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/MuteCommand.java b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/MuteCommand.java index e977196cc0..8102a2e646 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/MuteCommand.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/MuteCommand.java @@ -14,6 +14,7 @@ import org.togetherjava.tjbot.commands.CommandVisibility; import org.togetherjava.tjbot.commands.SlashCommandAdapter; import org.togetherjava.tjbot.config.Config; +import org.togetherjava.tjbot.logging.LogMarkers; import javax.annotation.Nullable; import java.time.Instant; @@ -103,7 +104,8 @@ private AuditableRestAction muteUser(Member target, Member author, @Nullable ModerationUtils.TemporaryData temporaryData, String reason, Guild guild) { String durationMessage = temporaryData == null ? "permanently" : "for " + temporaryData.duration(); - logger.info("'{}' ({}) muted the user '{}' ({}) {} in guild '{}' for reason '{}'.", + logger.info(LogMarkers.SENSITIVE, + "'{}' ({}) muted the user '{}' ({}) {} in guild '{}' for reason '{}'.", author.getUser().getAsTag(), author.getId(), target.getUser().getAsTag(), target.getId(), durationMessage, guild.getName(), reason); diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/NoteCommand.java b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/NoteCommand.java index fc5017d170..803a664302 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/NoteCommand.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/NoteCommand.java @@ -9,6 +9,7 @@ import org.slf4j.LoggerFactory; import org.togetherjava.tjbot.commands.CommandVisibility; import org.togetherjava.tjbot.commands.SlashCommandAdapter; +import org.togetherjava.tjbot.logging.LogMarkers; import javax.annotation.Nullable; import java.util.Objects; @@ -77,7 +78,8 @@ private void sendNote(User target, Member author, String content, ISnowflake gui } private void storeNote(User target, Member author, String content, ISnowflake guild) { - logger.info("'{}' ({}) wrote a note about the user '{}' ({}) with content '{}'.", + logger.info(LogMarkers.SENSITIVE, + "'{}' ({}) wrote a note about the user '{}' ({}) with content '{}'.", author.getUser().getAsTag(), author.getId(), target.getAsTag(), target.getId(), content); diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/QuarantineCommand.java b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/QuarantineCommand.java index a75d73fdd2..9599db922c 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/QuarantineCommand.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/QuarantineCommand.java @@ -13,6 +13,7 @@ import org.togetherjava.tjbot.commands.CommandVisibility; import org.togetherjava.tjbot.commands.SlashCommandAdapter; import org.togetherjava.tjbot.config.Config; +import org.togetherjava.tjbot.logging.LogMarkers; import javax.annotation.Nullable; import java.util.Objects; @@ -88,7 +89,8 @@ private static MessageEmbed sendFeedback(boolean hasSentDm, Member target, Membe private AuditableRestAction quarantineUser(Member target, Member author, String reason, Guild guild) { - logger.info("'{}' ({}) quarantined the user '{}' ({}) in guild '{}' for reason '{}'.", + logger.info(LogMarkers.SENSITIVE, + "'{}' ({}) quarantined the user '{}' ({}) in guild '{}' for reason '{}'.", author.getUser().getAsTag(), author.getId(), target.getUser().getAsTag(), target.getId(), guild.getName(), reason); diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/RejoinModerationRoleListener.java b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/RejoinModerationRoleListener.java index 0df83b86d3..cec8f35260 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/RejoinModerationRoleListener.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/RejoinModerationRoleListener.java @@ -10,6 +10,7 @@ import org.slf4j.LoggerFactory; import org.togetherjava.tjbot.commands.EventReceiver; import org.togetherjava.tjbot.config.Config; +import org.togetherjava.tjbot.logging.LogMarkers; import java.util.List; import java.util.Optional; @@ -93,7 +94,8 @@ private boolean shouldApplyModerationRole(ModerationRole moderationRole, private static void applyModerationRole(ModerationRole moderationRole, Member member) { Guild guild = member.getGuild(); - logger.info("Reapplied existing {} to user '{}' ({}) in guild '{}' after rejoining.", + logger.info(LogMarkers.SENSITIVE, + "Reapplied existing {} to user '{}' ({}) in guild '{}' after rejoining.", moderationRole.actionName, member.getUser().getAsTag(), member.getId(), guild.getName()); diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/UnbanCommand.java b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/UnbanCommand.java index 3aea534970..0bc6d5746b 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/UnbanCommand.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/UnbanCommand.java @@ -11,6 +11,7 @@ import org.slf4j.LoggerFactory; import org.togetherjava.tjbot.commands.CommandVisibility; import org.togetherjava.tjbot.commands.SlashCommandAdapter; +import org.togetherjava.tjbot.logging.LogMarkers; import java.util.Objects; @@ -49,7 +50,8 @@ private void unban(User target, Member author, String reason, Guild guild, ModerationAction.UNBAN, target, null, reason); event.replyEmbeds(message).queue(); - logger.info("'{}' ({}) unbanned the user '{}' ({}) from guild '{}' for reason '{}'.", + logger.info(LogMarkers.SENSITIVE, + "'{}' ({}) unbanned the user '{}' ({}) from guild '{}' for reason '{}'.", author.getUser().getAsTag(), author.getId(), target.getAsTag(), target.getId(), guild.getName(), reason); @@ -63,21 +65,23 @@ private static void handleFailure(Throwable unbanFailure, User target, IReplyCal if (unbanFailure instanceof ErrorResponseException errorResponseException) { if (errorResponseException.getErrorResponse() == ErrorResponse.UNKNOWN_USER) { event.reply("The specified user does not exist.").setEphemeral(true).queue(); - logger.debug("Unable to unban the user '{}' because they do not exist.", targetTag); + logger.debug(LogMarkers.SENSITIVE, + "Unable to unban the user '{}' because they do not exist.", targetTag); return; } if (errorResponseException.getErrorResponse() == ErrorResponse.UNKNOWN_BAN) { event.reply("The specified user is not banned.").setEphemeral(true).queue(); - logger.debug("Unable to unban the user '{}' because they are not banned.", - targetTag); + logger.debug(LogMarkers.SENSITIVE, + "Unable to unban the user '{}' because they are not banned.", targetTag); return; } } event.reply("Sorry, but something went wrong.").setEphemeral(true).queue(); - logger.warn("Something unexpected went wrong while trying to unban the user '{}'.", - targetTag, unbanFailure); + logger.warn(LogMarkers.SENSITIVE, + "Something unexpected went wrong while trying to unban the user '{}'.", targetTag, + unbanFailure); } private boolean handleChecks(IPermissionHolder bot, Member author, CharSequence reason, diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/UnmuteCommand.java b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/UnmuteCommand.java index 48e61b86d1..dfd77ac67e 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/UnmuteCommand.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/UnmuteCommand.java @@ -13,6 +13,7 @@ import org.togetherjava.tjbot.commands.CommandVisibility; import org.togetherjava.tjbot.commands.SlashCommandAdapter; import org.togetherjava.tjbot.config.Config; +import org.togetherjava.tjbot.logging.LogMarkers; import javax.annotation.Nullable; import java.util.Objects; @@ -81,7 +82,8 @@ private static MessageEmbed sendFeedback(boolean hasSentDm, Member target, Membe private AuditableRestAction unmuteUser(Member target, Member author, String reason, Guild guild) { - logger.info("'{}' ({}) unmuted the user '{}' ({}) in guild '{}' for reason '{}'.", + logger.info(LogMarkers.SENSITIVE, + "'{}' ({}) unmuted the user '{}' ({}) in guild '{}' for reason '{}'.", author.getUser().getAsTag(), author.getId(), target.getUser().getAsTag(), target.getId(), guild.getName(), reason); diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/UnquarantineCommand.java b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/UnquarantineCommand.java index 7736e9b87c..6619c7ce12 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/UnquarantineCommand.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/UnquarantineCommand.java @@ -13,6 +13,7 @@ import org.togetherjava.tjbot.commands.CommandVisibility; import org.togetherjava.tjbot.commands.SlashCommandAdapter; import org.togetherjava.tjbot.config.Config; +import org.togetherjava.tjbot.logging.LogMarkers; import javax.annotation.Nullable; import java.util.Objects; @@ -86,7 +87,8 @@ private static MessageEmbed sendFeedback(boolean hasSentDm, Member target, Membe private AuditableRestAction unquarantineUser(Member target, Member author, String reason, Guild guild) { - logger.info("'{}' ({}) unquarantined the user '{}' ({}) in guild '{}' for reason '{}'.", + logger.info(LogMarkers.SENSITIVE, + "'{}' ({}) unquarantined the user '{}' ({}) in guild '{}' for reason '{}'.", author.getUser().getAsTag(), author.getId(), target.getUser().getAsTag(), target.getId(), guild.getName(), reason); diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/WarnCommand.java b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/WarnCommand.java index e518e5c5da..ef7073dea3 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/WarnCommand.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/WarnCommand.java @@ -11,6 +11,7 @@ import org.slf4j.LoggerFactory; import org.togetherjava.tjbot.commands.CommandVisibility; import org.togetherjava.tjbot.commands.SlashCommandAdapter; +import org.togetherjava.tjbot.logging.LogMarkers; import javax.annotation.Nullable; import java.util.Objects; @@ -69,7 +70,7 @@ private static RestAction dmUser(ISnowflake target, String reason, Guil } private void warnUser(User target, Member author, String reason, Guild guild) { - logger.info("'{}' ({}) warned the user '{}' ({}) for reason '{}'.", + logger.info(LogMarkers.SENSITIVE, "'{}' ({}) warned the user '{}' ({}) for reason '{}'.", author.getUser().getAsTag(), author.getId(), target.getAsTag(), target.getId(), reason); diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/scam/ScamBlocker.java b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/scam/ScamBlocker.java index 5cf745fdde..f12e547944 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/scam/ScamBlocker.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/scam/ScamBlocker.java @@ -26,6 +26,7 @@ import org.togetherjava.tjbot.commands.utils.MessageUtils; import org.togetherjava.tjbot.config.Config; import org.togetherjava.tjbot.config.ScamBlockerConfig; +import org.togetherjava.tjbot.logging.LogMarkers; import java.awt.Color; import java.util.*; @@ -176,7 +177,8 @@ private void addScamToHistory(MessageReceivedEvent event) { } private void logScamMessage(MessageReceivedEvent event) { - logger.warn("Detected a scam message ('{}') from user '{}' in channel '{}' of guild '{}'.", + logger.warn(LogMarkers.SENSITIVE, + "Detected a scam message ('{}') from user '{}' in channel '{}' of guild '{}'.", event.getMessageId(), event.getAuthor().getId(), event.getChannel().getId(), event.getGuild().getId()); } @@ -281,7 +283,7 @@ public void onButtonClick(ButtonInteractionEvent event, List argsRaw) { MessageUtils.disableButtons(event.getMessage()); event.deferEdit().queue(); if (event.getButton().getStyle() == ButtonStyle.DANGER) { - logger.info( + logger.info(LogMarkers.SENSITIVE, "Identified a false-positive scam (id '{}', hash '{}') in guild '{}' sent by author '{}'", args.messageId, args.contentHash, args.guildId, args.authorId); return; @@ -307,7 +309,7 @@ public void onButtonClick(ButtonInteractionEvent event, List argsRaw) { TextChannel channel = guild.getTextChannelById(scamMessage.channelId()); if (channel == null) { logger.debug( - "Attempted to delete scam messages, bot the channel '{}' does not exist anymore, skipping deleting messages for this channel.", + "Attempted to delete scam messages, but the channel '{}' does not exist anymore, skipping deleting messages for this channel.", scamMessage.channelId()); return; } @@ -318,10 +320,10 @@ public void onButtonClick(ButtonInteractionEvent event, List argsRaw) { Consumer onRetrieveAuthorFailure = new ErrorHandler() .handle(ErrorResponse.UNKNOWN_USER, - failure -> logger.debug( + failure -> logger.debug(LogMarkers.SENSITIVE, "Attempted to handle scam, but user '{}' does not exist anymore.", args.authorId)) - .handle(ErrorResponse.UNKNOWN_MEMBER, failure -> logger.debug( + .handle(ErrorResponse.UNKNOWN_MEMBER, failure -> logger.debug(LogMarkers.SENSITIVE, "Attempted to handle scam, but user '{}' is not a member of the guild anymore.", args.authorId)); diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/temp/RevocableRoleBasedAction.java b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/temp/RevocableRoleBasedAction.java index 1e5e61b7e4..ed60da7694 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/temp/RevocableRoleBasedAction.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/temp/RevocableRoleBasedAction.java @@ -3,6 +3,7 @@ import net.dv8tion.jda.api.exceptions.ErrorResponseException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.togetherjava.tjbot.logging.LogMarkers; /** * Role based moderation actions that can be revoked, for example a {@link TemporaryMuteAction} or a @@ -28,10 +29,10 @@ public FailureIdentification handleRevokeFailure(Throwable failure, long targetI if (failure instanceof ErrorResponseException errorResponseException) { switch (errorResponseException.getErrorResponse()) { - case UNKNOWN_USER -> logger.debug( + case UNKNOWN_USER -> logger.debug(LogMarkers.SENSITIVE, "Attempted to revoke a temporary {} but user '{}' does not exist anymore.", actionName, targetId); - case UNKNOWN_MEMBER -> logger.debug( + case UNKNOWN_MEMBER -> logger.debug(LogMarkers.SENSITIVE, "Attempted to revoke a temporary {} but user '{}' is not a member of the guild anymore.", actionName, targetId); case UNKNOWN_ROLE -> logger.warn( diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/temp/TemporaryBanAction.java b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/temp/TemporaryBanAction.java index 1e6cdb779a..bedb8374dc 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/temp/TemporaryBanAction.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/temp/TemporaryBanAction.java @@ -8,6 +8,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.togetherjava.tjbot.commands.moderation.ModerationAction; +import org.togetherjava.tjbot.logging.LogMarkers; /** * Action to revoke temporary bans, as applied by @@ -36,14 +37,14 @@ public RestAction revokeAction(Guild guild, User target, String reason) { public FailureIdentification handleRevokeFailure(Throwable failure, long targetId) { if (failure instanceof ErrorResponseException errorResponseException) { if (errorResponseException.getErrorResponse() == ErrorResponse.UNKNOWN_USER) { - logger.debug( + logger.debug(LogMarkers.SENSITIVE, "Attempted to revoke a temporary ban but user '{}' does not exist anymore.", targetId); return FailureIdentification.KNOWN; } if (errorResponseException.getErrorResponse() == ErrorResponse.UNKNOWN_BAN) { - logger.debug( + logger.debug(LogMarkers.SENSITIVE, "Attempted to revoke a temporary ban but the user '{}' is not banned anymore.", targetId); return FailureIdentification.KNOWN; diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/temp/TemporaryModerationRoutine.java b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/temp/TemporaryModerationRoutine.java index 0cf42e45cd..d989bc6895 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/temp/TemporaryModerationRoutine.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/temp/TemporaryModerationRoutine.java @@ -11,6 +11,7 @@ import org.togetherjava.tjbot.commands.moderation.ModerationAction; import org.togetherjava.tjbot.commands.moderation.ModerationActionsStore; import org.togetherjava.tjbot.config.Config; +import org.togetherjava.tjbot.logging.LogMarkers; import java.util.Map; import java.util.Objects; @@ -123,8 +124,8 @@ private void revokeAction(RevocationGroupIdentifier groupIdentifier) { private RestAction executeRevocation(Guild guild, User target, ModerationAction actionType) { - logger.info("Revoked temporary action {} against user '{}' ({}).", actionType, - target.getAsTag(), target.getId()); + logger.info(LogMarkers.SENSITIVE, "Revoked temporary action {} against user '{}' ({}).", + actionType, target.getAsTag(), target.getId()); RevocableModerationAction action = getRevocableActionByType(actionType); String reason = "Automatic revocation of temporary action."; @@ -140,7 +141,7 @@ private void handleFailure(Throwable failure, RevocationGroupIdentifier groupIde return; } - logger.warn( + logger.warn(LogMarkers.SENSITIVE, "Attempted to revoke a temporary moderation action for user '{}' but something unexpected went wrong.", groupIdentifier.targetId, failure); } diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/tags/TagManageCommand.java b/application/src/main/java/org/togetherjava/tjbot/commands/tags/TagManageCommand.java index bcb3939834..d72a4e1b14 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/tags/TagManageCommand.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/tags/TagManageCommand.java @@ -296,9 +296,8 @@ private Optional getTagContent(Subcommand subcommand, String id) { } catch (NoSuchElementException e) { // NOTE Rare race condition, for example if another thread deleted the tag in the // meantime - logger.warn(String.format( - "tried to retrieve content of tag '%s', but the content doesn't exist.", - id)); + logger.warn("Tried to retrieve content of tag '{}', but the content doesn't exist.", + id); } } diff --git a/application/src/main/java/org/togetherjava/tjbot/config/Config.java b/application/src/main/java/org/togetherjava/tjbot/config/Config.java index b2ac4062b9..d9b9d8a88f 100644 --- a/application/src/main/java/org/togetherjava/tjbot/config/Config.java +++ b/application/src/main/java/org/togetherjava/tjbot/config/Config.java @@ -31,8 +31,9 @@ public final class Config { private final String wolframAlphaAppId; private final HelpSystemConfig helpSystem; private final List blacklistedFileExtension; - private final String mediaOnlyChannelPattern; + private final String logInfoChannelWebhook; + private final String logErrorChannelWebhook; @SuppressWarnings("ConstructorWithTooManyParameters") @JsonCreator(mode = JsonCreator.Mode.PROPERTIES) @@ -59,7 +60,11 @@ private Config(@JsonProperty(value = "token", required = true) String token, @JsonProperty(value = "mediaOnlyChannelPattern", required = true) String mediaOnlyChannelPattern, @JsonProperty(value = "blacklistedFileExtension", - required = true) List blacklistedFileExtension) { + required = true) List blacklistedFileExtension, + @JsonProperty(value = "logInfoChannelWebhook", + required = true) String logInfoChannelWebhook, + @JsonProperty(value = "logErrorChannelWebhook", + required = true) String logErrorChannelWebhook) { this.token = Objects.requireNonNull(token); this.gistApiKey = Objects.requireNonNull(gistApiKey); this.databasePath = Objects.requireNonNull(databasePath); @@ -77,6 +82,8 @@ private Config(@JsonProperty(value = "token", required = true) String token, this.helpSystem = Objects.requireNonNull(helpSystem); this.mediaOnlyChannelPattern = Objects.requireNonNull(mediaOnlyChannelPattern); this.blacklistedFileExtension = Objects.requireNonNull(blacklistedFileExtension); + this.logInfoChannelWebhook = Objects.requireNonNull(logInfoChannelWebhook); + this.logErrorChannelWebhook = Objects.requireNonNull(logErrorChannelWebhook); } /** @@ -250,4 +257,22 @@ public String getMediaOnlyChannelPattern() { public List getBlacklistedFileExtensions() { return Collections.unmodifiableList(blacklistedFileExtension); } + + /** + * The Discord channel webhook for posting log messages with levels INFO, DEBUG and TRACE. + * + * @return the webhook URL + */ + public String getLogInfoChannelWebhook() { + return logInfoChannelWebhook; + } + + /** + * The Discord channel webhook for posting log messages with levels FATAL, ERROR and WARNING. + * + * @return the webhook URL + */ + public String getLogErrorChannelWebhook() { + return logErrorChannelWebhook; + } } diff --git a/application/src/main/java/org/togetherjava/tjbot/logging/LogMarkers.java b/application/src/main/java/org/togetherjava/tjbot/logging/LogMarkers.java new file mode 100644 index 0000000000..0be74a74ca --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/logging/LogMarkers.java @@ -0,0 +1,23 @@ +package org.togetherjava.tjbot.logging; + +import org.slf4j.Marker; +import org.slf4j.MarkerFactory; + +/** + * Markers to be used for tagging logs. Loggers can then be filter based on them. + */ +public final class LogMarkers { + /** + * Log should not be forwarded to Discord. + */ + public static final Marker NO_DISCORD = MarkerFactory.getMarker("NO_DISCORD"); + /** + * The log contains sensitive information that only moderators and people with similar authority + * should be allowed to view. + */ + public static final Marker SENSITIVE = MarkerFactory.getMarker("SENSITIVE"); + + private LogMarkers() { + throw new UnsupportedOperationException("Utility class"); + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/logging/discord/DiscordLogAppender.java b/application/src/main/java/org/togetherjava/tjbot/logging/discord/DiscordLogAppender.java new file mode 100644 index 0000000000..c826e5066e --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/logging/discord/DiscordLogAppender.java @@ -0,0 +1,64 @@ +package org.togetherjava.tjbot.logging.discord; + +import org.apache.logging.log4j.core.*; +import org.apache.logging.log4j.core.appender.AbstractAppender; +import org.apache.logging.log4j.core.config.Property; +import org.apache.logging.log4j.core.config.plugins.Plugin; +import org.apache.logging.log4j.core.config.plugins.PluginBuilderAttribute; +import org.apache.logging.log4j.core.config.plugins.PluginBuilderFactory; +import org.apache.logging.log4j.core.config.plugins.validation.constraints.Required; + +import java.io.Serializable; +import java.net.URI; +import java.util.Objects; + +@Plugin(name = "Discord", category = Core.CATEGORY_NAME, elementType = Appender.ELEMENT_TYPE) +final class DiscordLogAppender extends AbstractAppender { + private static final Property[] NO_PROPERTIES = {}; + + private final DiscordLogForwarder logForwarder; + + private DiscordLogAppender(String name, Filter filter, StringLayout layout, + boolean ignoreExceptions, URI webhook) { + super(name, filter, layout, ignoreExceptions, NO_PROPERTIES); + + logForwarder = new DiscordLogForwarder(webhook); + } + + @Override + public void append(LogEvent event) { + logForwarder.forwardLogEvent(event); + } + + @PluginBuilderFactory + static DiscordLogAppenderBuilder newBuilder() { + return new DiscordLogAppenderBuilder(); + } + + static final class DiscordLogAppenderBuilder + extends AbstractAppender.Builder + implements org.apache.logging.log4j.core.util.Builder { + + @PluginBuilderAttribute + @Required + private URI webhook; + + public DiscordLogAppenderBuilder setWebhook(URI webhook) { + this.webhook = webhook; + return asBuilder(); + } + + @Override + public DiscordLogAppender build() { + Layout layout = getOrCreateLayout(); + if (!(layout instanceof StringLayout)) { + throw new IllegalArgumentException("Layout must be a StringLayout"); + } + + String name = Objects.requireNonNull(getName()); + + return new DiscordLogAppender(name, getFilter(), (StringLayout) layout, + isIgnoreExceptions(), webhook); + } + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/logging/discord/DiscordLogForwarder.java b/application/src/main/java/org/togetherjava/tjbot/logging/discord/DiscordLogForwarder.java new file mode 100644 index 0000000000..92b087d073 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/logging/discord/DiscordLogForwarder.java @@ -0,0 +1,154 @@ +package org.togetherjava.tjbot.logging.discord; + +import club.minnced.discord.webhook.WebhookClient; +import club.minnced.discord.webhook.send.WebhookEmbed; +import club.minnced.discord.webhook.send.WebhookEmbedBuilder; +import club.minnced.discord.webhook.send.WebhookMessage; +import org.apache.logging.log4j.Level; +import org.apache.logging.log4j.core.LogEvent; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.togetherjava.tjbot.logging.LogMarkers; + +import java.net.URI; +import java.time.Instant; +import java.util.*; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.stream.Stream; + +/** + * Forwards log events to a Discord channel via a webhook. See {@link #forwardLogEvent(LogEvent)}. + *

+ * Logs are forwarded in correct order, based on their timestamp. They are not forwarded + * immediately, but at a fixed schedule in batches of {@value MAX_BATCH_SIZE} logs. + *

+ * Although unlikely to hit, the class maximally buffers {@value MAX_PENDING_LOGS} logs until + * discarding further logs. Under normal circumstances, the class can easily handle high loads of + * logs. + *

+ * The class is thread-safe. + */ +final class DiscordLogForwarder { + private static final Logger logger = LoggerFactory.getLogger(DiscordLogForwarder.class); + + private static final int MAX_PENDING_LOGS = 10_000; + private static final int MAX_BATCH_SIZE = WebhookMessage.MAX_EMBEDS; + private static final ScheduledExecutorService SERVICE = + Executors.newSingleThreadScheduledExecutor(); + /** + * Has to be small enough for fitting all {@value MAX_BATCH_SIZE} embeds contained in a batch + * into the total character length of ~6000. + */ + private static final int MAX_EMBED_DESCRIPTION = 1_000; + private static final Map LEVEL_TO_AMBIENT_COLOR = + Map.of(Level.TRACE, 0x00B362, Level.DEBUG, 0x00A5CE, Level.INFO, 0xAC59FF, Level.WARN, + 0xDFDF00, Level.ERROR, 0xBF2200, Level.FATAL, 0xFF8484); + + private final WebhookClient webhookClient; + /** + * Internal buffer of logs that still have to be forwarded to Discord. Actions are synchronized + * using {@link #pendingLogsLock} to ensure thread safety. + */ + private final Queue pendingLogs = new PriorityQueue<>(); + private final Object pendingLogsLock = new Object(); + + DiscordLogForwarder(URI webhook) { + webhookClient = WebhookClient.withUrl(webhook.toString()); + + SERVICE.scheduleWithFixedDelay(this::processPendingLogs, 5, 5, TimeUnit.SECONDS); + } + + + /** + * Forwards the given log message to Discord. + *

+ * Logs are not immediately forwarded, but on a schedule. If the maximal buffer size of + * {@value MAX_PENDING_LOGS} is exceeded, logs are discarded. + *

+ * This method is thread-safe. + * + * @param event the log to forward + */ + void forwardLogEvent(LogEvent event) { + if (pendingLogs.size() >= MAX_PENDING_LOGS) { + logger.warn(LogMarkers.NO_DISCORD, + """ + Exceeded the max amount of logs that can be buffered. \ + Logs are forwarded to Discord slower than they pile up. Discarding the latest log..."""); + return; + } + + LogMessage log = LogMessage.ofEvent(event); + + synchronized (pendingLogsLock) { + pendingLogs.add(log); + } + } + + private void processPendingLogs() { + try { + // Process batch + List logsToProcess = pollLogsToProcessBatch(); + if (logsToProcess.isEmpty()) { + return; + } + + List logBatch = logsToProcess.stream().map(LogMessage::embed).toList(); + + webhookClient.send(logBatch); + } catch (Exception e) { + logger.warn(LogMarkers.NO_DISCORD, + "Unknown error when forwarding pending logs to Discord.", e); + } + } + + private List pollLogsToProcessBatch() { + int batchSize = Math.min(pendingLogs.size(), MAX_BATCH_SIZE); + synchronized (pendingLogsLock) { + return Stream.generate(pendingLogs::remove).limit(batchSize).toList(); + } + } + + private record LogMessage(WebhookEmbed embed, + Instant timestamp) implements Comparable { + + private static LogMessage ofEvent(LogEvent event) { + String authorName = event.getLoggerName(); + String title = event.getLevel().name(); + int colorDecimal = Objects.requireNonNull(LEVEL_TO_AMBIENT_COLOR.get(event.getLevel())); + String description = + abbreviate(event.getMessage().getFormattedMessage(), MAX_EMBED_DESCRIPTION); + Instant timestamp = Instant.ofEpochMilli(event.getInstant().getEpochMillisecond()); + + WebhookEmbed embed = new WebhookEmbedBuilder() + .setAuthor(new WebhookEmbed.EmbedAuthor(authorName, null, null)) + .setTitle(new WebhookEmbed.EmbedTitle(title, null)) + .setDescription(description) + .setColor(colorDecimal) + .setTimestamp(timestamp) + .build(); + + return new LogMessage(embed, timestamp); + } + + private static String abbreviate(String text, int maxLength) { + if (text.length() < maxLength) { + return text; + } + + if (maxLength < 3) { + return text.substring(0, maxLength); + } + + return text.substring(0, maxLength - 3) + "..."; + } + + @Override + public int compareTo(@NotNull LogMessage o) { + return timestamp.compareTo(o.timestamp); + } + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/logging/discord/DiscordLogging.java b/application/src/main/java/org/togetherjava/tjbot/logging/discord/DiscordLogging.java new file mode 100644 index 0000000000..da823cc501 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/logging/discord/DiscordLogging.java @@ -0,0 +1,104 @@ +package org.togetherjava.tjbot.logging.discord; + +import org.apache.logging.log4j.Level; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.core.Appender; +import org.apache.logging.log4j.core.Filter; +import org.apache.logging.log4j.core.LoggerContext; +import org.apache.logging.log4j.core.config.Configuration; +import org.apache.logging.log4j.core.filter.CompositeFilter; +import org.apache.logging.log4j.core.filter.LevelRangeFilter; +import org.apache.logging.log4j.core.filter.MarkerFilter; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.togetherjava.tjbot.config.Config; +import org.togetherjava.tjbot.logging.LogMarkers; + +import java.net.URI; +import java.util.Optional; + +/** + * Provides solutions for forwarding logs to Discord. See {@link #startDiscordLogging(Config)}. + */ +public final class DiscordLogging { + private static final Logger logger = LoggerFactory.getLogger(DiscordLogging.class); + + private DiscordLogging() { + throw new UnsupportedOperationException("Utility class"); + } + + /** + * Sets up and starts the log forwarding to Discord. + *

+ * Disables the feature if the config is set up incorrectly. + * + * @param botConfig to get the logging details from, such as the Discord webhook urls + */ + public static void startDiscordLogging(Config botConfig) { + LoggerContext context = (LoggerContext) LogManager.getContext(false); + Configuration logConfig = context.getConfiguration(); + + addAppenders(logConfig, botConfig); + + context.updateLoggers(); + } + + private static void addAppenders(Configuration logConfig, Config botConfig) { + parseWebhookUri(botConfig.getLogInfoChannelWebhook()) + .ifPresent(webhookUri -> addDiscordLogAppender("DiscordInfo", createInfoRangeFilter(), + webhookUri, logConfig)); + + parseWebhookUri(botConfig.getLogErrorChannelWebhook()) + .ifPresent(webhookUri -> addDiscordLogAppender("DiscordError", createErrorRangeFilter(), + webhookUri, logConfig)); + } + + private static Optional parseWebhookUri(String webhookUri) { + try { + return Optional.of(URI.create(webhookUri)); + } catch (IllegalArgumentException e) { + logger.warn(LogMarkers.NO_DISCORD, + "The webhook URL ({}) in the config is invalid, logs will not be forwarded to Discord.", + webhookUri, e); + return Optional.empty(); + } + } + + // Security warning about configuring logs. It is safe in this case, the only user input are the + // webhook URIs, which cannot inject anything malicious. + // The only risk is changing the target to an attackers' server, but therefore they need access + // to the config. + @SuppressWarnings("squid:S4792") + private static void addDiscordLogAppender(String name, Filter filter, URI webhookUri, + Configuration logConfig) { + // NOTE The whole setup is done programmatically in order to allow the webhooks + // to be read from the config file + Filter[] filters = {filter, createDenyMarkerFilter(LogMarkers.NO_DISCORD.getName()), + createDenyMarkerFilter(LogMarkers.SENSITIVE.getName())}; + + Appender appender = DiscordLogAppender.newBuilder() + .setName(name) + .setWebhook(webhookUri) + .setFilter(CompositeFilter.createFilters(filters)) + .build(); + + appender.start(); + + logConfig.addAppender(appender); + logConfig.getRootLogger().addAppender(appender, null, null); + } + + private static Filter createDenyMarkerFilter(String markerName) { + return MarkerFilter.createFilter(markerName, Filter.Result.DENY, Filter.Result.NEUTRAL); + } + + private static Filter createInfoRangeFilter() { + return LevelRangeFilter.createFilter(Level.INFO, Level.TRACE, Filter.Result.NEUTRAL, + Filter.Result.DENY); + } + + private static Filter createErrorRangeFilter() { + return LevelRangeFilter.createFilter(Level.FATAL, Level.WARN, Filter.Result.NEUTRAL, + Filter.Result.DENY); + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/logging/discord/package-info.java b/application/src/main/java/org/togetherjava/tjbot/logging/discord/package-info.java new file mode 100644 index 0000000000..d81c9230a2 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/logging/discord/package-info.java @@ -0,0 +1,11 @@ +/** + * Contains a Discord log forwarder plugin, accessible by + * {@link org.togetherjava.tjbot.logging.discord.DiscordLogging}. + */ +@MethodsReturnNonnullByDefault +@ParametersAreNonnullByDefault +package org.togetherjava.tjbot.logging.discord; + +import org.togetherjava.tjbot.annotations.MethodsReturnNonnullByDefault; + +import javax.annotation.ParametersAreNonnullByDefault;