diff --git a/pom.xml b/pom.xml index cd876e1..9863220 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ com.n1netails n1netails-slack-client - 0.2.0 + 0.3.0 jar n1netails-slack-client @@ -16,8 +16,27 @@ scm:git:git://github.com/n1netails/n1netails-slack-client.git scm:git:ssh://git@github.com:n1netails/n1netails-slack-client.git + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.15.0 + + 17 + + + org.projectlombok + lombok + 1.18.42 + + + + + + - + MIT License https://opensource.org/licenses/MIT @@ -38,8 +57,8 @@ UTF-8 - 1.8 - 1.8 + 17 + 17 @@ -53,7 +72,7 @@ com.slack.api slack-api-client - 1.45.4 + 1.47.0 diff --git a/readme.md b/readme.md index ded405a..9d750dd 100644 --- a/readme.md +++ b/readme.md @@ -37,48 +37,71 @@ Install the slack client by adding the following dependency: com.n1netails n1netails-slack-client - 0.2.0 + 0.3.0 ``` Gradle (Groovy) ```groovy -implementation 'com.n1netails:n1netails-slack-client:0.2.0' +implementation 'com.n1netails:n1netails-slack-client:0.3.0' ``` ## Usage -Here's how to use the client to send a message: +Here's how to use the client to send a message, pics or gifs: ```java import com.n1netails.n1netails.slack.api.SlackClient; -import com.n1netails.n1netails.slack.internal.SlackClientImpl; import com.n1netails.n1netails.slack.model.SlackMessage; -import com.n1netails.n1netails.slack.service.BotService; +import com.n1netails.n1netails.slack.model.actions_element.ButtonElement; +import com.n1netails.n1netails.slack.model.block.TextBlock; +import com.n1netails.n1netails.slack.model.block.ImageBlock; +import com.n1netails.n1netails.slack.model.block.GifBlock; +import com.n1netails.n1netails.slack.model.block.ActionsBlock; +import com.n1netails.n1netails.slack.model.element.ButtonElement; +import com.n1netails.n1netails.slack.exception.SlackClientException; public class Example { public static void main(String[] args) { // Your bot token String token = "xoxb-your-bot-token"; - // The channel you want to send the message to (e.g., "#general") - String channel = "#prototype"; // or "#channel-name" - - // Create the bot service - BotService botService = new BotService(token); - - // Create the Slack client - SlackClient slackClient = new SlackClientImpl(botService); + // Channel to send the message to + String channel = "#prototype"; - // Create the message - SlackMessage message = new SlackMessage(); - message.setChannel(channel); - message.setText("Hello from the N1ne Tails Slack Client!"); + // Build the Slack client + SlackClient slackClient = SlackClient.builder() + .token(token) + .build(); + + // Build an ActionsBlock + ActionsBlock actionsBlock = ActionsBlock.builder() + .addElement(ButtonElement.link("Visit Website", "https://example.com")) + .addElement(ButtonElement.builder().text("Click me").actionId("YOUR ACTION ID").build()) //Elements support 2 types of creation + .build(); + + // Build the Slack message + // Build the Slack message + SlackMessage message = SlackMessage.builder() + .channel(channel) + .text("New content 🚀") // fallback message for notifications + .addBlock(TextBlock.builder().text("New content 🚀").build()) + .addBlock(ImageBlock.builder() + .imageUrl("https://n1netails.com/img/n1netails_icon_transparent.png") + .altText("N1netails token") + .build()) + .addBlock(ImageBlock.of("https://n1netails.com/img/quickstart/n1netails-letter.jpg", "N1netails letter")) //Blocks support 2 types of creation + .addBlock(GifBlock.builder() + .gifUrl("https://media1.giphy.com/media/v1.Y2lkPTc5MGI3NjExaDRhOWtpMnVsM2NiMzJ4aXpoOXpuamZzcHpudG4zbzIzenVlaHN0eSZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/xsE65jaPsUKUo/giphy.gif") + .altText("Fox GIF") + .build()) + .addBlock(actionsBlock) // add the actions block with buttons + .build(); try { // Send the message slackClient.sendMessage(message); System.out.println("Message sent successfully!"); - } catch (Exception e) { + } catch (SlackClientException e) { System.err.println("Error sending message: " + e.getMessage()); } } @@ -90,33 +113,38 @@ You can also send more complex messages using [Slack's Block Kit](https://api.sl ```java import com.n1netails.n1netails.slack.api.SlackClient; -import com.n1netails.n1netails.slack.internal.SlackClientImpl; import com.n1netails.n1netails.slack.model.SlackMessage; -import com.n1netails.n1netails.slack.service.BotService; import com.slack.api.model.block.Blocks; import com.slack.api.model.block.composition.BlockCompositions; +import com.n1netails.n1netails.slack.exception.SlackClientException; -import java.util.Arrays; +import java.util.List; public class AdvancedExample { public static void main(String[] args) { String token = "xoxb-your-bot-token"; String channel = "#prototype"; - BotService botService = new BotService(token); - SlackClient slackClient = new SlackClientImpl(botService); - - SlackMessage message = new SlackMessage(); - message.setChannel(channel); - message.setText("This is a fallback message for notifications."); - message.setBlocks(Arrays.asList( - Blocks.section(section -> section.text(BlockCompositions.markdownText("*This is a message with blocks.*"))) - )); + // Build the Slack client + SlackClient slackClient = SlackClient.builder() + .token(token) + .build(); + + // Build a Slack message with custom Block Kit blocks + SlackMessage message = SlackMessage.builder() + .channel(channel) + .text("This is a fallback message for notifications.") // fallback + .addRawBlock( + Blocks.section(section -> + section.text(BlockCompositions.markdownText("*This is a message with custom blocks.*")) + ) + ) + .build(); try { slackClient.sendMessage(message); System.out.println("Advanced message sent successfully!"); - } catch (Exception e) { + } catch (SlackClientException e) { System.err.println("Error sending message: " + e.getMessage()); } } @@ -157,4 +185,10 @@ For community users, open an issue on GitHub or Join our Discord ## Contributing -Please use the following guidelines for contributions [CONTRIBUTING](./contributing.md) \ No newline at end of file +Please use the following guidelines for contributions [CONTRIBUTING](./contributing.md) + +## N1netails Slack Client Contributors + +Thanks to all the amazing people who contributed to N1netails Slack Client! 💙 + +[![Contributors](https://contrib.rocks/image?repo=n1netails/n1netails-slack-client)](https://github.com/n1netails/n1netails-slack-client/graphs/contributors) \ No newline at end of file diff --git a/src/main/java/com/n1netails/n1netails/slack/api/BotService.java b/src/main/java/com/n1netails/n1netails/slack/api/BotService.java new file mode 100644 index 0000000..4948c7b --- /dev/null +++ b/src/main/java/com/n1netails/n1netails/slack/api/BotService.java @@ -0,0 +1,120 @@ +package com.n1netails.n1netails.slack.api; + +import com.n1netails.n1netails.slack.api.builder.SlackBlockBuilder; +import com.n1netails.n1netails.slack.exception.SlackErrorMapper; +import com.n1netails.n1netails.slack.exception.SlackClientException; +import com.n1netails.n1netails.slack.exception.SlackTransportException; +import com.n1netails.n1netails.slack.exception.SlackValidationException; +import com.n1netails.n1netails.slack.model.SlackMessage; +import com.n1netails.n1netails.slack.validation.SlackBlockValidator; +import com.slack.api.Slack; +import com.slack.api.methods.MethodsClient; +import com.slack.api.methods.SlackApiException; +import com.slack.api.methods.request.chat.ChatPostMessageRequest; +import com.slack.api.methods.response.chat.ChatPostMessageResponse; +import com.slack.api.model.block.LayoutBlock; + +import java.io.IOException; +import java.util.List; + +/** + * Service responsible for sending messages to Slack via the Slack SDK. + *

