diff --git a/application/config.json.template b/application/config.json.template index 84ac087cb0..3f9262c32b 100644 --- a/application/config.json.template +++ b/application/config.json.template @@ -87,6 +87,7 @@ "wsh" ], "logInfoChannelWebhook": "", - "logErrorChannelWebhook": "" - "openaiApiKey": "" + "logErrorChannelWebhook": "", + "openaiApiKey": "", + "sourceCodeBaseUrl": "//blob/master/application/src/main/java/>" } 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 e5933ca262..f314b129b3 100644 --- a/application/src/main/java/org/togetherjava/tjbot/config/Config.java +++ b/application/src/main/java/org/togetherjava/tjbot/config/Config.java @@ -37,6 +37,7 @@ public final class Config { private final String logInfoChannelWebhook; private final String logErrorChannelWebhook; private final String openaiApiKey; + private final String sourceCodeBaseUrl; @SuppressWarnings("ConstructorWithTooManyParameters") @JsonCreator(mode = JsonCreator.Mode.PROPERTIES) @@ -72,7 +73,8 @@ private Config(@JsonProperty(value = "token", required = true) String token, required = true) String logInfoChannelWebhook, @JsonProperty(value = "logErrorChannelWebhook", required = true) String logErrorChannelWebhook, - @JsonProperty(value = "openaiApiKey", required = true) String openaiApiKey) { + @JsonProperty(value = "openaiApiKey", required = true) String openaiApiKey, + @JsonProperty(value = "sourceCodeBaseUrl", required = true) String sourceCodeBaseUrl) { this.token = Objects.requireNonNull(token); this.gistApiKey = Objects.requireNonNull(gistApiKey); this.databasePath = Objects.requireNonNull(databasePath); @@ -96,6 +98,7 @@ private Config(@JsonProperty(value = "token", required = true) String token, this.logInfoChannelWebhook = Objects.requireNonNull(logInfoChannelWebhook); this.logErrorChannelWebhook = Objects.requireNonNull(logErrorChannelWebhook); this.openaiApiKey = Objects.requireNonNull(openaiApiKey); + this.sourceCodeBaseUrl = Objects.requireNonNull(sourceCodeBaseUrl); } /** @@ -316,4 +319,15 @@ public String getLogErrorChannelWebhook() { public String getOpenaiApiKey() { return openaiApiKey; } + + /** + * The base URL of the source code of this bot. E.g. + * {@code getSourceCodeBaseUrl() + "/org/togetherjava/tjbot/config/Config.java"} would point to + * this file. + * + * @return the base url of the source code of this bot + */ + public String getSourceCodeBaseUrl() { + return sourceCodeBaseUrl; + } } 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 index c826e5066e..fbf6e97a83 100644 --- a/application/src/main/java/org/togetherjava/tjbot/logging/discord/DiscordLogAppender.java +++ b/application/src/main/java/org/togetherjava/tjbot/logging/discord/DiscordLogAppender.java @@ -19,10 +19,10 @@ final class DiscordLogAppender extends AbstractAppender { private final DiscordLogForwarder logForwarder; private DiscordLogAppender(String name, Filter filter, StringLayout layout, - boolean ignoreExceptions, URI webhook) { + boolean ignoreExceptions, URI webhook, String sourceCodeBaseUrl) { super(name, filter, layout, ignoreExceptions, NO_PROPERTIES); - logForwarder = new DiscordLogForwarder(webhook); + logForwarder = new DiscordLogForwarder(webhook, sourceCodeBaseUrl); } @Override @@ -43,11 +43,19 @@ static final class DiscordLogAppenderBuilder @Required private URI webhook; + @Required + private String sourceCodeBaseUrl; + public DiscordLogAppenderBuilder setWebhook(URI webhook) { this.webhook = webhook; return asBuilder(); } + public DiscordLogAppenderBuilder setSourceCodeBaseUrl(String sourceCodeBaseUrl) { + this.sourceCodeBaseUrl = sourceCodeBaseUrl; + return asBuilder(); + } + @Override public DiscordLogAppender build() { Layout layout = getOrCreateLayout(); @@ -58,7 +66,7 @@ public DiscordLogAppender build() { String name = Objects.requireNonNull(getName()); return new DiscordLogAppender(name, getFilter(), (StringLayout) layout, - isIgnoreExceptions(), webhook); + isIgnoreExceptions(), webhook, sourceCodeBaseUrl); } } } 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 index ece1a49a5a..b86a6eec16 100644 --- a/application/src/main/java/org/togetherjava/tjbot/logging/discord/DiscordLogForwarder.java +++ b/application/src/main/java/org/togetherjava/tjbot/logging/discord/DiscordLogForwarder.java @@ -7,6 +7,7 @@ import org.apache.logging.log4j.Level; import org.apache.logging.log4j.core.LogEvent; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -70,6 +71,7 @@ final class DiscordLogForwarder { 0xDFDF00, Level.ERROR, 0xBF2200, Level.FATAL, 0xFF8484); private final WebhookClient webhookClient; + private final String sourceCodeBaseUrl; /** * Internal buffer of logs that still have to be forwarded to Discord. Actions are synchronized * using {@link #pendingLogsLock} to ensure thread safety. @@ -77,9 +79,15 @@ final class DiscordLogForwarder { private final Queue pendingLogs = new PriorityQueue<>(); private final Object pendingLogsLock = new Object(); - DiscordLogForwarder(URI webhook) { + DiscordLogForwarder(URI webhook, String sourceCodeBaseUrl) { webhookClient = WebhookClient.withUrl(webhook.toString()); + if (!sourceCodeBaseUrl.endsWith("/")) { + this.sourceCodeBaseUrl = sourceCodeBaseUrl + "/"; + } else { + this.sourceCodeBaseUrl = sourceCodeBaseUrl; + } + SERVICE.scheduleWithFixedDelay(this::processPendingLogs, 5, 5, TimeUnit.SECONDS); } @@ -110,7 +118,7 @@ void forwardLogEvent(LogEvent event) { """); } - LogMessage log = LogMessage.ofEvent(event); + LogMessage log = LogMessage.ofEvent(event, sourceCodeBaseUrl); synchronized (pendingLogsLock) { pendingLogs.add(log); @@ -160,8 +168,11 @@ private List validateBatch(List logBatch) { private record LogMessage(WebhookEmbed embed, Instant timestamp) implements Comparable { - private static LogMessage ofEvent(LogEvent event) { + private static final String BASE_PACKAGE = "org.togetherjava.tjbot."; + + private static LogMessage ofEvent(LogEvent event, String sourceCodeBaseUrl) { String authorName = event.getLoggerName(); + String authorUrl = linkToSource(event.getSource(), sourceCodeBaseUrl).orElse(null); String title = event.getLevel().name(); int colorDecimal = Objects.requireNonNull(LEVEL_TO_AMBIENT_COLOR.get(event.getLevel())); String description = @@ -169,13 +180,12 @@ private static LogMessage ofEvent(LogEvent event) { Instant timestamp = Instant.ofEpochMilli(event.getInstant().getEpochMillisecond()); WebhookEmbed embed = new WebhookEmbedBuilder() - .setAuthor(new WebhookEmbed.EmbedAuthor(authorName, null, null)) + .setAuthor(new WebhookEmbed.EmbedAuthor(authorName, null, authorUrl)) .setTitle(new WebhookEmbed.EmbedTitle(title, null)) .setDescription(description) .setColor(colorDecimal) .setTimestamp(timestamp) .build(); - return new LogMessage(embed, timestamp); } @@ -193,6 +203,21 @@ private static String describeLogEvent(LogEvent event) { return logMessage + "\n" + exceptionWriter.toString().replace("\t", "> "); } + private static Optional linkToSource(@Nullable StackTraceElement sourceElement, + String sourceCodeBaseUrl) { + if (sourceElement == null) { + return Optional.empty(); + } + + String source = sourceElement.getClassName(); + if (!source.startsWith(BASE_PACKAGE)) { + return Optional.empty(); + } + + String link = "%s%s.java".formatted(sourceCodeBaseUrl, source.replace('.', '/')); + return Optional.of(link); + } + private LogMessage shortened() { String shortDescription = MessageUtils.abbreviate( Objects.requireNonNull(embed.getDescription()), MAX_EMBED_DESCRIPTION_SHORT); 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 index da53282cf7..99d2de7970 100644 --- a/application/src/main/java/org/togetherjava/tjbot/logging/discord/DiscordLogging.java +++ b/application/src/main/java/org/togetherjava/tjbot/logging/discord/DiscordLogging.java @@ -47,11 +47,11 @@ public static void startDiscordLogging(Config botConfig) { private static void addAppenders(Configuration logConfig, Config botConfig) { parseWebhookUri(botConfig.getLogInfoChannelWebhook()) .ifPresent(webhookUri -> addDiscordLogAppender("DiscordInfo", createInfoRangeFilter(), - webhookUri, logConfig)); + webhookUri, botConfig.getSourceCodeBaseUrl(), logConfig)); parseWebhookUri(botConfig.getLogErrorChannelWebhook()) .ifPresent(webhookUri -> addDiscordLogAppender("DiscordError", createErrorRangeFilter(), - webhookUri, logConfig)); + webhookUri, botConfig.getSourceCodeBaseUrl(), logConfig)); } private static Optional parseWebhookUri(String webhookUri) { @@ -71,7 +71,7 @@ private static Optional parseWebhookUri(String webhookUri) { // to the config. @SuppressWarnings("squid:S4792") private static void addDiscordLogAppender(String name, Filter filter, URI webhookUri, - Configuration logConfig) { + String sourceCodeBaseUrl, 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()), @@ -80,6 +80,7 @@ private static void addDiscordLogAppender(String name, Filter filter, URI webhoo Appender appender = DiscordLogAppender.newBuilder() .setName(name) .setWebhook(webhookUri) + .setSourceCodeBaseUrl(sourceCodeBaseUrl) .setFilter(CompositeFilter.createFilters(filters)) .build();