diff --git a/openspec/changes/archive/2026-08-12-71-implement-discord-webhook-notification-delivery/.openspec.yaml b/openspec/changes/archive/2026-08-12-71-implement-discord-webhook-notification-delivery/.openspec.yaml new file mode 100644 index 0000000..5081c98 --- /dev/null +++ b/openspec/changes/archive/2026-08-12-71-implement-discord-webhook-notification-delivery/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-12 diff --git a/openspec/changes/archive/2026-08-12-71-implement-discord-webhook-notification-delivery/design.md b/openspec/changes/archive/2026-08-12-71-implement-discord-webhook-notification-delivery/design.md new file mode 100644 index 0000000..da2a5bd --- /dev/null +++ b/openspec/changes/archive/2026-08-12-71-implement-discord-webhook-notification-delivery/design.md @@ -0,0 +1,45 @@ +## Context + +See `proposal.md` for motivation. `DiscordNotificationService` is the only affected production class and currently validates only null/blank messages before throwing `UnsupportedOperationException`. The existing Jackson dependency can safely serialize the payload; no notification API, caller, or composition-root change is required. + +## Goals / Non-Goals + +**Goals:** + +- Construct a fully bound synchronous Discord webhook service from `ΩRestEndpointΩ` and a positive `Duration`. +- Validate the endpoint locally, preserve message text exactly, and make successful response handling explicit. +- Keep the webhook URI confidential in all validation and delivery paths. + +**Non-Goals:** + +- Configuration loading or Nenjim composition-root wiring. +- Retries, rate-limit waiting, asynchronous execution, or non-text Discord features. +- Changes to the notification API, Pushover, SMTP, alarms, Evelyn, or tests. + +## Decisions + +### Strict local URI validation before client construction + +The constructor validates the URI shape with URI components and a fixed path grammar: `https://discord.com/api/webhooks/{id}/{token}` or `https://discord.com/api/v{version}/webhooks/{id}/{token}`. Versioned paths accept only Discord API versions 6 through 10, which are currently non-discontinued; the host is case-insensitive and all token-bearing endpoint values are absent from diagnostics. A network probe is rejected because construction must be side-effect free and valid webhooks can change remotely. + +### One synchronous JSON request per valid push + +The service uses a construction-time `HttpClient` configured with the supplied connect timeout and `Redirect.NEVER`. Every valid message is serialized with Jackson into `content`, then posted with `wait=true`, the same request timeout, JSON headers, and a `DiscordBot (url, version)` User-Agent identifying Nenjim. This preserves the BoundNotificationService contract and lets Discord confirm persistence. Manual JSON escaping and asynchronous delivery are rejected. + +### Failure policy preserves secrets and interruption + +Only 2xx is successful. Other statuses become `IOException` containing only status and useful body. I/O failures are wrapped with non-sensitive context; interruption restores the flag and is the cause of an `IOException`. The service neither retries nor waits after 429, so each valid `push()` owns exactly one send attempt. + +## Risks / Trade-offs + +- **[Webhook endpoint is a credential]** → Never log or interpolate it into exceptions; redirect following is disabled. +- **[Strict URI validation could reject a future Discord route]** → Accept canonical unversioned and conventional versioned execute-webhook paths only; future route support requires an explicit spec change. +- **[No automated tests by issue instruction]** → Compile generated sources and unchanged tests, assemble, validate OpenSpec, and perform structural and diff checks without a live webhook. + +## Migration Plan + +1. Replace the URL-based stub constructor and delivery path in place. +2. Update the Discord requirement in the active capability delta. +3. Verify compilation, assembly, strict OpenSpec validation, and absence of old-stub patterns or credentials. + +Rollback is a single-class and active-change revert; no consumer migration is needed. diff --git a/openspec/changes/archive/2026-08-12-71-implement-discord-webhook-notification-delivery/proposal.md b/openspec/changes/archive/2026-08-12-71-implement-discord-webhook-notification-delivery/proposal.md new file mode 100644 index 0000000..e4d6bcc --- /dev/null +++ b/openspec/changes/archive/2026-08-12-71-implement-discord-webhook-notification-delivery/proposal.md @@ -0,0 +1,24 @@ +## Why + +The transport-independent notification API already exposes a bound Discord service, but its valid-message path is still a stub. Completing synchronous Discord incoming-webhook delivery makes that service usable without exposing Discord details to consumers. + +## What Changes + +- Replace the Discord stub with synchronous JSON webhook delivery through the existing bound notification-service API. +- Require a structurally valid canonical Discord webhook URI and a positive caller-supplied timeout at construction. +- Validate messages, preserve their content exactly, use `wait=true`, and report delivery failures through `IOException` without retrying. +- Protect webhook credentials from diagnostics and disable redirect following. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `notification-services`: Replaces the unimplemented Discord-delivery requirement with fully bound synchronous webhook-delivery, validation, response, interruption, and secret-protection behavior. + +## Impact + +Only `DiscordNotificationService` and the active OpenSpec change are affected. The existing Jackson dependency is reused. No composition-root wiring, other notification transport, or test code changes. diff --git a/openspec/changes/archive/2026-08-12-71-implement-discord-webhook-notification-delivery/specs/notification-services/spec.md b/openspec/changes/archive/2026-08-12-71-implement-discord-webhook-notification-delivery/specs/notification-services/spec.md new file mode 100644 index 0000000..602205d --- /dev/null +++ b/openspec/changes/archive/2026-08-12-71-implement-discord-webhook-notification-delivery/specs/notification-services/spec.md @@ -0,0 +1,48 @@ +## ADDED Requirements + +### Requirement: Discord webhook delivery +The Discord notification service SHALL remain a `BoundNotificationService` that is fully configured at construction from a non-null structurally valid canonical Discord incoming-webhook `ΩRestEndpointΩ` and a non-null positive `Duration`. It SHALL make no construction-time network request, follow no HTTP redirects, and SHALL NOT expose a raw `URL` or raw `String` constructor. + +The endpoint SHALL be an absolute HTTPS URI with a case-insensitive `discord.com` host, no user-info, fragment, query, or non-HTTPS port, and either an unversioned `/api/webhooks/{id}/{token}` path or a versioned `/api/v{version}/webhooks/{id}/{token}` path using a non-discontinued Discord API version from 6 through 10, with non-blank ID and token segments. Constructor validation diagnostics SHALL NOT disclose the endpoint or token. + +Every public `push(...)` entry point SHALL reject a null, empty, blank, or over-2,000-character `ΩNotificationMessageΩ` with `IllegalArgumentException` before JSON serialization, request construction, or transport work. For every valid message, the service SHALL synchronously send exactly one POST to the configured endpoint with `wait=true`, the configured request timeout, JSON UTF-8 content and accept headers, a `DiscordBot (url, version)` User-Agent identifying Nenjim, and a safely serialized JSON object whose `content` preserves the message exactly. The webhook token SHALL be the only authentication mechanism. + +Every 2xx HTTP response SHALL return normally. Every non-2xx response, including redirects and 429 responses, and every communication or request-preparation failure SHALL cause `IOException` without retrying, waiting, or sending another request. Response failures SHALL include the HTTP status and useful response body when available, without disclosing the endpoint or token. When synchronous HTTP delivery is interrupted, the service SHALL restore the thread interrupt flag and throw an `IOException` retaining the `InterruptedException` as its cause. + +#### Scenario: Fully configured construction +- **WHEN** a caller constructs the service with a canonical Discord webhook endpoint and positive timeout +- **THEN** it receives a bound notification service without a network request + +#### Scenario: Invalid endpoint or timeout is rejected +- **WHEN** a caller supplies a null or structurally invalid endpoint, or a null, zero, or negative timeout +- **THEN** construction throws `IllegalArgumentException` without disclosing the endpoint token + +#### Scenario: Invalid notification message is rejected before delivery +- **WHEN** a caller pushes a null, empty, blank, or over-2,000-character message +- **THEN** the service throws `IllegalArgumentException` before serialization or HTTP work + +#### Scenario: Valid message is synchronously confirmed +- **WHEN** a caller pushes a valid notification message and Discord returns a 2xx response +- **THEN** the service sends one synchronous JSON POST with `wait=true` and returns normally + +#### Scenario: Discord rejects delivery +- **WHEN** Discord returns a non-2xx response, including 429 or a redirect +- **THEN** the service throws `IOException` containing the status and does not retry or follow the redirect + +#### Scenario: Delivery communication fails +- **WHEN** preparing or sending the webhook request fails +- **THEN** the service throws `IOException` without exposing the webhook endpoint or token + +#### Scenario: Delivery is interrupted +- **WHEN** synchronous webhook delivery is interrupted +- **THEN** the service restores the thread interrupt status and throws `IOException` with the interruption as its cause + +#### Scenario: Webhook secret remains protected +- **WHEN** validation or delivery reports an error +- **THEN** no log or exception message includes the complete webhook endpoint or token + +## REMOVED Requirements + +### Requirement: Discord delivery remains unimplemented +**Reason**: Discord incoming-webhook delivery is now implemented synchronously. +**Migration**: Construct `DiscordNotificationService` with a canonical Discord webhook `ΩRestEndpointΩ` and a positive `Duration`, then use it through `BoundNotificationService`. diff --git a/openspec/changes/archive/2026-08-12-71-implement-discord-webhook-notification-delivery/tasks.md b/openspec/changes/archive/2026-08-12-71-implement-discord-webhook-notification-delivery/tasks.md new file mode 100644 index 0000000..ef64b32 --- /dev/null +++ b/openspec/changes/archive/2026-08-12-71-implement-discord-webhook-notification-delivery/tasks.md @@ -0,0 +1,12 @@ +## 1. Discord Webhook Delivery + +- [x] 1.1 Replace the URL stub constructor with validated `ΩRestEndpointΩ` and positive `Duration` construction, including connect-timeout and no-redirect client configuration. +- [x] 1.2 Implement direct notification-message validation and one synchronous Jackson JSON webhook POST with `wait=true`, headers, request timeout, and exact message content. +- [x] 1.3 Implement 2xx success handling and non-sensitive `IOException` handling for non-2xx, communication, timeout, and interrupted delivery without retries. +- [x] 1.4 Remove or update the private inline example so it cannot carry a credential or perform a network-executable example. + +## 2. Verification + +- [x] 2.1 Compile generated main sources and existing unchanged test sources, then run the normal assembly/build. +- [x] 2.2 Run strict validation of the active OpenSpec change and structural checks for the old URL constructor, stub, credentials, and prohibited scope changes. +- [x] 2.3 Review the complete diff, confirm no test or generated source changes, and commit the scoped implementation. diff --git a/openspec/specs/notification-services/spec.md b/openspec/specs/notification-services/spec.md index 2796445..bd72642 100644 --- a/openspec/specs/notification-services/spec.md +++ b/openspec/specs/notification-services/spec.md @@ -61,12 +61,46 @@ An SMTP destination SHALL contain both an `ΩEmailAddressΩ` and a fixed `ΩEmai - **WHEN** a valid message is pushed through a valid SMTP binding - **THEN** the service reports that SMTP delivery is not implemented -### Requirement: Discord delivery remains unimplemented -The Discord stub SHALL implement `BoundNotificationService`, accept `ΩNotificationMessageΩ` directly, and SHALL NOT implement webhook delivery as part of this capability. +### Requirement: Discord webhook delivery +The Discord notification service SHALL remain a `BoundNotificationService` that is fully configured at construction from a non-null structurally valid canonical Discord incoming-webhook `ΩRestEndpointΩ` and a non-null positive `Duration`. It SHALL make no construction-time network request, follow no HTTP redirects, and SHALL NOT expose a raw `URL` or raw `String` constructor. -#### Scenario: Valid Discord delivery remains unimplemented -- **WHEN** a valid message is pushed through the configured Discord service -- **THEN** the service reports that Discord delivery is not implemented without performing webhook delivery +The endpoint SHALL be an absolute HTTPS URI with a case-insensitive `discord.com` host, no user-info, fragment, query, or non-HTTPS port, and either an unversioned `/api/webhooks/{id}/{token}` path or a versioned `/api/v{version}/webhooks/{id}/{token}` path using a non-discontinued Discord API version from 6 through 10, with non-blank ID and token segments. Constructor validation diagnostics SHALL NOT disclose the endpoint or token. + +Every public `push(...)` entry point SHALL reject a null, empty, blank, or over-2,000-character `ΩNotificationMessageΩ` with `IllegalArgumentException` before JSON serialization, request construction, or transport work. For every valid message, the service SHALL synchronously send exactly one POST to the configured endpoint with `wait=true`, the configured request timeout, JSON UTF-8 content and accept headers, a `DiscordBot (url, version)` User-Agent identifying Nenjim, and a safely serialized JSON object whose `content` preserves the message exactly. The webhook token SHALL be the only authentication mechanism. + +Every 2xx HTTP response SHALL return normally. Every non-2xx response, including redirects and 429 responses, and every communication or request-preparation failure SHALL cause `IOException` without retrying, waiting, or sending another request. Response failures SHALL include the HTTP status and useful response body when available, without disclosing the endpoint or token. When synchronous HTTP delivery is interrupted, the service SHALL restore the thread interrupt flag and throw an `IOException` retaining the `InterruptedException` as its cause. + +#### Scenario: Fully configured construction +- **WHEN** a caller constructs the service with a canonical Discord webhook endpoint and positive timeout +- **THEN** it receives a bound notification service without a network request + +#### Scenario: Invalid endpoint or timeout is rejected +- **WHEN** a caller supplies a null or structurally invalid endpoint, or a null, zero, or negative timeout +- **THEN** construction throws `IllegalArgumentException` without disclosing the endpoint token + +#### Scenario: Invalid notification message is rejected before delivery +- **WHEN** a caller pushes a null, empty, blank, or over-2,000-character message +- **THEN** the service throws `IllegalArgumentException` before serialization or HTTP work + +#### Scenario: Valid message is synchronously confirmed +- **WHEN** a caller pushes a valid notification message and Discord returns a 2xx response +- **THEN** the service sends one synchronous JSON POST with `wait=true` and returns normally + +#### Scenario: Discord rejects delivery +- **WHEN** Discord returns a non-2xx response, including 429 or a redirect +- **THEN** the service throws `IOException` containing the status and does not retry or follow the redirect + +#### Scenario: Delivery communication fails +- **WHEN** preparing or sending the webhook request fails +- **THEN** the service throws `IOException` without exposing the webhook endpoint or token + +#### Scenario: Delivery is interrupted +- **WHEN** synchronous webhook delivery is interrupted +- **THEN** the service restores the thread interrupt status and throws `IOException` with the interruption as its cause + +#### Scenario: Webhook secret remains protected +- **WHEN** validation or delivery reports an error +- **THEN** no log or exception message includes the complete webhook endpoint or token ### Requirement: Obsolete notification API is removed The notification framework SHALL reside exclusively under `com.r35157.service.notification`. It SHALL NOT retain compatibility aliases under `com.r35157.libs.notification`, notifier-named public interfaces or implementations, the `NotificationMessage` marker, or transport-specific message wrapper types. diff --git a/src/main/tjava/com/r35157/service/notification/impl/discord/DiscordNotificationService.tjava b/src/main/tjava/com/r35157/service/notification/impl/discord/DiscordNotificationService.tjava index d288677..decc42a 100644 --- a/src/main/tjava/com/r35157/service/notification/impl/discord/DiscordNotificationService.tjava +++ b/src/main/tjava/com/r35157/service/notification/impl/discord/DiscordNotificationService.tjava @@ -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 + *
The supplied user agent must use Discord's canonical format:
+ * {@code DiscordBot ( 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 (