+ * Encapsulates the Slack {@link MethodsClient}, performs message validation, + * and builds message blocks using {@link SlackBlockBuilder}. + * Handles transport and validation exceptions internally. + *

+ *

+ * Example usage: + *

{@code
+ * BotService botService = new BotService("xoxb-your-token");
+ * SlackMessage message = new SlackMessage("general", "Hello, Slack!");
+ * botService.send(message);
+ * }
+ * + *

All messages are validated before sending. Text or blocks must be present + * in the {@link SlackMessage}, otherwise a {@link SlackValidationException} is thrown.

+ * + *

Exceptions thrown:

+ *
    + *
  • {@link SlackTransportException} – for network/SDK errors
  • + *
+ * + * @author Shahid Foy and Artur Slimak + */ +final public class BotService { + private final MethodsClient methods; + private final SlackBlockValidator blockValidator; + private final SlackBlockBuilder blockBuilder; + + /** + * Constructs a new {@link BotService} with the given Slack bot token. + * + * @param token Slack bot token for authentication; must not be null or blank + */ + public BotService(String token) { + this.methods = Slack.getInstance().methods(token); + this.blockValidator = new SlackBlockValidator(); + this.blockBuilder = new SlackBlockBuilder(); + + } + + + /** + * Sends a {@link SlackMessage} to Slack. + *

+ * The message is validated for basic requirements (text or blocks present), + * and all blocks are validated via {@link SlackBlockValidator}. + *

+ * + * @param slackMessage the message to send; must not be null + * @throws SlackClientException if validation fails or sending fails due to network/Slack API errors + */ + public void send(SlackMessage slackMessage) throws SlackClientException { + validateBasic(slackMessage); + + blockValidator.validateMessageBlocks(slackMessage); + + try { + List blocks = blockBuilder.build(slackMessage); + + ChatPostMessageRequest request = + ChatPostMessageRequest.builder() + .channel(slackMessage.getChannel()) + .text(slackMessage.getText()) + .blocks(blocks) + .build(); + + ChatPostMessageResponse response = methods.chatPostMessage(request); + if (!response.isOk()) { + throw SlackErrorMapper.map(response); + } + } catch (IOException e) { + throw new SlackTransportException("Network error while calling Slack API", e); + } catch (SlackApiException e) { + throw new SlackTransportException("Slack SDK failure for channel: " + slackMessage.getChannel(), e); + } + } + + /** + * Performs basic validation on a {@link SlackMessage}. + *

+ * Ensures that the message is not null and contains either text, blocks, or raw blocks. + *

+ * + * @param message the Slack message to validate + * @throws SlackValidationException if the message is null or contains no text/blocks + */ + private void validateBasic(SlackMessage message) throws SlackValidationException { + if (message == null) { + throw new SlackValidationException("SlackMessage cannot be null"); + } + + if ((message.getText() == null || message.getText().isBlank()) && + (message.getBlocks() == null || message.getBlocks().isEmpty()) && + (message.getRawBlocks() == null || message.getRawBlocks().isEmpty())) { + + throw new SlackValidationException("Message must contain text or blocks"); + } + } +} diff --git a/src/main/java/com/n1netails/n1netails/slack/api/SlackClient.java b/src/main/java/com/n1netails/n1netails/slack/api/SlackClient.java index 527e8da..3c18228 100644 --- a/src/main/java/com/n1netails/n1netails/slack/api/SlackClient.java +++ b/src/main/java/com/n1netails/n1netails/slack/api/SlackClient.java @@ -1,17 +1,70 @@ package com.n1netails.n1netails.slack.api; +import com.n1netails.n1netails.slack.api.SlackClientImpl; import com.n1netails.n1netails.slack.exception.SlackClientException; import com.n1netails.n1netails.slack.model.SlackMessage; /** - * Slick Client - * @author shahid foy + * Slack Client interface for sending messages to Slack. + *

+ * This is a sealed interface, allowing only {@link SlackClientImpl} to implement it. + * Use the {@link #builder()} method to create instances. + *

+ *

+ * Example usage: + *

{@code
+ * SlackClient client = SlackClient.builder()
+ *                                 .token("xoxb-your-token")
+ *                                 .build();
+ * client.sendMessage(new SlackMessage("Hello, Slack!"));
+ * }
+ * + *

All implementations are expected to handle exceptions via {@link SlackClientException}.

+ * + * @author Shahid Foy and Artur Slimak */ -public interface SlackClient { +public sealed interface SlackClient permits SlackClientImpl { /** - * Send slack message - * @param slackMessage slack message + * Sends a message to Slack. + * + * @param slackMessage the message to send, must not be null + * @throws SlackClientException if sending fails (network, authentication, or other Slack API issues) */ void sendMessage(SlackMessage slackMessage) throws SlackClientException; + + /** + * Returns a new {@link Builder} for constructing a {@link SlackClient}. + * + * @return a new builder instance + */ + static Builder builder() { + return new SlackClientImpl.Builder(); + } + + /** + * Builder interface for {@link SlackClient}. + *

+ * This is a sealed interface, allowing only {@link SlackClientImpl.Builder} to implement it. + * Provides a fluent API for setting configuration parameters. + *

+ */ + sealed interface Builder permits SlackClientImpl.Builder { + + /** + * Sets the Slack authentication token. + * + * @param token the OAuth token for Slack API access + * @return this builder instance + */ + Builder token(String token); + + /** + * Builds the {@link SlackClient} instance using the provided configuration. + * + * @return a configured {@link SlackClient} + * @throws SlackClientException if required parameters are missing or invalid + */ + SlackClient build() throws SlackClientException; + } } diff --git a/src/main/java/com/n1netails/n1netails/slack/api/SlackClientImpl.java b/src/main/java/com/n1netails/n1netails/slack/api/SlackClientImpl.java new file mode 100644 index 0000000..fbd4b37 --- /dev/null +++ b/src/main/java/com/n1netails/n1netails/slack/api/SlackClientImpl.java @@ -0,0 +1,60 @@ +package com.n1netails.n1netails.slack.api; + +import com.n1netails.n1netails.slack.exception.SlackClientException; +import com.n1netails.n1netails.slack.model.SlackMessage; + +/** + * Concrete implementation of {@link SlackClient}. + *

+ * Uses {@link BotService} internally to send messages to Slack. + * Created via {@link SlackClientImpl.Builder}. + *

+ * + * Example: + *
{@code
+ * SlackClient client = SlackClientImpl.builder()
+ *                                     .token("xoxb-your-token")
+ *                                     .build();
+ * client.sendMessage(new SlackMessage("Hello!"));
+ * }
+ * + * @author Artur Slimak + */ +final class SlackClientImpl implements SlackClient { + + private final BotService botService; + + private SlackClientImpl(Builder builder) { + this.botService = new BotService(builder.token); + } + + @Override + public void sendMessage(SlackMessage slackMessage) throws SlackClientException { + botService.send(slackMessage); + } + + /** + * Builder for {@link SlackClientImpl}. + *

+ * Implements the {@link SlackClient.Builder} interface. + * Used to configure and construct an instance of {@link SlackClientImpl}. + *

+ */ + public static final class Builder implements SlackClient.Builder { + private String token; + + @Override + public SlackClient.Builder token(String token) { + this.token = token; + return this; + } + + @Override + public SlackClient build() throws SlackClientException { + if (this.token == null || this.token.isBlank()) + throw new SlackClientException("Token must be provided"); + + return new SlackClientImpl(this); + } + } +} diff --git a/src/main/java/com/n1netails/n1netails/slack/api/builder/SlackBlockBuilder.java b/src/main/java/com/n1netails/n1netails/slack/api/builder/SlackBlockBuilder.java new file mode 100644 index 0000000..f5bc6f0 --- /dev/null +++ b/src/main/java/com/n1netails/n1netails/slack/api/builder/SlackBlockBuilder.java @@ -0,0 +1,55 @@ +package com.n1netails.n1netails.slack.api.builder; + +import com.n1netails.n1netails.slack.model.SlackBlock; +import com.n1netails.n1netails.slack.model.SlackMessage; +import com.slack.api.model.block.LayoutBlock; + +import java.util.List; + +/** + * Builder class responsible for converting a {@link SlackMessage} into a list of Slack {@link LayoutBlock}s. + *

+ * The builder first checks if the message contains raw {@link LayoutBlock}s. If so, it returns them directly. + * Otherwise, it converts the high-level {@link SlackBlock} objects into {@link LayoutBlock}s using their {@code toLayoutBlock()} method. + *

+ *

+ * Example usage: + *

{@code
+ * SlackMessage message = new SlackMessage("general", "Hello!");
+ * List blocks = new SlackBlockBuilder().build(message);
+ * }
+ * + * @author Artur Slimak + */ +public class SlackBlockBuilder { + + /** + * Builds a list of {@link LayoutBlock}s from a {@link SlackMessage}. + *

+ * Priority order: + *

+ *
    + *
  1. If {@code rawBlocks} are present in the message, return them as-is.
  2. + *
  3. If {@code blocks} are present, convert each {@link SlackBlock} to {@link LayoutBlock}.
  4. + *
  5. If neither is present, returns {@code null}.
  6. + *
+ * + * @param message the Slack message to convert; must not be null + * @return list of {@link LayoutBlock}s or {@code null} if none are available + */ + public List build(SlackMessage message) { + + if (message.getRawBlocks() != null && !message.getRawBlocks().isEmpty()) { + return message.getRawBlocks(); + } + + if (message.getBlocks() == null || message.getBlocks().isEmpty()) { + return null; + } + + return message.getBlocks() + .stream() + .map(SlackBlock::toLayoutBlock) + .toList(); + } +} diff --git a/src/main/java/com/n1netails/n1netails/slack/exception/SlackClientException.java b/src/main/java/com/n1netails/n1netails/slack/exception/SlackClientException.java index 56a103b..3906188 100644 --- a/src/main/java/com/n1netails/n1netails/slack/exception/SlackClientException.java +++ b/src/main/java/com/n1netails/n1netails/slack/exception/SlackClientException.java @@ -4,7 +4,7 @@ * Slack Client Exception * @author shahid foy */ -public class SlackClientException extends Exception { +public class SlackClientException extends RuntimeException { /** * Slack Client Exception Constructor diff --git a/src/main/java/com/n1netails/n1netails/slack/exception/SlackErrorMapper.java b/src/main/java/com/n1netails/n1netails/slack/exception/SlackErrorMapper.java new file mode 100644 index 0000000..48170d4 --- /dev/null +++ b/src/main/java/com/n1netails/n1netails/slack/exception/SlackErrorMapper.java @@ -0,0 +1,119 @@ +package com.n1netails.n1netails.slack.exception; + +import com.slack.api.methods.response.chat.ChatPostMessageResponse; + +import java.util.ArrayList; +import java.util.List; + +/** + * Utility class for mapping Slack API {@link ChatPostMessageResponse} errors + * into human-readable {@link SlackValidationException} messages. + *

+ * This class parses raw Slack API error messages, including JSON pointers, + * and formats them for easier debugging and display in your application. + *

+ * + *

Example usage:

+ *
{@code
+ * ChatPostMessageResponse response = methodsClient.chatPostMessage(request);
+ * if (!response.isOk()) {
+ *     throw SlackErrorMapper.map(response);
+ * }
+ * }
+ * + *

Implements static, stateless methods and is thread-safe.

+ * + * @author Artur Slimak + */ +public class SlackErrorMapper { + + /** + * Maps a {@link ChatPostMessageResponse} from the Slack API into a {@link SlackValidationException}. + * + * @param response the Slack API response + * @return a {@link SlackValidationException} containing formatted error messages + */ + public static SlackValidationException map(ChatPostMessageResponse response) { + if (response.getErrors() == null || response.getErrors().isEmpty()) { + return new SlackValidationException(response.getError()); + } + + List formattedErrors = new ArrayList<>(); + + for (String error : response.getErrors()) { + formattedErrors.add(format(error)); + } + + return new SlackValidationException( + "Slack API validation failed:\n - " + String.join("\n - ", formattedErrors) + ); + } + + /** + * Formats a raw Slack API error message, including parsing JSON pointers if present. + * + * @param rawError the raw error string from Slack API + * @return a human-readable formatted error message + */ + private static String format(String rawError) { + try { + String message = rawError; + int pointerStart = rawError.indexOf("[json-pointer:"); + + if (pointerStart > -1) { + message = rawError.substring(0, pointerStart).trim(); + + String pointer = rawError.substring(pointerStart) + .replace("[json-pointer:", "") + .replace("]", ""); + + return mapPointer(pointer, message); + } + + return message; + + } catch (Exception e) { + return rawError; + } + } + + + /** + * Maps a JSON pointer from Slack API errors into a readable path format. + * + * @param pointer the JSON pointer string (e.g., "/blocks/0/elements/1") + * @param message the original error message + * @return a human-readable error with the pointer path + */ + private static String mapPointer(String pointer, String message) { + String[] parts = pointer.split("/"); + + StringBuilder path = new StringBuilder(); + + for (int i = 1; i < parts.length; i++) { + String part = parts[i]; + + if (isNumeric(part)) { + path.append("[").append(part).append("]"); + } else { + if (!path.isEmpty()) { + path.append("."); + } + path.append(part); + } + } + + return path + ": " + message; + } + + + /** + * Checks if a string is numeric. + * + * @param str the string to check + * @return {@code true} if the string consists only of digits, {@code false} otherwise + */ + private static boolean isNumeric(String str) { + return str.chars().allMatch(Character::isDigit); + } +} diff --git a/src/main/java/com/n1netails/n1netails/slack/exception/SlackTransportException.java b/src/main/java/com/n1netails/n1netails/slack/exception/SlackTransportException.java new file mode 100644 index 0000000..642f6e2 --- /dev/null +++ b/src/main/java/com/n1netails/n1netails/slack/exception/SlackTransportException.java @@ -0,0 +1,11 @@ +package com.n1netails.n1netails.slack.exception; + +public class SlackTransportException extends SlackClientException { + public SlackTransportException(String message) { + super(message); + } + + public SlackTransportException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/com/n1netails/n1netails/slack/exception/SlackValidationException.java b/src/main/java/com/n1netails/n1netails/slack/exception/SlackValidationException.java new file mode 100644 index 0000000..671c5a8 --- /dev/null +++ b/src/main/java/com/n1netails/n1netails/slack/exception/SlackValidationException.java @@ -0,0 +1,7 @@ +package com.n1netails.n1netails.slack.exception; + +public class SlackValidationException extends SlackClientException { + public SlackValidationException(String message) { + super(message); + } +} diff --git a/src/main/java/com/n1netails/n1netails/slack/internal/SlackClientImpl.java b/src/main/java/com/n1netails/n1netails/slack/internal/SlackClientImpl.java deleted file mode 100644 index 3550acb..0000000 --- a/src/main/java/com/n1netails/n1netails/slack/internal/SlackClientImpl.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.n1netails.n1netails.slack.internal; - -import com.n1netails.n1netails.slack.api.SlackClient; -import com.n1netails.n1netails.slack.exception.SlackClientException; -import com.n1netails.n1netails.slack.model.SlackMessage; -import com.n1netails.n1netails.slack.service.BotService; - -/** - * Slack Client Implementation - * @author shahid foy - */ -public class SlackClientImpl implements SlackClient { - - private final BotService botService; - - public SlackClientImpl(BotService botService) { this.botService = botService; } - - /** - * Send slack notification - * @param slackMessage slack message - */ - @Override - public void sendMessage(SlackMessage slackMessage) throws SlackClientException { - try { - botService.send(slackMessage); - } catch (Exception e) { - throw new SlackClientException("Failed to send Slack message", e); - } - } -} diff --git a/src/main/java/com/n1netails/n1netails/slack/model/SlackBlock.java b/src/main/java/com/n1netails/n1netails/slack/model/SlackBlock.java new file mode 100644 index 0000000..80217e4 --- /dev/null +++ b/src/main/java/com/n1netails/n1netails/slack/model/SlackBlock.java @@ -0,0 +1,37 @@ +package com.n1netails.n1netails.slack.model; + +import com.n1netails.n1netails.slack.model.block.ActionsBlock; +import com.n1netails.n1netails.slack.model.block.GifBlock; +import com.n1netails.n1netails.slack.model.block.ImageBlock; +import com.n1netails.n1netails.slack.model.block.TextBlock; +import com.slack.api.model.block.LayoutBlock; + +import java.util.List; + +/** + * Represents a Slack block element that can be converted into a Slack API {@link LayoutBlock}. + *

+ * All concrete Slack blocks (e.g., {@link TextBlock}, {@link ImageBlock}, {@link GifBlock}, {@link ActionsBlock}) + * should implement this interface. + *

+ *

+ * Example usage: + *

{@code
+ * SlackBlock block = TextBlock.of("Hello Slack!");
+ * LayoutBlock layoutBlock = block.toLayoutBlock();
+ * }
+ * + *

Extends {@link SlackNode}, allowing blocks to participate in SlackNode hierarchies for compositional validation.

+ * + *

Implementations should be immutable wherever possible.

+ * + * @author Artur Slimak + */ +public interface SlackBlock extends SlackNode { + /** + * Converts this Slack block into a Slack API {@link LayoutBlock}. + * + * @return the Slack API representation of this block + */ + LayoutBlock toLayoutBlock(); +} diff --git a/src/main/java/com/n1netails/n1netails/slack/model/SlackElement.java b/src/main/java/com/n1netails/n1netails/slack/model/SlackElement.java new file mode 100644 index 0000000..636c36c --- /dev/null +++ b/src/main/java/com/n1netails/n1netails/slack/model/SlackElement.java @@ -0,0 +1,32 @@ +package com.n1netails.n1netails.slack.model; + +import com.n1netails.n1netails.slack.model.actions_element.ButtonElement; +import com.slack.api.model.block.element.BlockElement; + +/** + * Represents a Slack interactive element that can be converted into a Slack API {@link BlockElement}. + *

+ * Examples of Slack elements include buttons, select menus, and other interactive UI components. + * All concrete elements (e.g., {@link ButtonElement}) should implement this interface. + *

+ * + *

Example usage:

+ *
{@code
+ * SlackElement button = ButtonElement.link("Open URL", "https://example.com");
+ * BlockElement blockElement = button.toBlockElement();
+ * }
+ * + *

Extends {@link SlackNode}, allowing elements to participate in SlackNode hierarchies for compositional validation.

+ * + *

Implementations should be immutable wherever possible.

+ * + * @author Artur Slimak + */ +public interface SlackElement extends SlackNode { + /** + * Converts this Slack element into a Slack API {@link BlockElement}. + * + * @return the Slack API representation of this element + */ + BlockElement toBlockElement(); +} diff --git a/src/main/java/com/n1netails/n1netails/slack/model/SlackMessage.java b/src/main/java/com/n1netails/n1netails/slack/model/SlackMessage.java index 007f8f7..c751556 100644 --- a/src/main/java/com/n1netails/n1netails/slack/model/SlackMessage.java +++ b/src/main/java/com/n1netails/n1netails/slack/model/SlackMessage.java @@ -1,25 +1,164 @@ package com.n1netails.n1netails.slack.model; +import com.n1netails.n1netails.slack.exception.SlackValidationException; import com.slack.api.model.block.LayoutBlock; import lombok.Getter; -import lombok.Setter; +import java.util.ArrayList; import java.util.List; /** - * Slack Message - * @author shahid foy + * Represents a Slack message that can be sent to a channel. + *

+ * A Slack message can contain plain text, structured {@link SlackBlock}s, or raw Slack API blocks + * (Block Kit Documentation). + * Messages are immutable once built and should be constructed via the {@link Builder}. + *

+ * + * Example usage: + *
{@code
+ * SlackMessage message = SlackMessage.builder()
+ *     .channel("general")
+ *     .text("Hello, Slack!")
+ *     .build();
+ * }
+ * + *

Cannot mix {@link SlackBlock} and raw Block Kit blocks in the same message.

+ * + *

All instances are immutable and thread-safe after creation.

+ * + * @author Shahid Foy and Artur Slimak */ @Getter -@Setter public class SlackMessage { private String channel; private String text; - private List blocks; + private List blocks; + private List rawBlocks; + + private SlackMessage(Builder builder) { + this.channel = builder.channel; + this.text = builder.text; + this.blocks = List.copyOf(builder.blocks); + this.rawBlocks = List.copyOf(builder.rawBlocks); + } + + + /** + * Returns a new {@link Builder} for constructing a {@link SlackMessage}. + * + * @return a new builder instance + */ + public static Builder builder() { + return new Builder(); + } /** - * Slack Message Constructor + * Builder class for creating {@link SlackMessage} instances. + *

+ * Allows setting the target channel, text, and adding blocks (either {@link SlackBlock} or raw {@link LayoutBlock}). + * Enforces validation rules to prevent invalid combinations. + *

*/ - public SlackMessage() {} + public static class Builder { + private String channel; + private String text; + private final List blocks = new ArrayList<>(); + private final List rawBlocks = new ArrayList<>(); + + /** + * Sets the target Slack channel for this message. + * + * @param channel the Slack channel name or ID + * @return this builder + */ + public Builder channel(String channel) { + this.channel = channel; + return this; + } + + + /** + * Sets the plain text content of this Slack message. + *

+ * If one or more blocks are present in the message, this text serves as a fallback + * for notifications or clients that cannot render blocks. + *

+ * + * @param text the message text + * @return this builder + */ + public Builder text(String text) { + this.text = text; + return this; + } + + + /** + * Adds a structured {@link SlackBlock} to this message. + * + * @param block the Slack block to add + * @return this builder + * @throws SlackValidationException if rawBlocks are already present + */ + public Builder addBlock(SlackBlock block) throws SlackValidationException { + if (!rawBlocks.isEmpty()) { + throw new SlackValidationException( + "Cannot add SlackBlock when rawBlocks are already present" + ); + } + this.blocks.add(block); + return this; + } + + /** + * Adds a raw {@link LayoutBlock} to this message. + * + * @param block the raw Slack API layout block (Block Kit Documentation) + * @return this builder + * @throws SlackValidationException if SlackBlocks are already present + */ + public Builder addRawBlock(LayoutBlock block) throws SlackValidationException { + if (!blocks.isEmpty()) { + throw new SlackValidationException( + "Cannot add rawBlock when SlackBlocks are already present" + ); + } + this.rawBlocks.add(block); + return this; + } + + /** + * Builds an immutable {@link SlackMessage} instance after performing validation. + * + * @return a new {@link SlackMessage} + * @throws SlackValidationException if channel is missing, content is missing, or block types are mixed + */ + public SlackMessage build() throws SlackValidationException { + if (channel == null || channel.isBlank()) { + throw new SlackValidationException("channel is required"); + } + + boolean hasContent = (text != null && !text.isBlank()) + || !blocks.isEmpty() + || !rawBlocks.isEmpty(); + + + if (!hasContent) { + throw new SlackValidationException( + "Either text, blocks, or rawBlocks must be provided" + ); + } + + if (!blocks.isEmpty() && !rawBlocks.isEmpty()) { + throw new SlackValidationException( + "Cannot mix SlackBlock and rawBlocks in the same message" + ); + } + + return new SlackMessage(this); + } + + } } diff --git a/src/main/java/com/n1netails/n1netails/slack/model/SlackNode.java b/src/main/java/com/n1netails/n1netails/slack/model/SlackNode.java new file mode 100644 index 0000000..2079948 --- /dev/null +++ b/src/main/java/com/n1netails/n1netails/slack/model/SlackNode.java @@ -0,0 +1,38 @@ +package com.n1netails.n1netails.slack.model; + +import com.n1netails.n1netails.slack.model.actions_element.ButtonElement; +import com.n1netails.n1netails.slack.model.block.GifBlock; +import com.n1netails.n1netails.slack.model.block.ImageBlock; +import com.n1netails.n1netails.slack.model.block.TextBlock; + +import java.util.List; + +/** + * Represents a node in a Slack message composition hierarchy. + *

+ * All Slack blocks and elements implement this interface to participate + * in a tree-like structure for compositional validation and traversal. + *

+ *

+ * Example usage: + *

{@code
+ * SlackNode block = TextBlock.of("Hello Slack!");
+ * List children = block.getChildren(); // returns an empty list
+ * }
+ * + *

Implementations should be immutable wherever possible.

+ * + * @author Artur SLimak + */ +public interface SlackNode { + /** + * Returns the children of this node in the Slack composition tree. + *

+ * For leaf nodes (e.g., {@link TextBlock}, {@link GifBlock}, {@link ImageBlock}, {@link ButtonElement}), + * this method returns an empty list. + *

+ * + * @return a list of child {@link SlackNode} instances + */ + List getChildren(); +} diff --git a/src/main/java/com/n1netails/n1netails/slack/model/actions_element/ButtonElement.java b/src/main/java/com/n1netails/n1netails/slack/model/actions_element/ButtonElement.java new file mode 100644 index 0000000..abda41f --- /dev/null +++ b/src/main/java/com/n1netails/n1netails/slack/model/actions_element/ButtonElement.java @@ -0,0 +1,92 @@ +package com.n1netails.n1netails.slack.model.actions_element; + +import com.n1netails.n1netails.slack.model.SlackElement; +import com.n1netails.n1netails.slack.model.SlackNode; +import com.slack.api.model.block.element.BlockElement; +import lombok.Getter; + +import java.util.List; + +/** + * Represents a Slack button element. + *

+ * A button can either trigger an action (actionId) or open a link (url). + * Use the static factory methods {@link #link(String, String)} or {@link #action(String, String)} + * or the {@link Builder} for custom construction. + *

+ *

+ * Example usage: + *

{@code
+ * ButtonElement linkButton = ButtonElement.link("Open Website", "https://example.com");
+ * ButtonElement actionButton = ButtonElement.action("Click Me", "action_123");
+ * }
+ * + *

Converts to Slack API block element via {@link #toBlockElement()}.

+ * + * @author Artur Slimak + */ +@Getter +public class ButtonElement implements SlackElement { + private final String text; + private final String actionId; + private final String url; + + private ButtonElement(String text, String actionId, String url) { + this.text = text; + this.actionId = actionId; + this.url = url; + } + + public static ButtonElement link(String text, String url) { + return new ButtonElement(text, null, url); + } + + public static ButtonElement action(String text, String actionId) { + return new ButtonElement(text, actionId, null); + } + + @Override + public BlockElement toBlockElement() { + return com.slack.api.model.block.element.ButtonElement.builder() + .text(com.slack.api.model.block.composition.PlainTextObject.builder() + .text(text) + .build()) + .actionId(actionId) + .url(url) + .build(); + } + + public static Builder builder() { + return new Builder(); + } + + @Override + public List getChildren() { + return List.of(); + } + + public static class Builder { + private String text; + private String actionId; + private String url; + + public Builder text(String text) { + this.text = text; + return this; + } + + public Builder actionId(String actionId) { + this.actionId = actionId; + return this; + } + + public Builder url(String url) { + this.url = url; + return this; + } + + public ButtonElement build() { + return new ButtonElement(text, actionId, url); + } + } +} diff --git a/src/main/java/com/n1netails/n1netails/slack/model/block/ActionsBlock.java b/src/main/java/com/n1netails/n1netails/slack/model/block/ActionsBlock.java new file mode 100644 index 0000000..eb7df98 --- /dev/null +++ b/src/main/java/com/n1netails/n1netails/slack/model/block/ActionsBlock.java @@ -0,0 +1,79 @@ +package com.n1netails.n1netails.slack.model.block; + +import com.n1netails.n1netails.slack.model.SlackBlock; +import com.n1netails.n1netails.slack.model.SlackElement; +import com.n1netails.n1netails.slack.model.SlackNode; +import com.n1netails.n1netails.slack.model.actions_element.ButtonElement; +import com.slack.api.model.block.LayoutBlock; +import lombok.Getter; + +import java.util.ArrayList; +import java.util.List; + +/** + * Represents a Slack Actions Block. + *

+ * An Actions Block contains a set of interactive elements, such as {@link ButtonElement}. + * Use {@link #of(List)} or {@link #builder()} to create instances. + *

+ *

+ * Example usage: + *

{@code
+ * ActionsBlock block = ActionsBlock.builder()
+ *     .addElement(ButtonElement.link("Open", "https://example.com"))
+ *     .addElement(ButtonElement.action("Click", "action_123"))
+ *     .build();
+ * }
+ * + *

Converts to Slack API block via {@link #toLayoutBlock()}.

+ * + *

Children elements can be retrieved using {@link #getChildren()}.

+ * + * @author Artur Slimak + */ +@Getter +public class ActionsBlock implements SlackBlock { + + private final List elements; + + private ActionsBlock(List elements) { + this.elements = List.copyOf(elements); + } + + public static ActionsBlock of(List elements) { + return new ActionsBlock(elements); + } + + @Override + public LayoutBlock toLayoutBlock() { + return com.slack.api.model.block.ActionsBlock.builder() + .elements( + elements.stream() + .map(SlackElement::toBlockElement) + .toList() + ) + .build(); + } + + public static Builder builder() { + return new Builder(); + } + + @Override + public List getChildren() { + return List.copyOf(elements); + } + + public static class Builder { + private final List elements = new ArrayList<>(); + + public Builder addElement(SlackElement element) { + elements.add(element); + return this; + } + + public ActionsBlock build() { + return new ActionsBlock(elements); + } + } +} diff --git a/src/main/java/com/n1netails/n1netails/slack/model/block/GifBlock.java b/src/main/java/com/n1netails/n1netails/slack/model/block/GifBlock.java new file mode 100644 index 0000000..b322ef5 --- /dev/null +++ b/src/main/java/com/n1netails/n1netails/slack/model/block/GifBlock.java @@ -0,0 +1,87 @@ +package com.n1netails.n1netails.slack.model.block; + +import com.n1netails.n1netails.slack.exception.SlackValidationException; +import com.n1netails.n1netails.slack.model.SlackBlock; +import com.n1netails.n1netails.slack.model.SlackNode; +import com.slack.api.model.block.LayoutBlock; +import lombok.Getter; + +import java.util.List; + +/** + * Represents a Slack Image Block containing a GIF. + *

+ * This block displays a GIF in a Slack message. Use {@link #of(String, String)} or the {@link Builder} + * to create instances. The GIF URL must be publicly accessible, and altText provides a description + * for accessibility and fallback display. + *

+ *

+ * Example usage: + *

{@code
+ * GifBlock gif = GifBlock.of(
+ *     "https://media.giphy.com/media/3oEjI6SIIHBdRxXI40/giphy.gif",
+ *     "Funny dancing cat"
+ * );
+ * }
+ * + *

Converts to a Slack API {@link LayoutBlock} via {@link #toLayoutBlock()}.

+ * + *

This block does not contain child nodes, so {@link #getChildren()} returns an empty list.

+ * + *

All instances are immutable once created.

+ * + * @author Artur Slimak + */ +@Getter +public class GifBlock implements SlackBlock { + private final String gifUrl; + private final String altText; + + private GifBlock(String gifUrl, String altText) { + this.gifUrl = gifUrl; + this.altText = altText; + } + + public static GifBlock of(String gifUrl, String altText) { + return new GifBlock(gifUrl, altText); + } + + + @Override + public LayoutBlock toLayoutBlock() { + return + com.slack.api.model.block.ImageBlock.builder() + .altText(altText) + .imageUrl(gifUrl) + .build() + ; + } + + public static Builder builder() { + return new Builder(); + } + + @Override + public List getChildren() { + return List.of(); + } + + public static class Builder { + private String gifUrl; + private String altText; + + public Builder gifUrl(String gifUrl) { + this.gifUrl = gifUrl; + return this; + } + + public Builder altText(String altText) { + this.altText = altText; + return this; + } + + public GifBlock build() { + return new GifBlock(gifUrl, altText); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/n1netails/n1netails/slack/model/block/ImageBlock.java b/src/main/java/com/n1netails/n1netails/slack/model/block/ImageBlock.java new file mode 100644 index 0000000..31afb49 --- /dev/null +++ b/src/main/java/com/n1netails/n1netails/slack/model/block/ImageBlock.java @@ -0,0 +1,85 @@ +package com.n1netails.n1netails.slack.model.block; + +import com.n1netails.n1netails.slack.model.SlackBlock; +import com.n1netails.n1netails.slack.model.SlackNode; +import com.slack.api.model.block.LayoutBlock; +import lombok.Getter; + +import java.util.List; + +/** + * Represents a Slack Image Block. + *

+ * An Image Block displays an image in a Slack message with an alternative text for accessibility. + * Use {@link #of(String, String)} or the {@link Builder} to create instances. + *

+ * + * Example usage: + *
{@code
+ * ImageBlock image = ImageBlock.of(
+ *     "https://example.com/image.png",
+ *     "Descriptive alt text"
+ * );
+ * }
+ * + *

Converts to a Slack API {@link LayoutBlock} via {@link #toLayoutBlock()}.

+ * + *

This block does not contain child nodes, so {@link #getChildren()} returns an empty list.

+ * + *

All instances are immutable once created.

+ * + * @author Artur Slimak + */ +@Getter +public class ImageBlock implements SlackBlock { + private final String imageUrl; + private final String altText; + + private ImageBlock(String imageUrl, String altText) { + this.imageUrl = imageUrl; + this.altText = altText; + } + + public static ImageBlock of(String imageUrl, String altText) { + return new ImageBlock(imageUrl, altText); + } + + + @Override + public LayoutBlock toLayoutBlock() { + return + com.slack.api.model.block.ImageBlock.builder() + .imageUrl(imageUrl) + .altText(altText) + .build() + ; + } + + public static Builder builder() { + return new Builder(); + } + + @Override + public List getChildren() { + return List.of(); + } + + public static class Builder { + private String imageUrl; + private String altText; + + public Builder imageUrl(String imageUrl) { + this.imageUrl = imageUrl; + return this; + } + + public Builder altText(String altText) { + this.altText = altText; + return this; + } + + public ImageBlock build() { + return new ImageBlock(imageUrl, altText); + } + } +} diff --git a/src/main/java/com/n1netails/n1netails/slack/model/block/TextBlock.java b/src/main/java/com/n1netails/n1netails/slack/model/block/TextBlock.java new file mode 100644 index 0000000..4aca9eb --- /dev/null +++ b/src/main/java/com/n1netails/n1netails/slack/model/block/TextBlock.java @@ -0,0 +1,72 @@ +package com.n1netails.n1netails.slack.model.block; + +import com.n1netails.n1netails.slack.model.SlackBlock; +import com.n1netails.n1netails.slack.model.SlackNode; +import com.slack.api.model.block.LayoutBlock; +import com.slack.api.model.block.composition.PlainTextObject; +import lombok.Getter; + +import java.util.List; + +/** + * Represents a Slack Text Block (Section Block) containing plain text. + *

+ * Use {@link #of(String)} or the {@link Builder} to create instances. + * This block displays text in a Slack message and does not support child nodes. + *

+ *

+ * Example usage: + *

{@code
+ * TextBlock block = TextBlock.of("Hello, Slack!");
+ * }
+ * + *

Converts to a Slack API {@link LayoutBlock} via {@link #toLayoutBlock()}.

+ * + *

All instances are immutable once created.

+ * + *

{@link #getChildren()} always returns an empty list.

+ * + * @author Artur Slimak + */ +@Getter +public class TextBlock implements SlackBlock { + private final String text; + + private TextBlock(String text) { + this.text = text; + } + + public TextBlock of(String text) { + return new TextBlock(text); + } + + @Override + public LayoutBlock toLayoutBlock() { + return com.slack.api.model.block.SectionBlock.builder() + .text(new PlainTextObject(text, false)) + .build(); + } + + public static Builder builder() { + return new Builder(); + } + + @Override + public List getChildren() { + return List.of(); + } + + public static class Builder { + private String text; + + public Builder text(String text) { + this.text = text; + return this; + } + + public TextBlock build() { + return new TextBlock(text); + } + } + +} diff --git a/src/main/java/com/n1netails/n1netails/slack/service/BotService.java b/src/main/java/com/n1netails/n1netails/slack/service/BotService.java deleted file mode 100644 index 4d3ae8f..0000000 --- a/src/main/java/com/n1netails/n1netails/slack/service/BotService.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.n1netails.n1netails.slack.service; - -import com.n1netails.n1netails.slack.exception.SlackClientException; -import com.n1netails.n1netails.slack.model.SlackMessage; -import com.slack.api.Slack; -import com.slack.api.methods.MethodsClient; -import com.slack.api.methods.request.chat.ChatPostMessageRequest; - -/** - * Slack Bot Service - * @author shahid foy - */ -public class BotService { - - private final String token; - - /** - * Bot Service Constructor - * @param token slack bot token - */ - public BotService(String token) { - this.token = token; - } - - public void send(SlackMessage slackMessage) throws SlackClientException { - try { - MethodsClient methods = Slack.getInstance().methods(token); - ChatPostMessageRequest.ChatPostMessageRequestBuilder requestBuilder = ChatPostMessageRequest.builder() - .channel(slackMessage.getChannel()) - .text(slackMessage.getText()); - - if (slackMessage.getBlocks() != null && !slackMessage.getBlocks().isEmpty()) { - requestBuilder.blocks(slackMessage.getBlocks()); - } - - methods.chatPostMessage(requestBuilder.build()); - } catch (Exception e) { - throw new SlackClientException("Failed to send Slack message", e); - } - } -} diff --git a/src/main/java/com/n1netails/n1netails/slack/validation/BasicSlackValidators.java b/src/main/java/com/n1netails/n1netails/slack/validation/BasicSlackValidators.java new file mode 100644 index 0000000..8dfa740 --- /dev/null +++ b/src/main/java/com/n1netails/n1netails/slack/validation/BasicSlackValidators.java @@ -0,0 +1,70 @@ +package com.n1netails.n1netails.slack.validation; + +import com.n1netails.n1netails.slack.exception.SlackValidationException; +import com.n1netails.n1netails.slack.model.actions_element.ButtonElement; +import com.n1netails.n1netails.slack.model.block.ActionsBlock; +import com.n1netails.n1netails.slack.model.block.GifBlock; +import com.n1netails.n1netails.slack.model.block.ImageBlock; +import com.n1netails.n1netails.slack.model.block.TextBlock; +import com.n1netails.n1netails.slack.validation.impl.block.ActionsBlockValidator; +import com.n1netails.n1netails.slack.validation.impl.block.GifBlockValidator; +import com.n1netails.n1netails.slack.validation.impl.block.ImageBlockValidator; +import com.n1netails.n1netails.slack.validation.impl.block.TextBlockValidator; +import com.n1netails.n1netails.slack.validation.impl.element.ButtonElementValidator; + +import java.util.HashMap; +import java.util.Map; + +/** + * Registry of basic Slack validators for common blocks and elements. + *

+ * Provides automatic validation of: + *

+ *
    + *
  • {@link ImageBlock}
  • + *
  • {@link GifBlock}
  • + *
  • {@link TextBlock}
  • + *
  • {@link ActionsBlock}
  • + *
  • ...
  • + *
+ *

+ * Can be extended with custom validators for additional Slack nodes. + *

+ * + *

Validation is skipped if the target is {@code null} or no validator exists for its type.

+ * + * @author Artur Slimak + */ +public class BasicSlackValidators { + private final Map, SlackValidator> validators = new HashMap<>(); + + + BasicSlackValidators() { + init(); + } + + private void init() { + validators.put(ImageBlock.class, new ImageBlockValidator()); + validators.put(GifBlock.class, new GifBlockValidator()); + validators.put(TextBlock.class, new TextBlockValidator()); + validators.put(ActionsBlock.class, new ActionsBlockValidator()); + validators.put(ButtonElement.class, new ButtonElementValidator()); + } + + /** + * Validates the given target using the registered validator for its type. + *

+ * If no validator exists for the target type, or the target is {@code null}, validation is skipped. + *

+ * + * @param the type of object to validate + * @param target the object to validate + * @throws SlackValidationException if validation fails + */ + @SuppressWarnings("unchecked") + public void validate(T target) { + if (target == null) return; + SlackValidator validator = (SlackValidator) validators.get(target.getClass()); + if (validator != null) validator.validate(target); + } +} diff --git a/src/main/java/com/n1netails/n1netails/slack/validation/SlackBlockValidator.java b/src/main/java/com/n1netails/n1netails/slack/validation/SlackBlockValidator.java new file mode 100644 index 0000000..38dfc10 --- /dev/null +++ b/src/main/java/com/n1netails/n1netails/slack/validation/SlackBlockValidator.java @@ -0,0 +1,100 @@ +package com.n1netails.n1netails.slack.validation; + +import com.n1netails.n1netails.slack.exception.SlackValidationException; +import com.n1netails.n1netails.slack.model.SlackBlock; +import com.n1netails.n1netails.slack.model.SlackMessage; +import com.n1netails.n1netails.slack.model.SlackNode; + +import java.util.ArrayList; +import java.util.List; + +/** + * Validates the blocks of a {@link SlackMessage}. + *

+ * This validator iterates over all {@link SlackBlock} instances in a message and validates + * each block and its children recursively using {@link BasicSlackValidators}. + *

+ * + *

Validation rules:

+ *
    + *
  • If {@link SlackMessage#rawBlocks} is non-empty, validation is skipped.
  • + *
  • If {@link SlackMessage#blocks} is empty or null, validation is skipped.
  • + *
  • All blocks and child elements are validated using their respective {@link SlackValidator} implementations.
  • + *
+ * + *

Example usage:

+ *
{@code
+ * SlackMessage message = SlackMessage.builder()
+ *     .channel("general")
+ *     .addBlock(TextBlock.of("Hello!"))
+ *     .build();
+ *
+ * SlackBlockValidator validator = new SlackBlockValidator();
+ * validator.validateMessageBlocks(message); // throws SlackValidationException if invalid
+ * }
+ * + *

Throws {@link SlackValidationException} if any block or child node fails validation, + * with detailed error messages including the block path in the message.

+ * + * @author Artur Slimak + */ +public class SlackBlockValidator { + + private final BasicSlackValidators basicSlackValidators; + + public SlackBlockValidator() { + this.basicSlackValidators = new BasicSlackValidators(); + } + + /** + * Validates all {@link SlackBlock} instances in the given Slack message. + *

+ * Validation is recursive: all child nodes of blocks are also validated. + *

+ * + * @param message the Slack message to validate + * @throws SlackValidationException if any block or child node fails validation + */ + public void validateMessageBlocks(SlackMessage message) { + if (message.getRawBlocks() != null && !message.getRawBlocks().isEmpty()) { + return; + } + + if (message.getBlocks() == null || message.getBlocks().isEmpty()) { + return; + } + + List errors = new ArrayList<>(); + + for (int i = 0; i < message.getBlocks().size(); i++) { + SlackBlock block = message.getBlocks().get(i); + validateNode(block, "Block[" + i + "]", errors); + } + + if (!errors.isEmpty()) { + throw new SlackValidationException( + "Slack message validation failed:\n - " + String.join("\n - ", errors) + ); + } + } + + /** + * Recursively validates a {@link SlackNode} and its children, collecting any validation errors. + * + * @param node the node to validate + * @param path the path of the node in the message (for error reporting) + * @param errors the list to collect validation error messages + */ + private void validateNode(SlackNode node, String path, List errors) { + try { + basicSlackValidators.validate(node); + } catch (SlackValidationException e) { + errors.add(path + " (" + node.getClass().getSimpleName() + "): " + e.getMessage()); + } + + List children = node.getChildren(); + for (int i = 0; i < children.size(); i++) { + validateNode(children.get(i), path + ".elements[" + i + "]", errors); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/n1netails/n1netails/slack/validation/SlackValidator.java b/src/main/java/com/n1netails/n1netails/slack/validation/SlackValidator.java new file mode 100644 index 0000000..c204a6b --- /dev/null +++ b/src/main/java/com/n1netails/n1netails/slack/validation/SlackValidator.java @@ -0,0 +1,28 @@ +package com.n1netails.n1netails.slack.validation; + +import com.n1netails.n1netails.slack.exception.SlackValidationException; + +/** + * Generic interface for validating Slack nodes or elements. + *

+ * Implementations provide type-specific validation logic for Slack blocks or elements. + *

+ * + *

Example usage:

+ *
{@code
+ * SlackValidator validator = new TextBlockValidator();
+ * validator.validate(TextBlock.of("Hello"));
+ * }
+ * + * @param the type of object to validate + * @author Artur Slimak + */ +public interface SlackValidator { + /** + * Validates the given target object. + * + * @param target the object to validate + * @throws SlackValidationException if validation fails + */ + void validate(T target) throws SlackValidationException; +} diff --git a/src/main/java/com/n1netails/n1netails/slack/validation/impl/block/ActionsBlockValidator.java b/src/main/java/com/n1netails/n1netails/slack/validation/impl/block/ActionsBlockValidator.java new file mode 100644 index 0000000..35f6763 --- /dev/null +++ b/src/main/java/com/n1netails/n1netails/slack/validation/impl/block/ActionsBlockValidator.java @@ -0,0 +1,35 @@ +package com.n1netails.n1netails.slack.validation.impl.block; + +import com.n1netails.n1netails.slack.exception.SlackValidationException; +import com.n1netails.n1netails.slack.model.SlackElement; +import com.n1netails.n1netails.slack.model.block.ActionsBlock; +import com.n1netails.n1netails.slack.validation.BasicSlackValidators; +import com.n1netails.n1netails.slack.validation.SlackValidator; + +/** + * Validator for {@link ActionsBlock}. + *

+ * Ensures that an {@link ActionsBlock} contains at least one child element. + *

+ * + *

Example usage:

+ *
{@code
+ * ActionsBlock block = ActionsBlock.builder()
+ *     .addElement(ButtonElement.link("Click me", "https://example.com"))
+ *     .build();
+ * new ActionsBlockValidator().validate(block); // passes validation
+ * }
+ * + *

Throws {@link SlackValidationException} if the block is empty.

+ * + * @author Artur Slimak + */ +public class ActionsBlockValidator implements SlackValidator { + @Override + public void validate(ActionsBlock target) throws SlackValidationException { + if (target.getElements() == null || target.getElements().isEmpty()) { + throw new SlackValidationException("ActionsBlock must contain at least one element"); + } + + } +} diff --git a/src/main/java/com/n1netails/n1netails/slack/validation/impl/block/GifBlockValidator.java b/src/main/java/com/n1netails/n1netails/slack/validation/impl/block/GifBlockValidator.java new file mode 100644 index 0000000..416122f --- /dev/null +++ b/src/main/java/com/n1netails/n1netails/slack/validation/impl/block/GifBlockValidator.java @@ -0,0 +1,34 @@ +package com.n1netails.n1netails.slack.validation.impl.block; + +import com.n1netails.n1netails.slack.exception.SlackValidationException; +import com.n1netails.n1netails.slack.model.block.GifBlock; +import com.n1netails.n1netails.slack.validation.SlackValidator; + +/** + * Validator for {@link GifBlock}. + *

+ * Ensures that a GIF block has both a valid URL and alternative text for accessibility. + *

+ * + *

Example usage:

+ *
{@code
+ * GifBlock gif = GifBlock.of("https://media.giphy.com/media/3oEjI6SIIHBdRxXI40/giphy.gif", "Dancing cat");
+ * new GifBlockValidator().validate(gif); // passes validation
+ * }
+ * + *

Throws {@link SlackValidationException} if the GIF URL or alt text is missing or blank.

+ * + * @author Artur Slimak + */ +public class GifBlockValidator implements SlackValidator { + @Override + public void validate(GifBlock target) throws SlackValidationException { + if (target.getGifUrl() == null || target.getGifUrl().isBlank()) { + throw new SlackValidationException("Gif URL cannot be empty"); + } + + if (target.getAltText() == null || target.getAltText().isBlank()) { + throw new SlackValidationException("altText is required for accessibility"); + } + } +} diff --git a/src/main/java/com/n1netails/n1netails/slack/validation/impl/block/ImageBlockValidator.java b/src/main/java/com/n1netails/n1netails/slack/validation/impl/block/ImageBlockValidator.java new file mode 100644 index 0000000..0db2ef7 --- /dev/null +++ b/src/main/java/com/n1netails/n1netails/slack/validation/impl/block/ImageBlockValidator.java @@ -0,0 +1,34 @@ +package com.n1netails.n1netails.slack.validation.impl.block; + +import com.n1netails.n1netails.slack.exception.SlackValidationException; +import com.n1netails.n1netails.slack.model.block.ImageBlock; +import com.n1netails.n1netails.slack.validation.SlackValidator; + +/** + * Validator for {@link ImageBlock}. + *

+ * Ensures that an image block has both a valid URL and alternative text for accessibility. + *

+ * + *

Example usage:

+ *
{@code
+ * ImageBlock image = ImageBlock.of("https://example.com/image.png", "Descriptive alt text");
+ * new ImageBlockValidator().validate(image); // passes validation
+ * }
+ * + *

Throws {@link SlackValidationException} if the image URL or alt text is missing or blank.

+ * + * @author Artur Slimak + */ +public class ImageBlockValidator implements SlackValidator { + @Override + public void validate(ImageBlock target) throws SlackValidationException { + if (target.getImageUrl() == null || target.getImageUrl().isBlank()) { + throw new SlackValidationException("Image URL cannot be empty"); + } + + if (target.getAltText() == null || target.getAltText().isBlank()) { + throw new SlackValidationException("altText is required for accessibility"); + } + } +} diff --git a/src/main/java/com/n1netails/n1netails/slack/validation/impl/block/TextBlockValidator.java b/src/main/java/com/n1netails/n1netails/slack/validation/impl/block/TextBlockValidator.java new file mode 100644 index 0000000..4e4fed3 --- /dev/null +++ b/src/main/java/com/n1netails/n1netails/slack/validation/impl/block/TextBlockValidator.java @@ -0,0 +1,31 @@ +package com.n1netails.n1netails.slack.validation.impl.block; + +import com.n1netails.n1netails.slack.exception.SlackValidationException; +import com.n1netails.n1netails.slack.model.block.GifBlock; +import com.n1netails.n1netails.slack.model.block.TextBlock; +import com.n1netails.n1netails.slack.validation.SlackValidator; + +/** + * Validator for {@link TextBlock}. + *

+ * Ensures that a text block contains non-empty text. + *

+ * + *

Example usage:

+ *
{@code
+ * TextBlock text = TextBlock.of("Hello Slack!");
+ * new TextBlockValidator().validate(text); // passes validation
+ * }
+ * + *

Throws {@link SlackValidationException} if the text is null or blank.

+ * + * @author Artur Slimak + */ +public class TextBlockValidator implements SlackValidator { + @Override + public void validate(TextBlock target) throws SlackValidationException { + if (target.getText() == null || target.getText().isBlank()) { + throw new SlackValidationException("Text cannot be empty"); + } + } +} diff --git a/src/main/java/com/n1netails/n1netails/slack/validation/impl/element/ButtonElementValidator.java b/src/main/java/com/n1netails/n1netails/slack/validation/impl/element/ButtonElementValidator.java new file mode 100644 index 0000000..36f1612 --- /dev/null +++ b/src/main/java/com/n1netails/n1netails/slack/validation/impl/element/ButtonElementValidator.java @@ -0,0 +1,43 @@ +package com.n1netails.n1netails.slack.validation.impl.element; + +import com.n1netails.n1netails.slack.exception.SlackValidationException; +import com.n1netails.n1netails.slack.model.actions_element.ButtonElement; +import com.n1netails.n1netails.slack.validation.SlackValidator; + +/** + * Validator for {@link ButtonElement}. + *

+ * Ensures that a button element has valid text and either an {@code actionId} or a URL. + *

+ * + *

Example usage:

+ *
{@code
+ * ButtonElement button = ButtonElement.link("Visit Site", "https://example.com");
+ * new ButtonElementValidator().validate(button); // passes validation
+ *
+ * ButtonElement actionButton = ButtonElement.action("Click me", "action_123");
+ * new ButtonElementValidator().validate(actionButton); // passes validation
+ * }
+ * + *

Throws {@link SlackValidationException} if:

+ *
    + *
  • Text is null or blank
  • + *
  • Both {@code actionId} and {@code url} are missing or blank
  • + *
+ * + * @author Artur Slimak + */ +public class ButtonElementValidator implements SlackValidator { + @Override + public void validate(ButtonElement target) throws SlackValidationException { + if (target.getText() == null || target.getText().isBlank()) { + throw new SlackValidationException("Button text is required"); + } + if ((target.getActionId() == null || target.getActionId().isBlank()) && + (target.getUrl() == null || target.getUrl().isBlank())) { + throw new SlackValidationException( + "Button must have either actionId or url" + ); + } + } +}