package com.aionemu.commons.logging; import java.io.IOException; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicLong; import java.util.regex.Matcher; import java.util.regex.Pattern; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import com.alibaba.fastjson2.JSON; import ch.qos.logback.core.AppenderBase; import ch.qos.logback.core.CoreConstants; import ch.qos.logback.core.encoder.Encoder; import ch.qos.logback.core.status.StatusManager; /** * Sends messages via a webhook (see API docs).
* If the message is longer than {@value #MAX_MESSAGE_LENGTH} characters (Discord limit) it will be sent in parts, while keeping code blocks intact. * * @author Neon */ public class DiscordChannelAppender extends AppenderBase { private static final Logger log = LoggerFactory.getLogger(DiscordChannelAppender.class); private static final int MAX_USERNAME_LENGTH = 32; private static final int MAX_MESSAGE_LENGTH = 2000; private static final Pattern CODE_BLOCK_TYPE_PATTERN = Pattern.compile("(```(?:[a-z]+\r?\n)?)"); private static final String CODE_BLOCK_END = "```"; private final AtomicLong floodResetTimeMillis = new AtomicLong(); private Encoder encoder; // required private String webhookUrl; // required private String userName_avatarUrl_msg_separator; // if specified, extracts user name and avatar to use by splitting the message with the separator private URI webhookUri; private HttpClient httpClient; @Override public void start() { if (checkValueMissing(encoder, "") || checkValueMissing(webhookUrl, "")) { return; } if (webhookUrl.isEmpty()) { addInfo(" is empty, appender will not be used"); StatusManager statusManager = context.getStatusManager(); statusManager.getCopyOfStatusListenerList().forEach(statusManager::remove); } else { webhookUri = URI.create(webhookUrl); httpClient = HttpClient.newHttpClient(); super.start(); } } private boolean checkValueMissing(Object value, String name) { if (value == null) { addError(name + " is missing"); return true; } if (value instanceof String v && v.endsWith(CoreConstants.UNDEFINED_PROPERTY_SUFFIX)) { addError(name + " is unresolved (configuration value for ${" + v.substring(0, v.length() - CoreConstants.UNDEFINED_PROPERTY_SUFFIX.length()) + "} is not set)"); return true; } return false; } @Override public void stop() { if (httpClient != null) { httpClient.close(); httpClient = null; } super.stop(); } @Override protected void append(E eventObject) { String rawMessage = new String(encoder.encode(eventObject)); String userName = null; String avatarUrl = null; String msg = rawMessage; if (userName_avatarUrl_msg_separator != null && !userName_avatarUrl_msg_separator.isEmpty()) { String[] parts = rawMessage.split(userName_avatarUrl_msg_separator, 3); for (int i = parts.length - 1, partCount = 0; i >= 0; i--, partCount++) { if (partCount == 0) { msg = parts[i]; } else if (partCount == 1) { avatarUrl = parts[i].trim(); } else if (partCount == 2) { userName = parts[i].trim(); } } } if (userName != null && userName.length() > MAX_USERNAME_LENGTH) userName = userName.substring(0, MAX_USERNAME_LENGTH - 1) + '…'; for (String messagePart : createMessageParts(msg)) { sendMessage(messagePart, userName, avatarUrl); } } private List createMessageParts(String msg) { msg = msg.replaceAll("\r\n", "\n"); // try to slightly shrink message due to the low message length limit if (msg.length() <= MAX_MESSAGE_LENGTH) return Collections.singletonList(msg); List messageParts = new ArrayList<>(); String codeBlockStart = null; int codeStartIndex = Integer.MAX_VALUE; int codeEndIndex = -1; if (msg.trim().endsWith(CODE_BLOCK_END)) { Matcher matcher = CODE_BLOCK_TYPE_PATTERN.matcher(msg); if (matcher.find()) { codeBlockStart = matcher.group(1).replaceAll("\n", ""); codeStartIndex = matcher.end() + 1; codeEndIndex = msg.lastIndexOf(CODE_BLOCK_END) - 1; } } int msgPosition = -1; String[] lines = msg.split("\n"); StringBuilder sb = new StringBuilder(MAX_MESSAGE_LENGTH); for (int i = 0; i < lines.length; i++) { String line = lines[i]; msgPosition += line.length() + (i == 0 ? 0 : 1); boolean isNewMessagePart = sb.length() == 0; boolean isInsideCodeBlock = msgPosition >= codeStartIndex && msgPosition <= codeEndIndex; if (isNewMessagePart && isInsideCodeBlock && !line.contains(codeBlockStart)) sb.append(codeBlockStart).append('\n'); else if (!isNewMessagePart) sb.append('\n'); int overflowingChars = calcTotalLength(line, sb, isInsideCodeBlock) - MAX_MESSAGE_LENGTH; if (overflowingChars <= 0) { // fits into current messagePart sb.append(line); if (i < lines.length - 1) continue; } else if (isNewMessagePart) { // must be truncated if (isInsideCodeBlock != (isInsideCodeBlock = msgPosition - overflowingChars >= codeStartIndex && msgPosition - overflowingChars <= codeEndIndex)) overflowingChars = calcTotalLength(line, sb, isInsideCodeBlock) - MAX_MESSAGE_LENGTH; sb.append(line, 0, line.length() - overflowingChars - 1).append('…'); } if (isInsideCodeBlock) { String codeBlockStartPlusNewLine = codeBlockStart + '\n'; if (sb.lastIndexOf(codeBlockStartPlusNewLine) == sb.length() - codeBlockStartPlusNewLine.length()) { // don't generate an empty code block at the end of the string sb.setLength(sb.length() - codeBlockStartPlusNewLine.length()); if (sb.length() > 0 && sb.charAt(sb.length() - 1) == '\n') // remove empty line sb.setLength(sb.length() - 1); } else sb.append(CODE_BLOCK_END); } if (sb.length() > 0 || line.length() == 0) // don't add an empty messagePart if it's because of a removed empty code block (see above) messageParts.add(sb.toString()); sb.setLength(0); if (!isNewMessagePart && overflowingChars > 0) { // this line will be the start of a new messagePart msgPosition -= line.length() + (i == 0 ? 0 : 1); // avoid duplicate count i--; } } return messageParts; } private int calcTotalLength(String line, StringBuilder sb, boolean isInsideCodeBlock) { int length = line.length() + sb.length(); if (isInsideCodeBlock) length += CODE_BLOCK_END.length(); return length; } private void sendMessage(String msg, String userName, String avatarUrl) { if (isRateLimited()) return; try { byte[] json = JSON.toJSONBytes(Map.of("content", msg, "username", userName, "avatar_url", avatarUrl)); HttpRequest httpRequest = HttpRequest.newBuilder(webhookUri) .headers("User-Agent", "DiscordChannelAppender/1.0") .headers("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofByteArray(json)) .build(); HttpResponse response = httpClient.send(httpRequest, HttpResponse.BodyHandlers.ofString()); handleResponse(response); } catch (InterruptedException ignored) { } catch (Exception e) { String errorHeader = "Error sending Discord message: "; if (!msg.contains(errorHeader)) // avoid potential recursive message sending (if appender sends warnings) log.warn(errorHeader + msg + "\nCaused by: " + e.getMessage()); } } private void handleResponse(HttpResponse response) throws IOException { if (response.statusCode() == 429) { long resetTime; long now = System.currentTimeMillis(); long rateLimitDurationMillis = response.headers().firstValueAsLong("Retry-After").orElse(0); if (rateLimitDurationMillis > 0) { resetTime = now + rateLimitDurationMillis; } else { resetTime = response.headers().firstValueAsLong("X-RateLimit-Reset").orElse(0) * 1000; } floodResetTimeMillis.set(resetTime > now ? resetTime : now + 3000); throw new IOException( "Flood control for channel triggered, reset in " + (floodResetTimeMillis.get() - now) / 1000 + "s. Meanwhile, all messages will be dropped."); } else if (response.statusCode() != 204) { throw new IOException("Server returned status code " + response.statusCode() + (response.body().isEmpty() ? "" : ": " + response.body())); } } private boolean isRateLimited() { return floodResetTimeMillis.get() > System.currentTimeMillis(); } public void setEncoder(Encoder encoder) { this.encoder = encoder; } public void setWebhookUrl(String webhookUrl) { this.webhookUrl = webhookUrl; } public void setUserName_avatarUrl_msg_separator(String userName_avatarUrl_msg_separator) { this.userName_avatarUrl_msg_separator = userName_avatarUrl_msg_separator; } }