+ * 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:
+ *+ * 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+ * 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: + *
+ *+ * 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+ * 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+ * 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+ * 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+ * 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+ * 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+ * 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+ * 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+ * 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+ * Provides automatic validation of: + *
+ *+ * 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+ * If no validator exists for the target type, or the target is {@code null}, validation is skipped. + *
+ * + * @param+ * This validator iterates over all {@link SlackBlock} instances in a message and validates + * each block and its children recursively using {@link BasicSlackValidators}. + *
+ * + *Validation rules:
+ *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+ * Implementations provide type-specific validation logic for Slack blocks or elements. + *
+ * + *Example usage:
+ *{@code
+ * SlackValidator validator = new TextBlockValidator();
+ * validator.validate(TextBlock.of("Hello"));
+ * }
+ *
+ * @param + * 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+ * 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+ * 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+ * 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+ * 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:
+ *