71: Implement Discord webhook notification delivery
This commit is contained in:
+229
-20
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user