71: Implement Discord webhook notification delivery

This commit is contained in:
2026-08-12 10:42:39 +02:00
parent df504e29bf
commit 961f8c69cb
7 changed files with 399 additions and 25 deletions
@@ -1,49 +1,258 @@
package com.r35157.service.notification.impl.discord;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.r35157.service.notification.BoundNotificationService;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.jetbrains.annotations.NotNull;
import java.io.IOException;
import java.net.URL;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Map;
/**
* Placeholder for bound Discord webhook notification delivery.
* Synchronously delivers bound notification messages through a Discord
* incoming webhook.
*/
public class DiscordNotificationService implements BoundNotificationService {
/**
* Creates a service bound to a Discord webhook URL.
* Creates a service bound to a canonical Discord incoming-webhook endpoint.
*
* @param discordWebhookUrl Discord webhook URL
* <p>The supplied user agent must use Discord's canonical format:
* {@code DiscordBot (<client-url>, <version-number>)}. For example:
* {@code DiscordBot (https://example.com/my-project, 1.0)}.
*
* <p>The client URL and version must identify the application or library
* configuring this service, rather than this transport implementation.
* Discord may reject or block HTTP requests that do not provide a valid
* user agent.
*
* @param discordWebhookEndpoint Discord incoming-webhook endpoint
* @param userAgent complete Discord HTTP user agent in the canonical
* {@code DiscordBot (<client-url>, <version-number>)} format
* @param timeout connection and individual request timeout
* @throws IllegalArgumentException if the endpoint is not a structurally
* valid canonical Discord incoming-webhook
* endpoint, if the user agent does not use
* the required Discord format, or if the
* timeout is null, zero, or negative
*/
public DiscordNotificationService(URL discordWebhookUrl) {
this.httpClient = HttpClient.newHttpClient();
this.discordWebhookUrl = discordWebhookUrl;
public DiscordNotificationService(
@NotNull ΩRestEndpointΩ discordWebhookEndpoint,
@NotNull ΩUserAgentΩ userAgent,
@NotNull Duration timeout
) {
this.discordWebhookEndpoint = validateDiscordWebhookEndpoint(discordWebhookEndpoint);
this.userAgent = validateDiscordUserAgent(userAgent);
this.timeout = validateTimeout(timeout);
this.httpClient = HttpClient.newBuilder()
.connectTimeout(this.timeout)
.followRedirects(HttpClient.Redirect.NEVER)
.build();
}
@Override
public void push(@NotNull ΩNotificationMessageΩ message) throws IOException {
validateDiscordMessage(message);
throw new UnsupportedOperationException("Not implemented yet!");
}
private static void validateDiscordMessage(ΩNotificationMessageΩ message) {
if (message == null || message.isBlank()) {
throw new IllegalArgumentException("Notification message cannot be blank");
HttpRequest request = createRequest(message);
HttpResponse<String> response;
try {
response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
} catch(InterruptedException ie) {
Thread.currentThread().interrupt();
throw new IOException("Discord delivery was interrupted", ie);
} catch(IOException ioe) {
throw new IOException("Discord delivery failed", ioe);
}
if (response.statusCode() < 200 || response.statusCode() >= 300) {
throw new IOException(createHttpFailureMessage(response));
}
}
private void test() throws IOException {
URL discordWebhookUrl = null;
private HttpRequest createRequest(ΩNotificationMessageΩ message) throws IOException {
try {
String payload = OBJECT_MAPPER.writeValueAsString(Map.of("content", message));
BoundNotificationService notificationService =
new DiscordNotificationService(discordWebhookUrl);
ΩNotificationMessageΩ message = "Hello World!";
notificationService.push(message);
return HttpRequest.newBuilder()
.uri(createExecutionEndpoint())
.timeout(timeout)
.header("Content-Type", "application/json; charset=UTF-8")
.header("Accept", "application/json")
.header("User-Agent", userAgent)
.POST(HttpRequest.BodyPublishers.ofString(
payload,
StandardCharsets.UTF_8
))
.build();
} catch(JsonProcessingException jpe) {
throw new IOException("Failed to serialize Discord notification message", jpe);
} catch(RuntimeException re) {
throw new IOException("Failed to prepare Discord delivery request", re);
}
}
private ΩRestEndpointΩ createExecutionEndpoint() {
return URI.create(discordWebhookEndpoint + "?wait=true");
}
private String createHttpFailureMessage(HttpResponse<String> response) {
String responseBody = redactWebhookSecret(response.body());
if (responseBody == null || responseBody.isBlank()) {
return "Discord delivery failed: HTTP " + response.statusCode();
}
return "Discord delivery failed: HTTP " + response.statusCode()
+ ": " + responseBody;
}
private String redactWebhookSecret(String value) {
if (value == null) {
return null;
}
return value
.replace(discordWebhookEndpoint.toString(), "<redacted>")
.replace(webhookToken(), "<redacted>");
}
private String webhookToken() {
String path = discordWebhookEndpoint.getPath();
return path.substring(path.lastIndexOf('/') + 1);
}
private static ΩRestEndpointΩ validateDiscordWebhookEndpoint(final ΩRestEndpointΩ endpoint) {
if (endpoint == null
|| !endpoint.isAbsolute()
|| !"https".equalsIgnoreCase(endpoint.getScheme())
|| endpoint.getHost() == null
|| !"discord.com".equalsIgnoreCase(endpoint.getHost())
|| endpoint.getUserInfo() != null
|| endpoint.getFragment() != null
|| endpoint.getQuery() != null
|| (endpoint.getPort() != -1 && endpoint.getPort() != 443)
|| !isCanonicalWebhookPath(endpoint.getPath()))
{
throw new IllegalArgumentException("Invalid Discord webhook endpoint");
}
return endpoint;
}
private static boolean isCanonicalWebhookPath(String path) {
if (path == null) {
return false;
}
String[] segments = path.split("/", -1);
if (segments.length == 5) {
return isUnversionedWebhookPath(segments);
}
if (segments.length == 6) {
return isVersionedWebhookPath(segments);
}
return false;
}
private static boolean isUnversionedWebhookPath(String[] segments) {
return segments[0].isEmpty()
&& "api".equals(segments[1])
&& "webhooks".equals(segments[2])
&& !segments[3].isBlank()
&& !segments[4].isBlank();
}
private static boolean isVersionedWebhookPath(String[] segments) {
return segments[0].isEmpty()
&& "api".equals(segments[1])
&& isSupportedDiscordApiVersion(segments[2])
&& "webhooks".equals(segments[3])
&& !segments[4].isBlank()
&& !segments[5].isBlank();
}
private static boolean isSupportedDiscordApiVersion(String version) {
return "v6".equals(version)
|| "v7".equals(version)
|| "v8".equals(version)
|| "v9".equals(version)
|| "v10".equals(version);
}
private static Duration validateTimeout(final Duration timeout) {
if (timeout == null || timeout.isZero() || timeout.isNegative()) {
throw new IllegalArgumentException("Discord timeout must be positive");
}
return timeout;
}
private static void validateDiscordMessage(final ΩNotificationMessageΩ message) {
if (message == null || message.isBlank()) {
throw new IllegalArgumentException("Notification message cannot be blank");
}
if (message.length() > DISCORD_CONTENT_LIMIT) {
throw new IllegalArgumentException(
"Notification message exceeds Discord's 2,000-character limit"
);
}
}
private static ΩUserAgentΩ validateDiscordUserAgent(final ΩUserAgentΩ userAgent) {
if (userAgent == null) {
throw new IllegalArgumentException("Discord user agent cannot be null");
}
Matcher matcher = DISCORD_USER_AGENT_PATTERN.matcher(userAgent);
if (!matcher.matches()) {
throw new IllegalArgumentException(
"Discord user agent must use the format "
+ "'DiscordBot (<client-url>, <version-number>)'"
);
}
URI clientUri;
try {
clientUri = URI.create(matcher.group("clientUrl"));
} catch(IllegalArgumentException iae) {
throw new IllegalArgumentException(
"Discord user agent contains an invalid client URL",
iae
);
}
if (!clientUri.isAbsolute()
|| clientUri.getHost() == null
|| (!"https".equalsIgnoreCase(clientUri.getScheme())
&& !"http".equalsIgnoreCase(clientUri.getScheme()))
|| clientUri.getUserInfo() != null
|| clientUri.getFragment() != null)
{
throw new IllegalArgumentException(
"Discord user agent must contain an absolute HTTP(S) client URL"
);
}
return userAgent;
}
private static final int DISCORD_CONTENT_LIMIT = 2_000;
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
private static final Pattern DISCORD_USER_AGENT_PATTERN = Pattern.compile(
"\\ADiscordBot \\((?<clientUrl>[^,\\r\\n]+), "
+ "[0-9]+(?:\\.[0-9]+)*\\)\\z"
);
private final ΩUserAgentΩ userAgent; // Example: "DiscordBot (https://git.r35157.com/r35157/com_r35157_nenjim-hubd-impl_ref, 0.1)";
private final ΩRestEndpointΩ discordWebhookEndpoint;
private final Duration timeout;
private final HttpClient httpClient;
private final URL discordWebhookUrl;
}