getIsSlackUrl();
+}
diff --git a/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/elements/richtextelements/RichTextElement.java b/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/elements/richtextelements/RichTextElement.java
index 0483e7b6..ccda4f66 100644
--- a/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/elements/richtextelements/RichTextElement.java
+++ b/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/elements/richtextelements/RichTextElement.java
@@ -48,6 +48,55 @@
value = RichTextEmojiElement.class,
name = RichTextEmojiElement.TYPE
),
+ @JsonSubTypes.Type(
+ value = RichTextAttachmentMentionElement.class,
+ name = RichTextAttachmentMentionElement.TYPE
+ ),
+ @JsonSubTypes.Type(
+ value = RichTextTeamElement.class,
+ name = RichTextTeamElement.TYPE
+ ),
+ @JsonSubTypes.Type(
+ value = RichTextMessageMentionElement.class,
+ name = RichTextMessageMentionElement.TYPE
+ ),
+ @JsonSubTypes.Type(
+ value = RichTextFileElement.class,
+ name = RichTextFileElement.TYPE
+ ),
+ @JsonSubTypes.Type(value = RichTextTagElement.class, name = RichTextTagElement.TYPE),
+ @JsonSubTypes.Type(
+ value = RichTextListRecordElement.class,
+ name = RichTextListRecordElement.TYPE
+ ),
+ @JsonSubTypes.Type(
+ value = RichTextWorkObjectMentionElement.class,
+ name = RichTextWorkObjectMentionElement.TYPE
+ ),
+ @JsonSubTypes.Type(
+ value = RichTextWorkflowMentionElement.class,
+ name = RichTextWorkflowMentionElement.TYPE
+ ),
+ @JsonSubTypes.Type(
+ value = RichTextSalesforceDataFieldElement.class,
+ name = RichTextSalesforceDataFieldElement.TYPE
+ ),
+ @JsonSubTypes.Type(
+ value = RichTextCanvasElement.class,
+ name = RichTextCanvasElement.TYPE
+ ),
+ @JsonSubTypes.Type(
+ value = RichTextCanvasUserMentionElement.class,
+ name = RichTextCanvasUserMentionElement.TYPE
+ ),
+ @JsonSubTypes.Type(
+ value = RichTextCanvasMessageUnfurlElement.class,
+ name = RichTextCanvasMessageUnfurlElement.TYPE
+ ),
+ @JsonSubTypes.Type(
+ value = RichTextCitationElement.class,
+ name = RichTextCitationElement.TYPE
+ ),
}
)
@JsonInclude(JsonInclude.Include.NON_EMPTY)
diff --git a/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/elements/richtextelements/RichTextFileElementIF.java b/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/elements/richtextelements/RichTextFileElementIF.java
new file mode 100644
index 00000000..ff554fd8
--- /dev/null
+++ b/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/elements/richtextelements/RichTextFileElementIF.java
@@ -0,0 +1,64 @@
+package com.hubspot.slack.client.models.blocks.elements.richtextelements;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy;
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
+import com.fasterxml.jackson.databind.annotation.JsonNaming;
+import com.hubspot.immutables.style.HubSpotStyle;
+import java.util.Optional;
+import org.immutables.value.Value;
+import org.immutables.value.Value.Immutable;
+
+/**
+ * Slack's file element renders as a link to a Slack file.
+ * This is a rich text element, compatible only with the {@code rich_text} block. It must be
+ * used within the {@code rich_text_list}, {@code rich_text_quote}, or {@code rich_text_section}
+ * block element within the {@code rich_text} block's {@code elements} array.
+ * Note: this is distinct from the {@code file} block (the {@code File} block class); both
+ * use {@code type: "file"} but the block references a remote file via {@code external_id}/{@code source},
+ * whereas this element links a Slack file via {@code file_id} inside rich text.
+ * @see File element
+ */
+@Immutable
+@HubSpotStyle
+@Value.Enclosing
+@JsonNaming(SnakeCaseStrategy.class)
+public interface RichTextFileElementIF extends RichTextElement {
+ String TYPE = "file";
+
+ @Override
+ @Value.Derived
+ default String getType() {
+ return TYPE;
+ }
+
+ @Value.Parameter
+ String getFileId();
+
+ Optional getText();
+
+ Optional getUrl();
+
+ Optional getIsSkillInvocation();
+
+ Optional getStyle();
+
+ @Immutable
+ @HubSpotStyle
+ @JsonNaming(SnakeCaseStrategy.class)
+ @JsonInclude(JsonInclude.Include.NON_ABSENT)
+ @JsonDeserialize(as = RichTextFileElement.Style.class)
+ interface StyleIF {
+ Optional getBold();
+
+ Optional getItalic();
+
+ Optional getStrike();
+
+ Optional getHighlight();
+
+ Optional getClientHighlight();
+
+ Optional getUnderline();
+ }
+}
diff --git a/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/elements/richtextelements/RichTextListRecordElementIF.java b/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/elements/richtextelements/RichTextListRecordElementIF.java
new file mode 100644
index 00000000..6facaf79
--- /dev/null
+++ b/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/elements/richtextelements/RichTextListRecordElementIF.java
@@ -0,0 +1,65 @@
+package com.hubspot.slack.client.models.blocks.elements.richtextelements;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy;
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
+import com.fasterxml.jackson.databind.annotation.JsonNaming;
+import com.hubspot.immutables.style.HubSpotStyle;
+import java.util.Optional;
+import org.immutables.value.Value;
+import org.immutables.value.Value.Immutable;
+
+/**
+ * Slack's list record element renders as a link to a Slack list record.
+ * This is a rich text element, compatible only with the {@code rich_text} block. It must be
+ * used within the {@code rich_text_list}, {@code rich_text_quote}, or {@code rich_text_section}
+ * block element within the {@code rich_text} block's {@code elements} array.
+ * @see List record element
+ */
+@Immutable
+@HubSpotStyle
+@Value.Enclosing
+@JsonNaming(SnakeCaseStrategy.class)
+public interface RichTextListRecordElementIF extends RichTextElement {
+ String TYPE = "list_record";
+
+ @Override
+ @Value.Derived
+ default String getType() {
+ return TYPE;
+ }
+
+ @Value.Parameter
+ String getFileId();
+
+ Optional getRecordId();
+
+ Optional getViewId();
+
+ Optional getText();
+
+ Optional getUrl();
+
+ Optional getStyle();
+
+ @Immutable
+ @HubSpotStyle
+ @JsonNaming(SnakeCaseStrategy.class)
+ @JsonInclude(JsonInclude.Include.NON_ABSENT)
+ @JsonDeserialize(as = RichTextListRecordElement.Style.class)
+ interface StyleIF {
+ Optional getBold();
+
+ Optional getItalic();
+
+ Optional getStrike();
+
+ Optional getHighlight();
+
+ Optional getClientHighlight();
+
+ Optional getUnderline();
+
+ Optional getUnlink();
+ }
+}
diff --git a/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/elements/richtextelements/RichTextMessageMentionElementIF.java b/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/elements/richtextelements/RichTextMessageMentionElementIF.java
new file mode 100644
index 00000000..c9205921
--- /dev/null
+++ b/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/elements/richtextelements/RichTextMessageMentionElementIF.java
@@ -0,0 +1,66 @@
+package com.hubspot.slack.client.models.blocks.elements.richtextelements;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy;
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
+import com.fasterxml.jackson.databind.annotation.JsonNaming;
+import com.hubspot.immutables.style.HubSpotStyle;
+import java.util.Optional;
+import org.immutables.value.Value;
+import org.immutables.value.Value.Immutable;
+
+/**
+ * Slack's message mention element renders as a link to a Slack message.
+ * This is a rich text element, compatible only with the {@code rich_text} block. It must be
+ * used within the {@code rich_text_list}, {@code rich_text_quote}, or {@code rich_text_section}
+ * block element within the {@code rich_text} block's {@code elements} array.
+ * @see Message mention element
+ */
+@Immutable
+@HubSpotStyle
+@Value.Enclosing
+@JsonNaming(SnakeCaseStrategy.class)
+public interface RichTextMessageMentionElementIF extends RichTextElement {
+ String TYPE = "message_mention";
+
+ @Override
+ @Value.Derived
+ default String getType() {
+ return TYPE;
+ }
+
+ String getChannelId();
+
+ String getMessageTs();
+
+ Optional getAuthorId();
+
+ Optional getThreadTs();
+
+ Optional getText();
+
+ Optional getUrl();
+
+ Optional getStyle();
+
+ @Immutable
+ @HubSpotStyle
+ @JsonNaming(SnakeCaseStrategy.class)
+ @JsonInclude(JsonInclude.Include.NON_ABSENT)
+ @JsonDeserialize(as = RichTextMessageMentionElement.Style.class)
+ interface StyleIF {
+ Optional getBold();
+
+ Optional getItalic();
+
+ Optional getStrike();
+
+ Optional getHighlight();
+
+ Optional getClientHighlight();
+
+ Optional getUnderline();
+
+ Optional getUnlink();
+ }
+}
diff --git a/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/elements/richtextelements/RichTextSalesforceDataFieldElementIF.java b/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/elements/richtextelements/RichTextSalesforceDataFieldElementIF.java
new file mode 100644
index 00000000..d34c287c
--- /dev/null
+++ b/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/elements/richtextelements/RichTextSalesforceDataFieldElementIF.java
@@ -0,0 +1,63 @@
+package com.hubspot.slack.client.models.blocks.elements.richtextelements;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy;
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
+import com.fasterxml.jackson.databind.annotation.JsonNaming;
+import com.hubspot.immutables.style.HubSpotStyle;
+import java.util.Optional;
+import org.immutables.value.Value;
+import org.immutables.value.Value.Immutable;
+
+/**
+ * Slack's Salesforce data field element renders as a Salesforce data field reference.
+ * This is a rich text element, compatible only with the {@code rich_text} block. It must be
+ * used within the {@code rich_text_list}, {@code rich_text_quote}, or {@code rich_text_section}
+ * block element within the {@code rich_text} block's {@code elements} array.
+ * @see Salesforce data field element
+ */
+@Immutable
+@HubSpotStyle
+@Value.Enclosing
+@JsonNaming(SnakeCaseStrategy.class)
+public interface RichTextSalesforceDataFieldElementIF extends RichTextElement {
+ String TYPE = "salesforce_data_field";
+
+ @Override
+ @Value.Derived
+ default String getType() {
+ return TYPE;
+ }
+
+ @Value.Parameter
+ String getSalesforceRecordId();
+
+ Optional getSalesforceFieldLabel();
+
+ Optional getSalesforceFieldApiName();
+
+ Optional getSalesforceIncludeFieldLabel();
+
+ Optional getStyle();
+
+ @Immutable
+ @HubSpotStyle
+ @JsonNaming(SnakeCaseStrategy.class)
+ @JsonInclude(JsonInclude.Include.NON_ABSENT)
+ @JsonDeserialize(as = RichTextSalesforceDataFieldElement.Style.class)
+ interface StyleIF {
+ Optional getBold();
+
+ Optional getItalic();
+
+ Optional getStrike();
+
+ Optional getHighlight();
+
+ Optional getClientHighlight();
+
+ Optional getUnderline();
+
+ Optional getUnlink();
+ }
+}
diff --git a/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/elements/richtextelements/RichTextTagElementIF.java b/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/elements/richtextelements/RichTextTagElementIF.java
new file mode 100644
index 00000000..326a68fd
--- /dev/null
+++ b/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/elements/richtextelements/RichTextTagElementIF.java
@@ -0,0 +1,60 @@
+package com.hubspot.slack.client.models.blocks.elements.richtextelements;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy;
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
+import com.fasterxml.jackson.databind.annotation.JsonNaming;
+import com.hubspot.immutables.style.HubSpotStyle;
+import java.util.Optional;
+import org.immutables.value.Value;
+import org.immutables.value.Value.Immutable;
+
+/**
+ * Slack's tag element renders as a colored tag or pill.
+ * This is a rich text element, compatible only with the {@code rich_text} block. It must be
+ * used within the {@code rich_text_list}, {@code rich_text_quote}, or {@code rich_text_section}
+ * block element within the {@code rich_text} block's {@code elements} array.
+ * @see Tag element
+ */
+@Immutable
+@HubSpotStyle
+@Value.Enclosing
+@JsonNaming(SnakeCaseStrategy.class)
+public interface RichTextTagElementIF extends RichTextElement {
+ String TYPE = "tag";
+
+ @Override
+ @Value.Derived
+ default String getType() {
+ return TYPE;
+ }
+
+ @Value.Parameter
+ String getText();
+
+ /** One of: gray, brown, purple, indigo, blue, green, yellow, orange, red. */
+ Optional getColor();
+
+ Optional getStyle();
+
+ @Immutable
+ @HubSpotStyle
+ @JsonNaming(SnakeCaseStrategy.class)
+ @JsonInclude(JsonInclude.Include.NON_ABSENT)
+ @JsonDeserialize(as = RichTextTagElement.Style.class)
+ interface StyleIF {
+ Optional getBold();
+
+ Optional getItalic();
+
+ Optional getStrike();
+
+ Optional getHighlight();
+
+ Optional getClientHighlight();
+
+ Optional getUnderline();
+
+ Optional getUnlink();
+ }
+}
diff --git a/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/elements/richtextelements/RichTextTeamElementIF.java b/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/elements/richtextelements/RichTextTeamElementIF.java
new file mode 100644
index 00000000..0d587666
--- /dev/null
+++ b/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/elements/richtextelements/RichTextTeamElementIF.java
@@ -0,0 +1,65 @@
+package com.hubspot.slack.client.models.blocks.elements.richtextelements;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy;
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
+import com.fasterxml.jackson.databind.annotation.JsonNaming;
+import com.hubspot.immutables.style.HubSpotStyle;
+import java.util.Optional;
+import org.immutables.value.Value;
+import org.immutables.value.Value.Immutable;
+
+/**
+ * Slack's team element renders as a mention of a workspace or team.
+ * This is a rich text element, compatible only with the {@code rich_text} block. It must be
+ * used within the {@code rich_text_list}, {@code rich_text_quote}, or {@code rich_text_section}
+ * block element within the {@code rich_text} block's {@code elements} array.
+ *
+ * WARNING — documented but not accepted by Slack at runtime. As of 2026-10-02, pasting
+ * the official docs example into the Block Kit Builder and previewing it in Slack fails: the Slack
+ * API rejects it with "unknown rich text element type team". The {@code team} element is
+ * therefore modeled here for completeness/round-trip support, but should NOT be sent to Slack until
+ * Slack actually supports it — doing so will cause the message to be rejected. Verify in the Block
+ * Kit Builder before relying on it.
+ *
+ * @see Team element
+ */
+@Immutable
+@HubSpotStyle
+@Value.Enclosing
+@JsonNaming(SnakeCaseStrategy.class)
+public interface RichTextTeamElementIF extends RichTextElement {
+ String TYPE = "team";
+
+ @Override
+ @Value.Derived
+ default String getType() {
+ return TYPE;
+ }
+
+ @Value.Parameter
+ String getTeamId();
+
+ Optional getStyle();
+
+ @Immutable
+ @HubSpotStyle
+ @JsonNaming(SnakeCaseStrategy.class)
+ @JsonInclude(JsonInclude.Include.NON_ABSENT)
+ @JsonDeserialize(as = RichTextTeamElement.Style.class)
+ interface StyleIF {
+ Optional getBold();
+
+ Optional getItalic();
+
+ Optional getStrike();
+
+ Optional getHighlight();
+
+ Optional getClientHighlight();
+
+ Optional getUnderline();
+
+ Optional getUnlink();
+ }
+}
diff --git a/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/elements/richtextelements/RichTextWorkObjectMentionElementIF.java b/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/elements/richtextelements/RichTextWorkObjectMentionElementIF.java
new file mode 100644
index 00000000..511c83e0
--- /dev/null
+++ b/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/elements/richtextelements/RichTextWorkObjectMentionElementIF.java
@@ -0,0 +1,68 @@
+package com.hubspot.slack.client.models.blocks.elements.richtextelements;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy;
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
+import com.fasterxml.jackson.databind.annotation.JsonNaming;
+import com.hubspot.immutables.style.HubSpotStyle;
+import java.util.Optional;
+import org.immutables.value.Value;
+import org.immutables.value.Value.Immutable;
+
+/**
+ * Slack's work object mention element renders as a Work Object reference.
+ * This is a rich text element, compatible only with the {@code rich_text} block. It must be
+ * used within the {@code rich_text_list}, {@code rich_text_quote}, or {@code rich_text_section}
+ * block element within the {@code rich_text} block's {@code elements} array.
+ * @see Work object mention element
+ */
+@Immutable
+@HubSpotStyle
+@Value.Enclosing
+@JsonNaming(SnakeCaseStrategy.class)
+public interface RichTextWorkObjectMentionElementIF extends RichTextElement {
+ String TYPE = "work_object_mention";
+
+ @Override
+ @Value.Derived
+ default String getType() {
+ return TYPE;
+ }
+
+ String getEntityId();
+
+ String getAppId();
+
+ String getText();
+
+ String getUrl();
+
+ Optional getIconUrl();
+
+ Optional getFullSizePreviewEnabled();
+
+ Optional getProductName();
+
+ Optional getStyle();
+
+ @Immutable
+ @HubSpotStyle
+ @JsonNaming(SnakeCaseStrategy.class)
+ @JsonInclude(JsonInclude.Include.NON_ABSENT)
+ @JsonDeserialize(as = RichTextWorkObjectMentionElement.Style.class)
+ interface StyleIF {
+ Optional getBold();
+
+ Optional getItalic();
+
+ Optional getStrike();
+
+ Optional getHighlight();
+
+ Optional getClientHighlight();
+
+ Optional getUnderline();
+
+ Optional getUnlink();
+ }
+}
diff --git a/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/elements/richtextelements/RichTextWorkflowMentionElementIF.java b/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/elements/richtextelements/RichTextWorkflowMentionElementIF.java
new file mode 100644
index 00000000..4abef942
--- /dev/null
+++ b/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/elements/richtextelements/RichTextWorkflowMentionElementIF.java
@@ -0,0 +1,66 @@
+package com.hubspot.slack.client.models.blocks.elements.richtextelements;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy;
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
+import com.fasterxml.jackson.databind.annotation.JsonNaming;
+import com.hubspot.immutables.style.HubSpotStyle;
+import java.util.Optional;
+import org.immutables.value.Value;
+import org.immutables.value.Value.Immutable;
+
+/**
+ * Slack's workflow mention element renders as a link to a workflow.
+ * This is a rich text element, compatible only with the {@code rich_text} block. It must be
+ * used within the {@code rich_text_list}, {@code rich_text_quote}, or {@code rich_text_section}
+ * block element within the {@code rich_text} block's {@code elements} array.
+ * @see Workflow mention element
+ */
+@Immutable
+@HubSpotStyle
+@Value.Enclosing
+@JsonNaming(SnakeCaseStrategy.class)
+public interface RichTextWorkflowMentionElementIF extends RichTextElement {
+ String TYPE = "workflow_mention";
+
+ @Override
+ @Value.Derived
+ default String getType() {
+ return TYPE;
+ }
+
+ String getWorkflowId();
+
+ String getFunctionTriggerId();
+
+ String getText();
+
+ Optional getUrl();
+
+ Optional getChannelId();
+
+ Optional getTs();
+
+ Optional getStyle();
+
+ @Immutable
+ @HubSpotStyle
+ @JsonNaming(SnakeCaseStrategy.class)
+ @JsonInclude(JsonInclude.Include.NON_ABSENT)
+ @JsonDeserialize(as = RichTextWorkflowMentionElement.Style.class)
+ interface StyleIF {
+ Optional getBold();
+
+ Optional getItalic();
+
+ Optional getStrike();
+
+ Optional getHighlight();
+
+ Optional getClientHighlight();
+
+ Optional getUnderline();
+
+ Optional getUnlink();
+ }
+}
diff --git a/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/elements/richtextelements/UnknownCitationDetails.java b/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/elements/richtextelements/UnknownCitationDetails.java
new file mode 100644
index 00000000..e4083ab5
--- /dev/null
+++ b/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/elements/richtextelements/UnknownCitationDetails.java
@@ -0,0 +1,20 @@
+package com.hubspot.slack.client.models.blocks.elements.richtextelements;
+
+/**
+ * Read-only deserialization fallback for a {@link CitationDetails} whose {@code citation_type} we
+ * don't model (mirrors {@code UnknownRichTextElement} / {@code UnknownBlock}). It drops the original
+ * payload, so it must NOT be re-serialized and sent back to Slack — re-serializing emits
+ * {@code citation_type: "unknown"}, which Slack rejects. Use it only to tolerate unknown inbound
+ * citation types without failing deserialization.
+ */
+public class UnknownCitationDetails implements CitationDetails {
+
+ public static final String CITATION_TYPE = "unknown";
+
+ protected UnknownCitationDetails() {}
+
+ @Override
+ public String getCitationType() {
+ return CITATION_TYPE;
+ }
+}
diff --git a/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/elements/richtextelements/WebCitationDetailsIF.java b/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/elements/richtextelements/WebCitationDetailsIF.java
new file mode 100644
index 00000000..7526b38d
--- /dev/null
+++ b/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/elements/richtextelements/WebCitationDetailsIF.java
@@ -0,0 +1,30 @@
+package com.hubspot.slack.client.models.blocks.elements.richtextelements;
+
+import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy;
+import com.fasterxml.jackson.databind.annotation.JsonNaming;
+import com.hubspot.immutables.style.HubSpotStyle;
+import org.immutables.value.Value;
+import org.immutables.value.Value.Immutable;
+
+/**
+ * A {@code web} citation details object (the {@code details} of a citation element).
+ * @see Citation element — fields
+ */
+@Immutable
+@HubSpotStyle
+@JsonNaming(SnakeCaseStrategy.class)
+public interface WebCitationDetailsIF extends CitationDetails {
+ String CITATION_TYPE = "web";
+
+ @Override
+ @Value.Derived
+ default String getCitationType() {
+ return CITATION_TYPE;
+ }
+
+ String getDisplayName();
+
+ String getTitle();
+
+ String getSnippet();
+}
diff --git a/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/objects/TriggerObjectIF.java b/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/objects/TriggerObjectIF.java
new file mode 100644
index 00000000..19f3f337
--- /dev/null
+++ b/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/objects/TriggerObjectIF.java
@@ -0,0 +1,24 @@
+package com.hubspot.slack.client.models.blocks.objects;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy;
+import com.fasterxml.jackson.databind.annotation.JsonNaming;
+import com.hubspot.immutables.style.HubSpotStyle;
+import java.util.List;
+import org.immutables.value.Value;
+import org.immutables.value.Value.Immutable;
+
+/**
+ * Slack's trigger composition object — a link trigger referenced by a {@link WorkflowObject}.
+ * @see Trigger object
+ */
+@Immutable
+@HubSpotStyle
+@JsonInclude(JsonInclude.Include.NON_EMPTY)
+@JsonNaming(SnakeCaseStrategy.class)
+public interface TriggerObjectIF {
+ @Value.Parameter
+ String getUrl();
+
+ List getCustomizableInputParameters();
+}
diff --git a/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/objects/WorkflowInputParameterIF.java b/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/objects/WorkflowInputParameterIF.java
new file mode 100644
index 00000000..1a2fb66b
--- /dev/null
+++ b/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/objects/WorkflowInputParameterIF.java
@@ -0,0 +1,25 @@
+package com.hubspot.slack.client.models.blocks.objects;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy;
+import com.fasterxml.jackson.databind.annotation.JsonNaming;
+import com.hubspot.immutables.style.HubSpotStyle;
+import org.immutables.value.Value;
+import org.immutables.value.Value.Immutable;
+
+/**
+ * A single customizable input parameter ({@code name}/{@code value}) in a {@link TriggerObject}'s
+ * {@code customizable_input_parameters} array.
+ * @see Trigger object — fields
+ */
+@Immutable
+@HubSpotStyle
+@JsonInclude(JsonInclude.Include.NON_EMPTY)
+@JsonNaming(SnakeCaseStrategy.class)
+public interface WorkflowInputParameterIF {
+ @Value.Parameter(order = 1)
+ String getName();
+
+ @Value.Parameter(order = 2)
+ String getValue();
+}
diff --git a/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/objects/WorkflowObjectIF.java b/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/objects/WorkflowObjectIF.java
new file mode 100644
index 00000000..08f35985
--- /dev/null
+++ b/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/objects/WorkflowObjectIF.java
@@ -0,0 +1,22 @@
+package com.hubspot.slack.client.models.blocks.objects;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy;
+import com.fasterxml.jackson.databind.annotation.JsonNaming;
+import com.hubspot.immutables.style.HubSpotStyle;
+import org.immutables.value.Value;
+import org.immutables.value.Value.Immutable;
+
+/**
+ * Slack's workflow composition object — describes the workflow that runs when a workflow button is
+ * clicked. Contains a single required {@link TriggerObject}.
+ * @see Workflow object
+ */
+@Immutable
+@HubSpotStyle
+@JsonInclude(JsonInclude.Include.NON_EMPTY)
+@JsonNaming(SnakeCaseStrategy.class)
+public interface WorkflowObjectIF {
+ @Value.Parameter
+ TriggerObject getTrigger();
+}
diff --git a/slack-base/src/test/java/com/hubspot/slack/client/models/blocks/RichTextBlockDeserializationTest.java b/slack-base/src/test/java/com/hubspot/slack/client/models/blocks/RichTextBlockDeserializationTest.java
index 47043716..06138658 100644
--- a/slack-base/src/test/java/com/hubspot/slack/client/models/blocks/RichTextBlockDeserializationTest.java
+++ b/slack-base/src/test/java/com/hubspot/slack/client/models/blocks/RichTextBlockDeserializationTest.java
@@ -6,17 +6,31 @@
import com.fasterxml.jackson.databind.ObjectMapper;
import com.hubspot.slack.client.jackson.ObjectMapperUtils;
import com.hubspot.slack.client.models.JsonLoader;
+import com.hubspot.slack.client.models.blocks.elements.richtextelements.FileCitationDetails;
+import com.hubspot.slack.client.models.blocks.elements.richtextelements.RichTextAttachmentMentionElement;
import com.hubspot.slack.client.models.blocks.elements.richtextelements.RichTextBroadcastElement;
import com.hubspot.slack.client.models.blocks.elements.richtextelements.RichTextBroadcastRange;
+import com.hubspot.slack.client.models.blocks.elements.richtextelements.RichTextCanvasElement;
+import com.hubspot.slack.client.models.blocks.elements.richtextelements.RichTextCanvasMessageUnfurlElement;
+import com.hubspot.slack.client.models.blocks.elements.richtextelements.RichTextCanvasUserMentionElement;
import com.hubspot.slack.client.models.blocks.elements.richtextelements.RichTextChannelElement;
+import com.hubspot.slack.client.models.blocks.elements.richtextelements.RichTextCitationElement;
import com.hubspot.slack.client.models.blocks.elements.richtextelements.RichTextColorElement;
import com.hubspot.slack.client.models.blocks.elements.richtextelements.RichTextDateElement;
import com.hubspot.slack.client.models.blocks.elements.richtextelements.RichTextEmojiElement;
+import com.hubspot.slack.client.models.blocks.elements.richtextelements.RichTextFileElement;
import com.hubspot.slack.client.models.blocks.elements.richtextelements.RichTextLinkElement;
+import com.hubspot.slack.client.models.blocks.elements.richtextelements.RichTextListRecordElement;
+import com.hubspot.slack.client.models.blocks.elements.richtextelements.RichTextMessageMentionElement;
+import com.hubspot.slack.client.models.blocks.elements.richtextelements.RichTextSalesforceDataFieldElement;
import com.hubspot.slack.client.models.blocks.elements.richtextelements.RichTextSection;
+import com.hubspot.slack.client.models.blocks.elements.richtextelements.RichTextTagElement;
+import com.hubspot.slack.client.models.blocks.elements.richtextelements.RichTextTeamElement;
import com.hubspot.slack.client.models.blocks.elements.richtextelements.RichTextTextElement;
import com.hubspot.slack.client.models.blocks.elements.richtextelements.RichTextUserElement;
import com.hubspot.slack.client.models.blocks.elements.richtextelements.RichTextUserGroupElement;
+import com.hubspot.slack.client.models.blocks.elements.richtextelements.RichTextWorkObjectMentionElement;
+import com.hubspot.slack.client.models.blocks.elements.richtextelements.RichTextWorkflowMentionElement;
import com.hubspot.slack.client.models.blocks.elements.richtextelements.UnknownRichTextElement;
import java.io.IOException;
import org.junit.BeforeClass;
@@ -36,6 +50,19 @@ public class RichTextBlockDeserializationTest {
private static final int SECTION_WITH_USERGROUP_ELEMENT_INDEX = 8;
private static final int SECTION_WITH_MULTIPLE_ELEMENTS_INDEX = 9;
private static final int SECTION_WITH_UNKNOWN_ELEMENT_INDEX = 10;
+ private static final int SECTION_WITH_ATTACHMENT_MENTION_ELEMENT_INDEX = 11;
+ private static final int SECTION_WITH_TEAM_ELEMENT_INDEX = 12;
+ private static final int SECTION_WITH_MESSAGE_MENTION_ELEMENT_INDEX = 13;
+ private static final int SECTION_WITH_FILE_ELEMENT_INDEX = 14;
+ private static final int SECTION_WITH_TAG_ELEMENT_INDEX = 15;
+ private static final int SECTION_WITH_LIST_RECORD_ELEMENT_INDEX = 16;
+ private static final int SECTION_WITH_WORK_OBJECT_MENTION_ELEMENT_INDEX = 17;
+ private static final int SECTION_WITH_WORKFLOW_MENTION_ELEMENT_INDEX = 18;
+ private static final int SECTION_WITH_SALESFORCE_DATA_FIELD_ELEMENT_INDEX = 19;
+ private static final int SECTION_WITH_CANVAS_ELEMENT_INDEX = 20;
+ private static final int SECTION_WITH_CANVAS_USER_MENTION_ELEMENT_INDEX = 21;
+ private static final int SECTION_WITH_CANVAS_MESSAGE_UNFURL_ELEMENT_INDEX = 22;
+ private static final int SECTION_WITH_CITATION_ELEMENT_INDEX = 23;
private static RichTextSection[] sections;
@BeforeClass
@@ -134,6 +161,188 @@ public void itDeserializesUsergroupElement() {
);
}
+ @Test
+ public void itDeserializesAttachmentMentionElement() {
+ assertThat(sections[SECTION_WITH_ATTACHMENT_MENTION_ELEMENT_INDEX].getElements())
+ .containsExactly(
+ RichTextAttachmentMentionElement
+ .builder()
+ .setUrl("https://example.com/attachment")
+ .setText("Fallback text")
+ .setAppId("A123ABC456")
+ .setStyle(
+ RichTextAttachmentMentionElement.Style
+ .builder()
+ .setBold(true)
+ .setClientHighlight(true)
+ .build()
+ )
+ .build()
+ );
+ }
+
+ @Test
+ public void itDeserializesTeamElement() {
+ assertThat(sections[SECTION_WITH_TEAM_ELEMENT_INDEX].getElements())
+ .containsExactly(
+ RichTextTeamElement
+ .builder()
+ .setTeamId("T123ABC456")
+ .setStyle(RichTextTeamElement.Style.builder().setBold(true).build())
+ .build()
+ );
+ }
+
+ @Test
+ public void itDeserializesMessageMentionElement() {
+ assertThat(sections[SECTION_WITH_MESSAGE_MENTION_ELEMENT_INDEX].getElements())
+ .containsExactly(
+ RichTextMessageMentionElement
+ .builder()
+ .setChannelId("C123ABC456")
+ .setMessageTs("1234567890.123456")
+ .setAuthorId("U123ABC456")
+ .setText("a message")
+ .build()
+ );
+ }
+
+ @Test
+ public void itDeserializesFileElement() {
+ assertThat(sections[SECTION_WITH_FILE_ELEMENT_INDEX].getElements())
+ .containsExactly(
+ RichTextFileElement
+ .builder()
+ .setFileId("F123ABC456")
+ .setText("the file")
+ .setStyle(RichTextFileElement.Style.builder().setBold(true).build())
+ .build()
+ );
+ }
+
+ @Test
+ public void itDeserializesTagElement() {
+ assertThat(sections[SECTION_WITH_TAG_ELEMENT_INDEX].getElements())
+ .containsExactly(
+ RichTextTagElement
+ .builder()
+ .setText("Urgent")
+ .setColor("red")
+ .setStyle(RichTextTagElement.Style.builder().setBold(true).build())
+ .build()
+ );
+ }
+
+ @Test
+ public void itDeserializesListRecordElement() {
+ assertThat(sections[SECTION_WITH_LIST_RECORD_ELEMENT_INDEX].getElements())
+ .containsExactly(
+ RichTextListRecordElement
+ .builder()
+ .setFileId("F123ABC456")
+ .setRecordId("Rec1")
+ .setText("a record")
+ .build()
+ );
+ }
+
+ @Test
+ public void itDeserializesWorkObjectMentionElement() {
+ assertThat(sections[SECTION_WITH_WORK_OBJECT_MENTION_ELEMENT_INDEX].getElements())
+ .containsExactly(
+ RichTextWorkObjectMentionElement
+ .builder()
+ .setEntityId("E123")
+ .setAppId("A123")
+ .setText("the object")
+ .setUrl("https://example.com/object")
+ .build()
+ );
+ }
+
+ @Test
+ public void itDeserializesWorkflowMentionElement() {
+ assertThat(sections[SECTION_WITH_WORKFLOW_MENTION_ELEMENT_INDEX].getElements())
+ .containsExactly(
+ RichTextWorkflowMentionElement
+ .builder()
+ .setWorkflowId("Wf123")
+ .setFunctionTriggerId("Ft123")
+ .setText("the workflow")
+ .build()
+ );
+ }
+
+ @Test
+ public void itDeserializesSalesforceDataFieldElement() {
+ assertThat(sections[SECTION_WITH_SALESFORCE_DATA_FIELD_ELEMENT_INDEX].getElements())
+ .containsExactly(
+ RichTextSalesforceDataFieldElement
+ .builder()
+ .setSalesforceRecordId("SR123")
+ .setSalesforceFieldLabel("Stage")
+ .build()
+ );
+ }
+
+ @Test
+ public void itDeserializesCanvasElement() {
+ assertThat(sections[SECTION_WITH_CANVAS_ELEMENT_INDEX].getElements())
+ .containsExactly(
+ RichTextCanvasElement
+ .builder()
+ .setFileId("F_canvas")
+ .setLabel("Doc")
+ .setText("the canvas")
+ .build()
+ );
+ }
+
+ @Test
+ public void itDeserializesCanvasUserMentionElement() {
+ assertThat(sections[SECTION_WITH_CANVAS_USER_MENTION_ELEMENT_INDEX].getElements())
+ .containsExactly(
+ RichTextCanvasUserMentionElement
+ .builder()
+ .setUserId("U123ABC456")
+ .setThreadId("Th123")
+ .build()
+ );
+ }
+
+ @Test
+ public void itDeserializesCanvasMessageUnfurlElement() {
+ assertThat(sections[SECTION_WITH_CANVAS_MESSAGE_UNFURL_ELEMENT_INDEX].getElements())
+ .containsExactly(
+ RichTextCanvasMessageUnfurlElement
+ .builder()
+ .setRootMessageTs("1234567890.123456")
+ .setRootMessageChannel("C123ABC456")
+ .build()
+ );
+ }
+
+ @Test
+ public void itDeserializesCitationElement() {
+ assertThat(sections[SECTION_WITH_CITATION_ELEMENT_INDEX].getElements())
+ .containsExactly(
+ RichTextCitationElement
+ .builder()
+ .setUrl("https://example.com/doc")
+ .setText("Source")
+ .setIndex(1)
+ .setDetails(
+ FileCitationDetails
+ .builder()
+ .setDescriptor("notes.txt")
+ .setFileId("F999")
+ .build()
+ )
+ .setFromLlm(true)
+ .build()
+ );
+ }
+
@Test
public void itDeserializesUnknownElementAsUnknownBlockElement() {
assertThat(sections[SECTION_WITH_UNKNOWN_ELEMENT_INDEX].getElements())
diff --git a/slack-base/src/test/java/com/hubspot/slack/client/models/blocks/elements/BlockElementSerializationTest.java b/slack-base/src/test/java/com/hubspot/slack/client/models/blocks/elements/BlockElementSerializationTest.java
index 2086bb55..ec3876ed 100644
--- a/slack-base/src/test/java/com/hubspot/slack/client/models/blocks/elements/BlockElementSerializationTest.java
+++ b/slack-base/src/test/java/com/hubspot/slack/client/models/blocks/elements/BlockElementSerializationTest.java
@@ -5,6 +5,7 @@
import com.hubspot.slack.client.SerializationTestBase;
import com.hubspot.slack.client.jackson.ObjectMapperUtils;
import com.hubspot.slack.client.models.JsonLoader;
+import com.hubspot.slack.client.models.blocks.Block;
import com.hubspot.slack.client.models.blocks.Section;
import java.io.IOException;
import org.junit.Test;
@@ -16,6 +17,64 @@ public void testBlockSerialization() throws IOException {
testSerialization("block_elements.json", BlockElement[].class);
}
+ @Test
+ public void testAttachmentMentionRichTextSerialization() throws IOException {
+ testSerialization("attachment_mention_rich_text.json", Block[].class);
+ }
+
+ @Test
+ public void testTeamRichTextSerialization() throws IOException {
+ testSerialization("team_rich_text.json", Block[].class);
+ }
+
+ @Test
+ public void testMessageMentionRichTextSerialization() throws IOException {
+ testSerialization("message_mention_rich_text.json", Block[].class);
+ }
+
+ @Test
+ public void testFileRichTextSerialization() throws IOException {
+ testSerialization("file_rich_text.json", Block[].class);
+ }
+
+ @Test
+ public void testRichTextElementsBatchSerialization() throws IOException {
+ testSerialization("rich_text_elements_batch.json", Block[].class);
+ }
+
+ @Test
+ public void testInteractiveElementsBatchSerialization() throws IOException {
+ testSerialization("interactive_elements_batch.json", BlockElement[].class);
+ }
+
+ @Test
+ public void testCitationRichTextSerialization() throws IOException {
+ testSerialization("citation_rich_text.json", Block[].class);
+ }
+
+ // Null-omission guard for all 14 Messages-surface elements (Slack rejects null-valued fields).
+ // Each fixture entry is required-fields-only, so every optional is ABSENT in the raw JSON. The
+ // round-trip asserts readTree(raw).equals(readTree(reserialized)); since a JSON null is a present
+ // NullNode (not the same as an absent key), if serialization emitted "field": null for any absent
+ // optional the generated tree would gain a key the raw tree lacks and this assertion would fail.
+ // The mapper sets no global serialization inclusion, so this genuinely exercises each model's
+ // @JsonInclude(NON_EMPTY).
+ @Test
+ public void testMinimalElementsOmitNulls() throws IOException {
+ testSerialization("minimal_elements.json", Block[].class);
+ }
+
+ @Test
+ public void testInteractiveElementsDeserializeToExpectedTypes() throws IOException {
+ BlockElement[] elements = ObjectMapperUtils
+ .mapper()
+ .readValue(
+ JsonLoader.loadJsonFromFile("interactive_elements_batch.json"),
+ BlockElement[].class
+ );
+ assertThat(elements[0]).isInstanceOf(WorkflowButton.class);
+ }
+
@Test
public void testUnknownBlockSerialization() throws IOException {
String rawJson = JsonLoader.loadJsonFromFile("unknown_block_element.json");
diff --git a/slack-base/src/test/resources/attachment_mention_rich_text.json b/slack-base/src/test/resources/attachment_mention_rich_text.json
new file mode 100644
index 00000000..92a95b6a
--- /dev/null
+++ b/slack-base/src/test/resources/attachment_mention_rich_text.json
@@ -0,0 +1,35 @@
+[
+ {
+ "type": "rich_text",
+ "elements": [
+ {
+ "type": "rich_text_section",
+ "elements": [
+ {
+ "type": "attachment_mention",
+ "url": "https://example.com/attachment",
+ "text": "Fallback text",
+ "app_id": "A123ABC456",
+ "entity_id": "E123ABC456",
+ "icon_url": "https://example.com/icon.png",
+ "channel_id": "C123ABC456",
+ "ts": "1234567890.123456",
+ "full_size_preview_enabled": true,
+ "icon_name": "sf-account",
+ "reference_object_type": "record",
+ "product_name": "Google Docs",
+ "style": {
+ "bold": true,
+ "italic": true,
+ "strike": true,
+ "highlight": true,
+ "client_highlight": true,
+ "underline": true,
+ "unlink": true
+ }
+ }
+ ]
+ }
+ ]
+ }
+]
diff --git a/slack-base/src/test/resources/citation_rich_text.json b/slack-base/src/test/resources/citation_rich_text.json
new file mode 100644
index 00000000..b9358e14
--- /dev/null
+++ b/slack-base/src/test/resources/citation_rich_text.json
@@ -0,0 +1,69 @@
+[
+ {
+ "type": "rich_text",
+ "elements": [
+ {
+ "type": "rich_text_section",
+ "elements": [
+ {
+ "type": "citation",
+ "url": "https://example.com/doc",
+ "text": "File source",
+ "index": 1,
+ "details": {
+ "citation_type": "file",
+ "descriptor": "notes.txt",
+ "file_id": "F999"
+ },
+ "from_llm": true,
+ "is_slack_url": false
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "rich_text",
+ "elements": [
+ {
+ "type": "rich_text_section",
+ "elements": [
+ {
+ "type": "citation",
+ "url": "https://example.com/web",
+ "text": "Web source",
+ "index": 2,
+ "details": {
+ "citation_type": "web",
+ "display_name": "Example",
+ "title": "Example Page",
+ "snippet": "a snippet"
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "rich_text",
+ "elements": [
+ {
+ "type": "rich_text_section",
+ "elements": [
+ {
+ "type": "citation",
+ "url": "https://slack.com/archives/C1/p123",
+ "text": "Message source",
+ "index": 3,
+ "details": {
+ "citation_type": "message",
+ "channel": "C123ABC456",
+ "message_ts": "1234567890.123456"
+ },
+ "is_slack_url": true
+ }
+ ]
+ }
+ ]
+ }
+]
diff --git a/slack-base/src/test/resources/file_rich_text.json b/slack-base/src/test/resources/file_rich_text.json
new file mode 100644
index 00000000..a89254d3
--- /dev/null
+++ b/slack-base/src/test/resources/file_rich_text.json
@@ -0,0 +1,27 @@
+[
+ {
+ "type": "rich_text",
+ "elements": [
+ {
+ "type": "rich_text_section",
+ "elements": [
+ {
+ "type": "file",
+ "file_id": "F123ABC456",
+ "text": "the file",
+ "url": "https://example.com/file",
+ "is_skill_invocation": true,
+ "style": {
+ "bold": true,
+ "italic": true,
+ "strike": true,
+ "highlight": true,
+ "client_highlight": true,
+ "underline": true
+ }
+ }
+ ]
+ }
+ ]
+ }
+]
diff --git a/slack-base/src/test/resources/interactive_elements_batch.json b/slack-base/src/test/resources/interactive_elements_batch.json
new file mode 100644
index 00000000..03c8a4d6
--- /dev/null
+++ b/slack-base/src/test/resources/interactive_elements_batch.json
@@ -0,0 +1,20 @@
+[
+ {
+ "type": "workflow_button",
+ "text": {
+ "type": "plain_text",
+ "text": "Run"
+ },
+ "workflow": {
+ "trigger": {
+ "url": "https://slack.com/shortcuts/Ft0000000000/abc123",
+ "customizable_input_parameters": [
+ { "name": "input_a", "value": "value_a" }
+ ]
+ }
+ },
+ "action_id": "workflow_button_1",
+ "style": "primary",
+ "accessibility_label": "Run the workflow"
+ }
+]
diff --git a/slack-base/src/test/resources/message_mention_rich_text.json b/slack-base/src/test/resources/message_mention_rich_text.json
new file mode 100644
index 00000000..695b2cf7
--- /dev/null
+++ b/slack-base/src/test/resources/message_mention_rich_text.json
@@ -0,0 +1,30 @@
+[
+ {
+ "type": "rich_text",
+ "elements": [
+ {
+ "type": "rich_text_section",
+ "elements": [
+ {
+ "type": "message_mention",
+ "channel_id": "C123ABC456",
+ "message_ts": "1234567890.123456",
+ "author_id": "U123ABC456",
+ "thread_ts": "1234567890.000001",
+ "text": "a message",
+ "url": "https://example.com/message",
+ "style": {
+ "bold": true,
+ "italic": true,
+ "strike": true,
+ "highlight": true,
+ "client_highlight": true,
+ "underline": true,
+ "unlink": true
+ }
+ }
+ ]
+ }
+ ]
+ }
+]
diff --git a/slack-base/src/test/resources/minimal_elements.json b/slack-base/src/test/resources/minimal_elements.json
new file mode 100644
index 00000000..3de08899
--- /dev/null
+++ b/slack-base/src/test/resources/minimal_elements.json
@@ -0,0 +1,172 @@
+[
+ {
+ "type": "rich_text",
+ "elements": [
+ { "type": "rich_text_section", "elements": [{ "type": "tag", "text": "Urgent" }] }
+ ]
+ },
+ {
+ "type": "rich_text",
+ "elements": [
+ {
+ "type": "rich_text_section",
+ "elements": [{ "type": "canvas", "file_id": "F0C6AVAQFC7" }]
+ }
+ ]
+ },
+ {
+ "type": "rich_text",
+ "elements": [
+ {
+ "type": "rich_text_section",
+ "elements": [
+ {
+ "type": "citation",
+ "url": "https://example.com/doc",
+ "text": "Source",
+ "index": 1,
+ "details": {
+ "citation_type": "file",
+ "descriptor": "notes.txt",
+ "file_id": "F123ABC456"
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "rich_text",
+ "elements": [
+ {
+ "type": "rich_text_section",
+ "elements": [
+ { "type": "salesforce_data_field", "salesforce_record_id": "SR123" }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "rich_text",
+ "elements": [
+ {
+ "type": "rich_text_section",
+ "elements": [
+ {
+ "type": "work_object_mention",
+ "entity_id": "E123",
+ "app_id": "A123",
+ "text": "the object",
+ "url": "https://example.com/object"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "rich_text",
+ "elements": [
+ {
+ "type": "rich_text_section",
+ "elements": [
+ { "type": "attachment_mention", "url": "https://example.com/attachment" }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "rich_text",
+ "elements": [
+ {
+ "type": "rich_text_section",
+ "elements": [
+ {
+ "type": "message_mention",
+ "channel_id": "C123ABC456",
+ "message_ts": "1234567890.123456"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "rich_text",
+ "elements": [
+ {
+ "type": "rich_text_section",
+ "elements": [{ "type": "file", "file_id": "F0C6AVAQFC7" }]
+ }
+ ]
+ },
+ {
+ "type": "rich_text",
+ "elements": [
+ {
+ "type": "rich_text_section",
+ "elements": [{ "type": "team", "team_id": "T123ABC456" }]
+ }
+ ]
+ },
+ {
+ "type": "rich_text",
+ "elements": [
+ {
+ "type": "rich_text_section",
+ "elements": [{ "type": "list_record", "file_id": "F123ABC456" }]
+ }
+ ]
+ },
+ {
+ "type": "rich_text",
+ "elements": [
+ {
+ "type": "rich_text_section",
+ "elements": [
+ {
+ "type": "workflow_mention",
+ "workflow_id": "Wf123",
+ "function_trigger_id": "Ft123",
+ "text": "the workflow"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "rich_text",
+ "elements": [
+ {
+ "type": "rich_text_section",
+ "elements": [{ "type": "canvas_user_mention", "user_id": "U123ABC456" }]
+ }
+ ]
+ },
+ {
+ "type": "rich_text",
+ "elements": [
+ {
+ "type": "rich_text_section",
+ "elements": [
+ {
+ "type": "canvas_message_unfurl",
+ "root_message_ts": "1234567890.123456",
+ "root_message_channel": "C123ABC456"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "actions",
+ "elements": [
+ {
+ "type": "workflow_button",
+ "text": { "type": "plain_text", "text": "Run" },
+ "workflow": {
+ "trigger": { "url": "https://slack.com/shortcuts/Ft0000000000/abc123" }
+ },
+ "action_id": "wb_1"
+ }
+ ]
+ }
+]
diff --git a/slack-base/src/test/resources/rich_text_blocks.json b/slack-base/src/test/resources/rich_text_blocks.json
index da26cfe0..59daaded 100644
--- a/slack-base/src/test/resources/rich_text_blocks.json
+++ b/slack-base/src/test/resources/rich_text_blocks.json
@@ -180,6 +180,57 @@
}
]
},
+ {
+ "type": "rich_text",
+ "elements": [
+ {
+ "type": "rich_text_section",
+ "elements": [
+ {
+ "type": "unsupported_rich_text_element",
+ "some_field": "some_value"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "rich_text",
+ "elements": [
+ {
+ "type": "rich_text_section",
+ "elements": [
+ {
+ "type": "attachment_mention",
+ "url": "https://example.com/attachment",
+ "text": "Fallback text",
+ "app_id": "A123ABC456",
+ "style": {
+ "bold": true,
+ "client_highlight": true
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "rich_text",
+ "elements": [
+ {
+ "type": "rich_text_section",
+ "elements": [
+ {
+ "type": "team",
+ "team_id": "T123ABC456",
+ "style": {
+ "bold": true
+ }
+ }
+ ]
+ }
+ ]
+ },
{
"type": "rich_text",
"elements": [
@@ -188,7 +239,155 @@
"elements": [
{
"type": "message_mention",
- "message_id": "1234567890.123456"
+ "channel_id": "C123ABC456",
+ "message_ts": "1234567890.123456",
+ "author_id": "U123ABC456",
+ "text": "a message"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "rich_text",
+ "elements": [
+ {
+ "type": "rich_text_section",
+ "elements": [
+ {
+ "type": "file",
+ "file_id": "F123ABC456",
+ "text": "the file",
+ "style": { "bold": true }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "rich_text",
+ "elements": [
+ {
+ "type": "rich_text_section",
+ "elements": [
+ { "type": "tag", "text": "Urgent", "color": "red", "style": { "bold": true } }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "rich_text",
+ "elements": [
+ {
+ "type": "rich_text_section",
+ "elements": [
+ { "type": "list_record", "file_id": "F123ABC456", "record_id": "Rec1", "text": "a record" }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "rich_text",
+ "elements": [
+ {
+ "type": "rich_text_section",
+ "elements": [
+ {
+ "type": "work_object_mention",
+ "entity_id": "E123",
+ "app_id": "A123",
+ "text": "the object",
+ "url": "https://example.com/object"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "rich_text",
+ "elements": [
+ {
+ "type": "rich_text_section",
+ "elements": [
+ {
+ "type": "workflow_mention",
+ "workflow_id": "Wf123",
+ "function_trigger_id": "Ft123",
+ "text": "the workflow"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "rich_text",
+ "elements": [
+ {
+ "type": "rich_text_section",
+ "elements": [
+ {
+ "type": "salesforce_data_field",
+ "salesforce_record_id": "SR123",
+ "salesforce_field_label": "Stage"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "rich_text",
+ "elements": [
+ {
+ "type": "rich_text_section",
+ "elements": [
+ { "type": "canvas", "file_id": "F_canvas", "label": "Doc", "text": "the canvas" }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "rich_text",
+ "elements": [
+ {
+ "type": "rich_text_section",
+ "elements": [
+ { "type": "canvas_user_mention", "user_id": "U123ABC456", "thread_id": "Th123" }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "rich_text",
+ "elements": [
+ {
+ "type": "rich_text_section",
+ "elements": [
+ {
+ "type": "canvas_message_unfurl",
+ "root_message_ts": "1234567890.123456",
+ "root_message_channel": "C123ABC456"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "rich_text",
+ "elements": [
+ {
+ "type": "rich_text_section",
+ "elements": [
+ {
+ "type": "citation",
+ "url": "https://example.com/doc",
+ "text": "Source",
+ "index": 1,
+ "details": {
+ "citation_type": "file",
+ "descriptor": "notes.txt",
+ "file_id": "F999"
+ },
+ "from_llm": true
}
]
}
diff --git a/slack-base/src/test/resources/rich_text_elements_batch.json b/slack-base/src/test/resources/rich_text_elements_batch.json
new file mode 100644
index 00000000..ac52a7a8
--- /dev/null
+++ b/slack-base/src/test/resources/rich_text_elements_batch.json
@@ -0,0 +1,212 @@
+[
+ {
+ "type": "rich_text",
+ "elements": [
+ {
+ "type": "rich_text_section",
+ "elements": [
+ {
+ "type": "tag",
+ "text": "Urgent",
+ "color": "red",
+ "style": {
+ "bold": true,
+ "italic": true,
+ "strike": true,
+ "highlight": true,
+ "client_highlight": true,
+ "underline": true,
+ "unlink": true
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "rich_text",
+ "elements": [
+ {
+ "type": "rich_text_section",
+ "elements": [
+ {
+ "type": "list_record",
+ "file_id": "F123ABC456",
+ "record_id": "Rec1",
+ "view_id": "View1",
+ "text": "a record",
+ "url": "https://example.com/record",
+ "style": {
+ "bold": true,
+ "italic": true,
+ "strike": true,
+ "highlight": true,
+ "client_highlight": true,
+ "underline": true,
+ "unlink": true
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "rich_text",
+ "elements": [
+ {
+ "type": "rich_text_section",
+ "elements": [
+ {
+ "type": "work_object_mention",
+ "entity_id": "E123",
+ "app_id": "A123",
+ "text": "the object",
+ "url": "https://example.com/object",
+ "icon_url": "https://example.com/icon.png",
+ "full_size_preview_enabled": true,
+ "product_name": "Acme",
+ "style": {
+ "bold": true,
+ "italic": true,
+ "strike": true,
+ "highlight": true,
+ "client_highlight": true,
+ "underline": true,
+ "unlink": true
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "rich_text",
+ "elements": [
+ {
+ "type": "rich_text_section",
+ "elements": [
+ {
+ "type": "workflow_mention",
+ "workflow_id": "Wf123",
+ "function_trigger_id": "Ft123",
+ "text": "the workflow",
+ "url": "https://example.com/workflow",
+ "channel_id": "C123ABC456",
+ "ts": "1234567890.123456",
+ "style": {
+ "bold": true,
+ "italic": true,
+ "strike": true,
+ "highlight": true,
+ "client_highlight": true,
+ "underline": true,
+ "unlink": true
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "rich_text",
+ "elements": [
+ {
+ "type": "rich_text_section",
+ "elements": [
+ {
+ "type": "salesforce_data_field",
+ "salesforce_record_id": "SR123",
+ "salesforce_field_label": "Stage",
+ "salesforce_field_api_name": "StageName",
+ "salesforce_include_field_label": true,
+ "style": {
+ "bold": true,
+ "italic": true,
+ "strike": true,
+ "highlight": true,
+ "client_highlight": true,
+ "underline": true,
+ "unlink": true
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "rich_text",
+ "elements": [
+ {
+ "type": "rich_text_section",
+ "elements": [
+ {
+ "type": "canvas",
+ "file_id": "F_canvas",
+ "label": "Doc",
+ "hide_title": true,
+ "section_id": "Sec1",
+ "text": "the canvas",
+ "url": "https://example.com/canvas",
+ "is_skill_invocation": true,
+ "style": {
+ "bold": true,
+ "italic": true,
+ "strike": true,
+ "highlight": true,
+ "client_highlight": true,
+ "underline": true,
+ "unlink": true
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "rich_text",
+ "elements": [
+ {
+ "type": "rich_text_section",
+ "elements": [
+ {
+ "type": "canvas_user_mention",
+ "user_id": "U123ABC456",
+ "thread_id": "Th123",
+ "style": {
+ "bold": true,
+ "italic": true,
+ "strike": true,
+ "highlight": true,
+ "client_highlight": true,
+ "underline": true,
+ "unlink": true
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "rich_text",
+ "elements": [
+ {
+ "type": "rich_text_section",
+ "elements": [
+ {
+ "type": "canvas_message_unfurl",
+ "root_message_ts": "1234567890.123456",
+ "root_message_channel": "C123ABC456",
+ "style": {
+ "bold": true,
+ "italic": true,
+ "strike": true,
+ "highlight": true,
+ "client_highlight": true,
+ "underline": true
+ }
+ }
+ ]
+ }
+ ]
+ }
+]
diff --git a/slack-base/src/test/resources/team_rich_text.json b/slack-base/src/test/resources/team_rich_text.json
new file mode 100644
index 00000000..2800696d
--- /dev/null
+++ b/slack-base/src/test/resources/team_rich_text.json
@@ -0,0 +1,25 @@
+[
+ {
+ "type": "rich_text",
+ "elements": [
+ {
+ "type": "rich_text_section",
+ "elements": [
+ {
+ "type": "team",
+ "team_id": "T123ABC456",
+ "style": {
+ "bold": true,
+ "italic": true,
+ "strike": true,
+ "highlight": true,
+ "client_highlight": true,
+ "underline": true,
+ "unlink": true
+ }
+ }
+ ]
+ }
+ ]
+ }
+]
diff --git a/slack-java-client/pom.xml b/slack-java-client/pom.xml
index 9efb64c4..83f89a33 100644
--- a/slack-java-client/pom.xml
+++ b/slack-java-client/pom.xml
@@ -100,5 +100,11 @@
assertj-core
test
+
+
+ org.slf4j
+ slf4j-simple
+ test
+
diff --git a/slack-java-client/src/test/java/com/hubspot/slack/client/blockkit/BlockKitPayloadFromFilesTest.java b/slack-java-client/src/test/java/com/hubspot/slack/client/blockkit/BlockKitPayloadFromFilesTest.java
new file mode 100644
index 00000000..c68c0d14
--- /dev/null
+++ b/slack-java-client/src/test/java/com/hubspot/slack/client/blockkit/BlockKitPayloadFromFilesTest.java
@@ -0,0 +1,93 @@
+package com.hubspot.slack.client.blockkit;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.Assume.assumeTrue;
+
+import com.google.common.base.Strings;
+import com.hubspot.slack.client.blockkit.BlockKitPayloadTester.PayloadSendSummary;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import org.junit.Before;
+import org.junit.Test;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Live, manual harness that sends Block Kit payload file(s) to a real channel and prints a summary.
+ * Skipped (JUnit {@code Assume}) unless a token, channel, and payload path are supplied, so it never
+ * runs in CI or a plain {@code mvn test}.
+ *
+ * Point it at a single JSON file or a folder of {@code *.json} payloads:
+ *
{@code
+ * mvn -pl slack-java-client test -Dtest=BlockKitPayloadFromFilesTest \
+ * -Dslack.test.token=xoxb-... -Dslack.test.channel=C0123456789 \
+ * -Dslack.test.payload=/abs/path/to/payloads_dir \
+ * -Dsurefire.failIfNoSpecifiedTests=false
+ * }
+ * (or set {@code SLACK_TEST_TOKEN} / {@code SLACK_TEST_CHANNEL} / {@code SLACK_TEST_PAYLOAD}).
+ *
+ * Each payload file must be a JSON array of blocks (the same shape as the
+ * {@code *_rich_text.json} test fixtures). The summary reports how many were sent and, for each
+ * failure, the reason (e.g. {@code unknown rich text element type team}).
+ */
+public class BlockKitPayloadFromFilesTest {
+
+ private static final Logger LOG = LoggerFactory.getLogger(
+ BlockKitPayloadFromFilesTest.class
+ );
+
+ private String token;
+ private String channelId;
+ private String payloadPath;
+
+ @Before
+ public void setUp() {
+ token = config("slack.test.token", "SLACK_TEST_TOKEN");
+ channelId = config("slack.test.channel", "SLACK_TEST_CHANNEL");
+ payloadPath = config("slack.test.payload", "SLACK_TEST_PAYLOAD");
+ assumeTrue(
+ "Set -Dslack.test.token, -Dslack.test.channel and -Dslack.test.payload (file or folder) " +
+ "to run the file-driven Block Kit payload test",
+ !Strings.isNullOrEmpty(token) &&
+ !Strings.isNullOrEmpty(channelId) &&
+ !Strings.isNullOrEmpty(payloadPath)
+ );
+ }
+
+ @Test
+ public void itSendsPayloadFileOrFolderAndPrintsSummary() throws Exception {
+ Path path = Paths.get(payloadPath);
+ assertThat(Files.exists(path)).as("payload path does not exist: %s", path).isTrue();
+
+ try (BlockKitPayloadTester tester = new BlockKitPayloadTester(token, channelId)) {
+ PayloadSendSummary summary = tester.postFromPath(path);
+
+ LOG.info("Block Kit payload send results:\n{}", summary.render());
+
+ assertThat(summary.getTotal())
+ .as("expected at least one *.json payload under %s", path)
+ .isGreaterThan(0);
+
+ // Fail the test (and the build) if Slack rejected any payload, so a bad payload is not
+ // silently buried in a green build. The assertion message carries the full summary with the
+ // per-payload Slack error reasons.
+ assertThat(summary.getFailedCount())
+ .as(
+ "Slack rejected %s of %s payload(s):%n%s",
+ summary.getFailedCount(),
+ summary.getTotal(),
+ summary.render()
+ )
+ .isZero();
+ }
+ }
+
+ private static String config(String sysProp, String envVar) {
+ String fromProp = System.getProperty(sysProp);
+ if (!Strings.isNullOrEmpty(fromProp)) {
+ return fromProp;
+ }
+ return System.getenv(envVar);
+ }
+}
diff --git a/slack-java-client/src/test/java/com/hubspot/slack/client/blockkit/BlockKitPayloadTester.java b/slack-java-client/src/test/java/com/hubspot/slack/client/blockkit/BlockKitPayloadTester.java
new file mode 100644
index 00000000..d2fd16cf
--- /dev/null
+++ b/slack-java-client/src/test/java/com/hubspot/slack/client/blockkit/BlockKitPayloadTester.java
@@ -0,0 +1,477 @@
+package com.hubspot.slack.client.blockkit;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.google.common.base.Stopwatch;
+import com.google.common.io.Resources;
+import com.hubspot.algebra.Result;
+import com.hubspot.horizon.HttpRequest;
+import com.hubspot.horizon.HttpResponse;
+import com.hubspot.slack.client.SlackClient;
+import com.hubspot.slack.client.SlackClientFactory;
+import com.hubspot.slack.client.SlackClientRuntimeConfig;
+import com.hubspot.slack.client.interceptors.http.ResponseDebugger;
+import com.hubspot.slack.client.jackson.ObjectMapperUtils;
+import com.hubspot.slack.client.methods.SlackMethod;
+import com.hubspot.slack.client.methods.params.chat.ChatPostMessageParams;
+import com.hubspot.slack.client.models.blocks.Block;
+import com.hubspot.slack.client.models.response.SlackError;
+import com.hubspot.slack.client.models.response.chat.ChatPostMessageResponse;
+import java.io.Closeable;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+/**
+ * Test-only harness for runtime-validating Block Kit payloads against the real Slack API.
+ *
+ *
Our (de)serialization round-trip tests only prove that {@code slack-client} can marshal a
+ * block/element to and from JSON — they do not prove that Slack actually accepts and renders
+ * it (see SISLACK-738 implementation rules, Rule 8). Some elements are documented but rejected at
+ * runtime, e.g. Slack returns {@code "unknown rich text element type team"} for the {@code team}
+ * element. This service closes that gap: give it a token + channel, hand it a blocks JSON payload
+ * (or pre-built {@link Block} objects), and it posts a real message and returns the raw Slack
+ * {@link Result} so a test can assert acceptance or inspect the rejection error.
+ *
+ *
Typical use (the Java object is built from the JSON, as required):
+ *
{@code
+ * try (BlockKitPayloadTester tester = new BlockKitPayloadTester(token, channelId)) {
+ * List blocks = BlockKitPayloadTester.blocksFromJson(payloadJson);
+ * Result result = tester.postBlocks(blocks);
+ * assertThat(result.isOk()).isTrue();
+ * }
+ * }
+ */
+public class BlockKitPayloadTester implements Closeable {
+
+ private static final String DEFAULT_FALLBACK_TEXT = "Block Kit payload test";
+
+ private final SlackClient slackClient;
+ private final String channelId;
+ private final boolean ownsClient;
+ private final CapturingResponseDebugger errorCapture;
+
+ /**
+ * Builds a {@link SlackClient} from the given token and targets the given channel.
+ *
+ * @param token a Slack token with {@code chat:write} (bot {@code xoxb-} or user {@code xoxp-})
+ * @param channelId the channel/conversation ID to post into (the token must be able to post there)
+ */
+ public BlockKitPayloadTester(String token, String channelId) {
+ this.errorCapture = new CapturingResponseDebugger();
+ this.slackClient =
+ SlackClientFactory
+ .defaultFactory()
+ .create(
+ SlackClientRuntimeConfig
+ .builder()
+ .setTokenSupplier(() -> token)
+ .setResponseDebugger(errorCapture)
+ .build()
+ );
+ this.channelId = channelId;
+ this.ownsClient = true;
+ }
+
+ /**
+ * Uses a caller-supplied client (e.g. with custom config); caller retains ownership/closing.
+ * Note: detailed failure reasons from {@code response_metadata.messages} are only captured when
+ * this class builds the client itself (the other constructor) — a caller-supplied client would
+ * need its own {@code ResponseDebugger} for that detail.
+ */
+ public BlockKitPayloadTester(SlackClient slackClient, String channelId) {
+ this.slackClient = slackClient;
+ this.channelId = channelId;
+ this.ownsClient = false;
+ this.errorCapture = null;
+ }
+
+ /**
+ * Deserializes a JSON array of blocks into {@code slack-client} {@link Block} objects,
+ * using the same {@link ObjectMapperUtils#mapper()} the library itself uses.
+ */
+ public static List blocksFromJson(String blocksJson) {
+ try {
+ return Arrays.asList(
+ ObjectMapperUtils.mapper().readValue(blocksJson, Block[].class)
+ );
+ } catch (IOException e) {
+ throw new RuntimeException("Could not parse blocks JSON into Block[]", e);
+ }
+ }
+
+ /** Loads a classpath resource (a JSON array of blocks) and deserializes it. */
+ public static List blocksFromResource(String resourceName) {
+ try {
+ String json = Resources.toString(
+ Resources.getResource(resourceName),
+ StandardCharsets.UTF_8
+ );
+ return blocksFromJson(json);
+ } catch (IOException e) {
+ throw new RuntimeException("Could not read blocks resource " + resourceName, e);
+ }
+ }
+
+ /** Posts pre-built blocks with the default fallback text. */
+ public Result postBlocks(List blocks) {
+ return postBlocks(blocks, DEFAULT_FALLBACK_TEXT);
+ }
+
+ /**
+ * Posts pre-built blocks and returns the raw Slack result.
+ *
+ * @return {@code ok} if Slack accepted the payload, {@code err} (with the Slack error string)
+ * if it was rejected — e.g. {@code invalid_blocks} / {@code unknown rich text element type ...}
+ */
+ public Result postBlocks(
+ List blocks,
+ String fallbackText
+ ) {
+ if (errorCapture != null) {
+ errorCapture.reset();
+ }
+ return slackClient
+ .postMessage(
+ ChatPostMessageParams
+ .builder()
+ .setChannelId(channelId)
+ .setText(fallbackText)
+ .setBlocks(blocks)
+ .build()
+ )
+ .join();
+ }
+
+ /**
+ * The detailed Slack error messages (from {@code response_metadata.messages}) for the most recent
+ * failed post, when available. Slack's top-level error is often just {@code invalid_blocks}; the
+ * actionable reason (e.g. {@code unknown rich text element type team}) lives here.
+ */
+ public Optional getLastErrorDetail() {
+ return errorCapture == null ? Optional.empty() : errorCapture.getDetail();
+ }
+
+ /** Convenience: build blocks from a JSON array string and post them. */
+ public Result postBlocksJson(String blocksJson) {
+ return postBlocks(blocksFromJson(blocksJson));
+ }
+
+ /**
+ * Reads a single JSON payload file (a JSON array of blocks), deserializes it, and posts it.
+ * Never throws for an individual payload — any read / parse / Slack-rejection problem is captured
+ * as a {@link PayloadSendResult} carrying the failure reason, so it reads the same whether used
+ * standalone or from {@link #postFromFolder(Path)}.
+ */
+ public PayloadSendResult postFromFile(Path jsonFile) {
+ return postFile(jsonFile, jsonFile.getFileName().toString());
+ }
+
+ private PayloadSendResult postFile(Path jsonFile, String displayName) {
+ String json;
+ try {
+ json = new String(Files.readAllBytes(jsonFile), StandardCharsets.UTF_8);
+ } catch (IOException e) {
+ return PayloadSendResult.failure(
+ displayName,
+ "Could not read file: " + describe(e)
+ );
+ }
+
+ List blocks;
+ try {
+ blocks = blocksFromJson(json);
+ } catch (RuntimeException e) {
+ return PayloadSendResult.failure(
+ displayName,
+ "Could not parse/deserialize payload: " + describe(e)
+ );
+ }
+
+ try {
+ Result result = postBlocks(
+ blocks,
+ "Block Kit payload test: " + displayName
+ );
+ if (result.isOk()) {
+ return PayloadSendResult.success(displayName, result.unwrapOrElseThrow().getTs());
+ }
+ return PayloadSendResult.failure(
+ displayName,
+ reasonFor(result.unwrapErrOrElseThrow())
+ );
+ } catch (RuntimeException e) {
+ return PayloadSendResult.failure(displayName, "Post failed: " + describe(e));
+ }
+ }
+
+ /**
+ * Builds a human-readable failure reason: Slack's top-level error, enriched with the detailed
+ * {@code response_metadata.messages} when the client captured them (see {@link #getLastErrorDetail()}).
+ */
+ private String reasonFor(SlackError error) {
+ String base = error.getError();
+ return getLastErrorDetail().map(detail -> base + " — " + detail).orElse(base);
+ }
+
+ /**
+ * Posts every {@code *.json} payload found under the given folder, recursively (so per-element
+ * subfolders are included), sorted by relative path. Each result is labelled with its path
+ * relative to {@code folder} (e.g. {@code tag/full.json}). Returns an aggregate summary.
+ */
+ public PayloadSendSummary postFromFolder(Path folder) {
+ List files;
+ try (Stream stream = Files.walk(folder)) {
+ files =
+ stream
+ .filter(Files::isRegularFile)
+ .filter(p -> p.getFileName().toString().toLowerCase().endsWith(".json"))
+ .sorted()
+ .collect(Collectors.toList());
+ } catch (IOException e) {
+ throw new RuntimeException("Could not walk folder " + folder, e);
+ }
+
+ List results = new ArrayList<>();
+ for (Path file : files) {
+ results.add(postFile(file, folder.relativize(file).toString()));
+ }
+ return new PayloadSendSummary(results);
+ }
+
+ /**
+ * Convenience: if {@code path} is a directory, behaves like {@link #postFromFolder(Path)};
+ * otherwise posts the single file and wraps the one result in a summary.
+ */
+ public PayloadSendSummary postFromPath(Path path) {
+ if (Files.isDirectory(path)) {
+ return postFromFolder(path);
+ }
+ return new PayloadSendSummary(Collections.singletonList(postFromFile(path)));
+ }
+
+ private static String describe(Throwable t) {
+ Throwable root = t;
+ while (root.getCause() != null && root.getCause() != root) {
+ root = root.getCause();
+ }
+ String message = root.getMessage();
+ return message == null || message.isEmpty()
+ ? root.getClass().getSimpleName()
+ : message;
+ }
+
+ @Override
+ public void close() throws IOException {
+ if (ownsClient) {
+ slackClient.close();
+ }
+ }
+
+ /**
+ * Captures the detailed error messages Slack returns under {@code response_metadata.messages} on a
+ * failed API call, which the {@code slack-client} {@link SlackError} model otherwise discards
+ * (it keeps only the top-level {@code error}, e.g. {@code invalid_blocks}).
+ */
+ private static final class CapturingResponseDebugger implements ResponseDebugger {
+
+ private final AtomicReference lastDetail = new AtomicReference<>();
+
+ void reset() {
+ lastDetail.set(null);
+ }
+
+ Optional getDetail() {
+ return Optional.ofNullable(lastDetail.get());
+ }
+
+ @Override
+ public void debugSlackApiError(
+ long requestId,
+ SlackMethod method,
+ HttpRequest request,
+ HttpResponse failure
+ ) {
+ String detail = extractMessages(failure);
+ if (detail != null) {
+ lastDetail.set(detail);
+ }
+ }
+
+ @Override
+ public void debugProcessingFailure(
+ long requestId,
+ SlackMethod method,
+ HttpRequest request,
+ HttpResponse response,
+ Throwable ex
+ ) {
+ String detail = extractMessages(response);
+ if (detail != null) {
+ lastDetail.set(detail);
+ }
+ }
+
+ @Override
+ public void debug(
+ long requestId,
+ SlackMethod method,
+ Stopwatch timer,
+ HttpRequest request,
+ HttpResponse response
+ ) {}
+
+ @Override
+ public void debugTransportException(
+ long requestId,
+ SlackMethod method,
+ HttpRequest request,
+ Throwable exception
+ ) {}
+
+ @Override
+ public void debugProactiveRateLimit(
+ long requestId,
+ SlackMethod method,
+ HttpRequest request
+ ) {}
+
+ private static String extractMessages(HttpResponse response) {
+ try {
+ JsonNode root = response.getAsJsonNode();
+ JsonNode messages = root.path("response_metadata").path("messages");
+ if (messages.isArray() && messages.size() > 0) {
+ List parts = new ArrayList<>();
+ messages.forEach(node -> parts.add(node.asText()));
+ return String.join("; ", parts);
+ }
+ JsonNode warning = root.path("warning");
+ if (warning.isTextual() && !warning.asText().isEmpty()) {
+ return warning.asText();
+ }
+ return null;
+ } catch (RuntimeException e) {
+ return null;
+ }
+ }
+ }
+
+ /** Outcome of sending one payload file: either success (with the message ts) or failure (reason). */
+ public static final class PayloadSendResult {
+
+ private final String fileName;
+ private final boolean success;
+ private final String detail;
+
+ private PayloadSendResult(String fileName, boolean success, String detail) {
+ this.fileName = fileName;
+ this.success = success;
+ this.detail = detail;
+ }
+
+ static PayloadSendResult success(String fileName, String messageTs) {
+ return new PayloadSendResult(fileName, true, messageTs);
+ }
+
+ static PayloadSendResult failure(String fileName, String reason) {
+ return new PayloadSendResult(fileName, false, reason);
+ }
+
+ public String getFileName() {
+ return fileName;
+ }
+
+ public boolean isSuccess() {
+ return success;
+ }
+
+ /** The posted message's {@code ts}, present only when {@link #isSuccess()}. */
+ public Optional getMessageTs() {
+ return success ? Optional.of(detail) : Optional.empty();
+ }
+
+ /** The failure reason, present only when the send failed. */
+ public Optional getError() {
+ return success ? Optional.empty() : Optional.of(detail);
+ }
+
+ @Override
+ public String toString() {
+ return success
+ ? "OK " + fileName + " (ts=" + detail + ")"
+ : "FAIL " + fileName + " — " + detail;
+ }
+ }
+
+ /** Aggregate of a batch of {@link PayloadSendResult}s with a human-readable {@link #render()}. */
+ public static final class PayloadSendSummary {
+
+ private final List results;
+
+ PayloadSendSummary(List results) {
+ this.results = Collections.unmodifiableList(new ArrayList<>(results));
+ }
+
+ public List getResults() {
+ return results;
+ }
+
+ public int getTotal() {
+ return results.size();
+ }
+
+ public int getSentCount() {
+ return Math.toIntExact(
+ results.stream().filter(PayloadSendResult::isSuccess).count()
+ );
+ }
+
+ public int getFailedCount() {
+ return getTotal() - getSentCount();
+ }
+
+ public List getFailures() {
+ return results.stream().filter(r -> !r.isSuccess()).collect(Collectors.toList());
+ }
+
+ /** Multi-line summary: a header count line, then one line per payload (sent first, then failures). */
+ public String render() {
+ StringBuilder sb = new StringBuilder();
+ sb
+ .append("Block Kit payload send summary: ")
+ .append(getTotal())
+ .append(" total, ")
+ .append(getSentCount())
+ .append(" sent, ")
+ .append(getFailedCount())
+ .append(" failed");
+ results
+ .stream()
+ .filter(PayloadSendResult::isSuccess)
+ .forEach(r ->
+ sb
+ .append("\n ✅ ")
+ .append(r.getFileName())
+ .append(" (ts=")
+ .append(r.getMessageTs().orElse(""))
+ .append(")")
+ );
+ for (PayloadSendResult failure : getFailures()) {
+ sb
+ .append("\n ❌ ")
+ .append(failure.getFileName())
+ .append(" — ")
+ .append(failure.getError().orElse("unknown error"));
+ }
+ return sb.toString();
+ }
+ }
+}
diff --git a/slack-java-client/src/test/java/com/hubspot/slack/client/blockkit/BlockKitPayloadTesterLiveTest.java b/slack-java-client/src/test/java/com/hubspot/slack/client/blockkit/BlockKitPayloadTesterLiveTest.java
new file mode 100644
index 00000000..d4ca5eb6
--- /dev/null
+++ b/slack-java-client/src/test/java/com/hubspot/slack/client/blockkit/BlockKitPayloadTesterLiveTest.java
@@ -0,0 +1,99 @@
+package com.hubspot.slack.client.blockkit;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.Assume.assumeTrue;
+
+import com.google.common.base.Strings;
+import com.hubspot.algebra.Result;
+import com.hubspot.slack.client.models.response.SlackError;
+import com.hubspot.slack.client.models.response.chat.ChatPostMessageResponse;
+import org.junit.Before;
+import org.junit.Test;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Live, manual runtime-validation of Block Kit payloads against the real Slack API via
+ * {@link BlockKitPayloadTester}. Skipped automatically (JUnit {@code Assume}) unless a token and
+ * channel are supplied, so it never runs in CI or a plain {@code mvn test}.
+ *
+ * Run it yourself with:
+ *
{@code
+ * mvn -pl slack-java-client test -Dtest=BlockKitPayloadTesterLiveTest \
+ * -Dslack.test.token=xoxb-... -Dslack.test.channel=C0123456789
+ * }
+ * (or set {@code SLACK_TEST_TOKEN} / {@code SLACK_TEST_CHANNEL} environment variables).
+ */
+public class BlockKitPayloadTesterLiveTest {
+
+ private static final Logger LOG = LoggerFactory.getLogger(
+ BlockKitPayloadTesterLiveTest.class
+ );
+
+ private String token;
+ private String channelId;
+
+ @Before
+ public void setUp() {
+ token = config("slack.test.token", "SLACK_TEST_TOKEN");
+ channelId = config("slack.test.channel", "SLACK_TEST_CHANNEL");
+ assumeTrue(
+ "Set -Dslack.test.token and -Dslack.test.channel (or SLACK_TEST_TOKEN / " +
+ "SLACK_TEST_CHANNEL) to run the live Block Kit payload tests",
+ !Strings.isNullOrEmpty(token) && !Strings.isNullOrEmpty(channelId)
+ );
+ }
+
+ /** Sanity check: a trivially-valid payload is accepted — proves the harness itself works. */
+ @Test
+ public void itPostsAKnownGoodSectionPayload() throws Exception {
+ String payloadJson =
+ "[{\"type\":\"section\",\"text\":{\"type\":\"mrkdwn\"," +
+ "\"text\":\"BlockKitPayloadTester :white_check_mark: known-good payload\"}}]";
+
+ try (BlockKitPayloadTester tester = new BlockKitPayloadTester(token, channelId)) {
+ Result result = tester.postBlocksJson(
+ payloadJson
+ );
+ LOG.info("known-good section result: {}", result);
+ assertThat(result.isOk())
+ .as("Slack should accept a simple section payload")
+ .isTrue();
+ }
+ }
+
+ /**
+ * Documents the observed runtime rejection of the documented-but-unsupported {@code team}
+ * element (Slack returns {@code "unknown rich text element type team"}). If this ever starts
+ * passing, Slack has shipped support and the WARNING on {@code RichTextTeamElementIF} /
+ * {@code block-kit-elements.md} should be removed.
+ */
+ @Test
+ public void itReportsSlackRejectionForTeamElement() throws Exception {
+ String payloadJson =
+ "[{\"type\":\"rich_text\",\"elements\":[{\"type\":\"rich_text_section\"," +
+ "\"elements\":[{\"type\":\"team\",\"team_id\":\"T123ABC456\"}]}]}]";
+
+ try (BlockKitPayloadTester tester = new BlockKitPayloadTester(token, channelId)) {
+ Result result = tester.postBlocksJson(
+ payloadJson
+ );
+ LOG.info("team-element result: {}", result);
+ assertThat(result.isErr())
+ .as("Slack is expected to reject the documented-but-unsupported `team` element")
+ .isTrue();
+ LOG.warn(
+ "Slack rejected `team` element as expected: {}",
+ result.unwrapErrOrElseThrow().getError()
+ );
+ }
+ }
+
+ private static String config(String sysProp, String envVar) {
+ String fromProp = System.getProperty(sysProp);
+ if (!Strings.isNullOrEmpty(fromProp)) {
+ return fromProp;
+ }
+ return System.getenv(envVar);
+ }
+}
diff --git a/slack-java-client/src/test/resources/blockkit-payloads/README.md b/slack-java-client/src/test/resources/blockkit-payloads/README.md
new file mode 100644
index 00000000..462f5cd5
--- /dev/null
+++ b/slack-java-client/src/test/resources/blockkit-payloads/README.md
@@ -0,0 +1,36 @@
+# blockkit-payloads
+
+An example Block Kit payload (`example_section.json`) for the `BlockKitPayloadFromFilesTest` /
+`BlockKitPayloadTester` harness.
+
+Each payload file is a **JSON array of blocks** — exactly the `blocks` array you'd pass to
+`chat.postMessage`:
+
+```json
+[ { "type": "section", "text": { "type": "mrkdwn", "text": "hello" } } ]
+```
+
+## How to use
+
+Run the harness and point it at a payload file or a folder of `*.json` payloads. Supply your own
+bot token (needs `chat:write`) and the target channel id. Run from the repo root:
+
+```bash
+mvn -pl slack-java-client -am test \
+ -Dtest=BlockKitPayloadFromFilesTest \
+ -Dslack.test.token=xoxb-your-bot-token \
+ -Dslack.test.channel=C0123456789 \
+ -Dslack.test.payload="$(pwd)/slack-java-client/src/test/resources/blockkit-payloads/example_section.json" \
+ -Dsurefire.failIfNoSpecifiedTests=false
+```
+
+- Use an **absolute** path for `-Dslack.test.payload` (the test runs with the module as its working
+ directory, so a repo-relative path won't resolve — the `$(pwd)/...` above makes it absolute).
+- `-Dslack.test.payload` may be a single file or a directory (all `*.json` in it are posted, sorted by name).
+- Token/channel/payload can also be given as the `SLACK_TEST_TOKEN` / `SLACK_TEST_CHANNEL` /
+ `SLACK_TEST_PAYLOAD` environment variables.
+- `-am` is required so the harness compiles against the local `slack-base` (which has the in-progress
+ elements); otherwise new element types deserialize to `unknown` and Slack rejects the payload.
+
+The harness posts each payload and prints a summary of how many were sent, with the Slack error
+detail for any failures.
diff --git a/slack-java-client/src/test/resources/blockkit-payloads/example_section.json b/slack-java-client/src/test/resources/blockkit-payloads/example_section.json
new file mode 100644
index 00000000..989da75d
--- /dev/null
+++ b/slack-java-client/src/test/resources/blockkit-payloads/example_section.json
@@ -0,0 +1,9 @@
+[
+ {
+ "type": "section",
+ "text": {
+ "type": "mrkdwn",
+ "text": "*Example payload* — a single Block Kit message. This file is a JSON array of blocks (the `blocks` you'd pass to chat.postMessage)."
+ }
+ }
+]
diff --git a/slack-java-client/src/test/resources/simplelogger.properties b/slack-java-client/src/test/resources/simplelogger.properties
new file mode 100644
index 00000000..9f8b278f
--- /dev/null
+++ b/slack-java-client/src/test/resources/simplelogger.properties
@@ -0,0 +1,9 @@
+# slf4j-simple config for test scope.
+# Keep the general test output quiet, but show the Block Kit payload harness at INFO on stdout.
+org.slf4j.simpleLogger.defaultLogLevel=warn
+org.slf4j.simpleLogger.log.com.hubspot.slack.client.blockkit=info
+org.slf4j.simpleLogger.logFile=System.out
+org.slf4j.simpleLogger.showDateTime=false
+org.slf4j.simpleLogger.showThreadName=false
+org.slf4j.simpleLogger.showLogName=false
+org.slf4j.simpleLogger.levelInBrackets=true