diff --git a/slack-api-model/pom.xml b/slack-api-model/pom.xml
index 5c5a51342..419f864ba 100644
--- a/slack-api-model/pom.xml
+++ b/slack-api-model/pom.xml
@@ -23,6 +23,11 @@
gson
${gson.version}
+
+ com.google.guava
+ guava
+ 33.4.8-jre
+
diff --git a/slack-api-model/src/main/java/com/slack/api/model/block/composition/ThirdPartyAuthObject.java b/slack-api-model/src/main/java/com/slack/api/model/block/composition/ThirdPartyAuthObject.java
new file mode 100644
index 000000000..33ee544c5
--- /dev/null
+++ b/slack-api-model/src/main/java/com/slack/api/model/block/composition/ThirdPartyAuthObject.java
@@ -0,0 +1,14 @@
+package com.slack.api.model.block.composition;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class ThirdPartyAuthObject {
+ Boolean enableDynamicAuth;
+}
diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/ActionBlockPayload.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/ActionBlockPayload.java
new file mode 100644
index 000000000..0a0bcf2e7
--- /dev/null
+++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/ActionBlockPayload.java
@@ -0,0 +1,4 @@
+package com.slack.api.model.work_objects;
+
+public class ActionBlockPayload {
+}
diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/ActionMenu.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/ActionMenu.java
new file mode 100644
index 000000000..900c7f23c
--- /dev/null
+++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/ActionMenu.java
@@ -0,0 +1,4 @@
+package com.slack.api.model.work_objects;
+
+public class ActionMenu {
+}
diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/AppIcons.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/AppIcons.java
new file mode 100644
index 000000000..ea9d0d4d5
--- /dev/null
+++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/AppIcons.java
@@ -0,0 +1,12 @@
+package com.slack.api.model.work_objects;
+
+import com.slack.api.model.block.ImageBlock;
+import lombok.Builder;
+import lombok.Value;
+
+@Value
+@Builder
+public class AppIcons {
+ ImageBlock image36;
+ ImageBlock image128;
+}
diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/Button.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/Button.java
new file mode 100644
index 000000000..9e01e9ab0
--- /dev/null
+++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/Button.java
@@ -0,0 +1,44 @@
+package com.slack.api.model.work_objects;
+
+import com.slack.api.model.block.composition.ConfirmationDialogObject;
+import com.slack.api.model.block.composition.PlainTextObject;
+import com.slack.api.model.block.composition.ThirdPartyAuthObject;
+import com.slack.api.model.work_objects.external.ButtonProcessingState;
+import com.slack.api.util.annotation.Required;
+import com.slack.api.util.predicate.FieldPredicate;
+import lombok.Builder;
+import lombok.Value;
+
+import java.util.List;
+
+import static com.google.common.base.Predicates.equalTo;
+import static com.google.common.base.Predicates.instanceOf;
+
+/**
+ * Button Action Payload.
+ *
+ * Represents the payload sent to the server from a `button` action.
+ */
+@Value
+@Builder
+public class Button extends PrimaryActions {
+ @Required String blockId;
+ @Required String actionId;
+ @Required(validator = ButtonTypePredicate.class) String type;
+ String style;
+ @Required PlainTextObject text;
+ String value;
+ String url;
+ String accessibilityLabel;
+ ConfirmationDialogObject confirm;
+ ThirdPartyAuthObject thirdPartyAuth;
+ List visibleToUserIds;
+ ButtonProcessingState processingState;
+
+ public static class ButtonTypePredicate implements FieldPredicate {
+ @Override
+ public boolean validate(Object obj) {
+ return instanceOf(String.class).and(equalTo("button")).test(obj);
+ }
+ }
+}
diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/Channel.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/Channel.java
new file mode 100644
index 000000000..83632987b
--- /dev/null
+++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/Channel.java
@@ -0,0 +1,29 @@
+package com.slack.api.model.work_objects;
+
+import com.google.common.base.Preconditions;
+import com.slack.api.util.annotation.Required;
+import com.slack.api.util.predicate.IsValidChannelIdPredicate;
+import lombok.Builder;
+import lombok.Value;
+
+import static com.slack.api.util.predicate.IsValidChannelIdPredicate.CHANNEL_ID_REGEX;
+
+/**
+ * Representation of a Slack channel on a work object.
+ */
+@Value
+public class Channel {
+ private static final IsValidChannelIdPredicate isValidChannelId = new IsValidChannelIdPredicate();
+
+ @Required(validator = IsValidChannelIdPredicate.class)
+ String channelId;
+
+ @Builder
+ public Channel(String channelId) {
+ Preconditions.checkArgument(
+ isValidChannelId.test(channelId),
+ String.format("Invalid slack channelId %s, required format %s", channelId, CHANNEL_ID_REGEX)
+ );
+ this.channelId = channelId;
+ }
+}
diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/CheckboxOption.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/CheckboxOption.java
new file mode 100644
index 000000000..fc34a3c28
--- /dev/null
+++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/CheckboxOption.java
@@ -0,0 +1,14 @@
+package com.slack.api.model.work_objects;
+
+import com.slack.api.util.annotation.Required;
+import lombok.Builder;
+import lombok.Value;
+
+@Value
+@Builder
+public class CheckboxOption {
+ @Required String text;
+ @Required Boolean checked;
+ String description;
+}
+
diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/CompactLayout.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/CompactLayout.java
new file mode 100644
index 000000000..b0afca205
--- /dev/null
+++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/CompactLayout.java
@@ -0,0 +1,20 @@
+package com.slack.api.model.work_objects;
+
+import com.slack.api.model.block.ImageBlock;
+import com.slack.api.util.annotation.Required;
+import lombok.Builder;
+import lombok.Value;
+
+@Value
+@Builder
+public class CompactLayout {
+ public static final String LAYOUT_TYPE = "compact";
+ @Required String layoutType;
+ ImageBlock productIcon;
+ @Required Title title;
+ Title subtitle;
+ Title headerTitle;
+ Title hoverSubtitle;
+ Fields fields;
+ Integer updatedAt;
+}
diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/DateTimeRange.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/DateTimeRange.java
new file mode 100644
index 000000000..db6671dce
--- /dev/null
+++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/DateTimeRange.java
@@ -0,0 +1,144 @@
+package com.slack.api.model.work_objects;
+
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonSerializationContext;
+import com.google.gson.JsonSerializer;
+import com.google.gson.annotations.JsonAdapter;
+import lombok.AccessLevel;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+import lombok.Setter;
+import lombok.Value;
+
+import java.lang.reflect.Type;
+import java.time.Instant;
+import java.time.LocalDate;
+import java.time.ZoneOffset;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
+
+/**
+ * Combined date/time display data for calendar events.
+ */
+@Value
+@Builder
+@JsonAdapter(DateTimeRange.DateTimeRangeSerializer.class)
+public class DateTimeRange {
+ /**
+ * Start as Unix timestamp or YYYY-MM-DD dates.
+ */
+ DateTimeImpl start;
+ /**
+ * End as Unix timestamp or YYYY-MM-DD dates
+ */
+ DateTimeImpl end;
+
+ /**
+ * Whether this is an all-day event.
+ */
+ Boolean allDay;
+
+ /**
+ * Recurrence description text.
+ */
+ String recurrence;
+
+ /**
+ * Since java doesn't support union types, this class represents a datetime input that can either be a unix
+ * timestamp or a date string in YYYY-MM-DD format.
+ */
+ @RequiredArgsConstructor(access = AccessLevel.PRIVATE)
+ public static class DateTimeImpl {
+ private final Instant dateTime;
+ @Getter @Setter
+ private boolean inYearMonthDayFormat = false;
+
+ public boolean isUnknown() {
+ return dateTime.equals(Instant.EPOCH);
+ }
+
+ public static DateTimeImpl atStartOfDay() {
+ Instant startOfDay = LocalDate.now(ZoneOffset.UTC).atStartOfDay(ZoneOffset.UTC).toInstant();
+ return new DateTimeImpl(startOfDay);
+ }
+
+ public static DateTimeImpl atEndOfDay() {
+ Instant endOfDay = LocalDate.now(ZoneOffset.UTC).plusDays(1).atStartOfDay(ZoneOffset.UTC).toInstant();
+ return new DateTimeImpl(endOfDay);
+ }
+
+ // Used during JSON serialization/deserialization when we can't infer what the date-time value is
+ public static DateTimeImpl unknown() {
+ return new DateTimeImpl(Instant.EPOCH);
+ }
+
+ public static DateTimeImpl from(String in) {
+ // See if this is a number first
+ try {
+ return DateTimeImpl.from(Long.parseLong(in));
+ } catch (NumberFormatException e) {
+ // Swallow - means the input is not a unix timestamp, so try to parse this as a string
+ }
+
+ // Now make sure it's in YYYY-MM-DD format
+ try {
+ Instant dt = LocalDate.parse(in).atStartOfDay(ZoneOffset.UTC).toInstant();
+ DateTimeImpl dateTime = new DateTimeImpl(dt);
+ dateTime.setInYearMonthDayFormat(true);
+ return dateTime;
+ } catch (DateTimeParseException e) {
+ throw new IllegalArgumentException(String.format("Datetime input string not in a recognized format %s", in));
+ }
+ }
+
+ public static DateTimeImpl from(int in) {
+ return from(Long.valueOf(in));
+ }
+
+ public static DateTimeImpl from(long in) {
+ Instant dateTime = Instant.ofEpochSecond(in);
+ return new DateTimeImpl(dateTime);
+ }
+
+ public long getUnixTime() {
+ return dateTime.getEpochSecond();
+ }
+
+ public String getDateTime() {
+ return DateTimeFormatter.ofPattern("yyyy-MM-dd").withZone(ZoneOffset.UTC).format(dateTime);
+ }
+ }
+
+ public static class DateTimeRangeSerializer implements JsonSerializer {
+ @Override
+ public JsonElement serialize(DateTimeRange dateTimeRange, Type typeOfT, JsonSerializationContext context) {
+ JsonObject jsonObject = new JsonObject();
+ if (dateTimeRange.getStart() != null) {
+ DateTimeImpl start = dateTimeRange.getStart();
+ if (start.isInYearMonthDayFormat()) {
+ jsonObject.addProperty("start", start.getDateTime());
+ } else {
+ jsonObject.addProperty("start", start.getUnixTime());
+ }
+ }
+ if (dateTimeRange.getEnd() != null) {
+ DateTimeImpl end = dateTimeRange.getEnd();
+ if (end.isInYearMonthDayFormat()) {
+ jsonObject.addProperty("end", end.getDateTime());
+ } else {
+ jsonObject.addProperty("end", end.getUnixTime());
+ }
+ }
+ if (dateTimeRange.getAllDay() != null) {
+ jsonObject.addProperty("all_day", dateTimeRange.getAllDay());
+ }
+ if (dateTimeRange.getRecurrence() != null && !dateTimeRange.getRecurrence().isEmpty()) {
+ jsonObject.addProperty("recurrence", dateTimeRange.getRecurrence());
+ }
+
+ return jsonObject;
+ }
+ }
+}
diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/DefaultAction.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/DefaultAction.java
new file mode 100644
index 000000000..1d44f8c07
--- /dev/null
+++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/DefaultAction.java
@@ -0,0 +1,32 @@
+package com.slack.api.model.work_objects;
+
+import com.google.gson.annotations.SerializedName;
+import com.slack.api.model.block.composition.PlainTextObject;
+import com.slack.api.util.annotation.Required;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+import lombok.Value;
+
+@Value
+@Builder
+public class DefaultAction extends PrimaryActions {
+ @Required Type type;
+ String style;
+ PlainTextObject text;
+
+ @Getter
+ @RequiredArgsConstructor
+ public enum Type {
+ @SerializedName("add-to-todo") ADD_TO_TODO("add-to-todo"),
+ @SerializedName("open-in-app") OPEN_IN_APP("open-in-app"),
+ @SerializedName("share-link") SHARE_LINK("share-link"),
+ @SerializedName("copy-link") COPY_LINK("copy-link"),
+ @SerializedName("add-to-list") ADD_TO_LIST("add-to-list"),
+ @SerializedName("save-for-later") SAVE_FOR_LATER("save-for-later"),
+ @SerializedName("remind-me") REMIND_ME("remind-me"),
+ @SerializedName("add-to-folder") ADD_TO_FOLDER("add-to-folder");
+
+ private final String value;
+ }
+}
diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/EntityReference.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/EntityReference.java
new file mode 100644
index 000000000..59eccda1e
--- /dev/null
+++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/EntityReference.java
@@ -0,0 +1,38 @@
+package com.slack.api.model.work_objects;
+
+import com.google.common.base.Preconditions;
+import com.slack.api.model.block.ImageBlock;
+import com.slack.api.util.annotation.Required;
+import com.slack.api.util.predicate.IsValidAppIdPredicate;
+import lombok.Builder;
+import lombok.Value;
+
+@Value
+@Builder
+public class EntityReference {
+ private static final IsValidAppIdPredicate isValidAppId = new IsValidAppIdPredicate();
+
+ @Required
+ String entityId;
+
+ @Required(validator = IsValidAppIdPredicate.class)
+ String appId;
+
+ @Required
+ String entityUrl;
+
+ String displayType;
+
+ @Required
+ String title;
+
+ ImageBlock icon;
+
+ public static class EntityReferenceBuilder {
+ public EntityReferenceBuilder appId(String appId) {
+ Preconditions.checkArgument(isValidAppId.test(appId));
+ this.appId = appId;
+ return this;
+ }
+ }
+}
diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/ExternalUser.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/ExternalUser.java
new file mode 100644
index 000000000..af6a85a2d
--- /dev/null
+++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/ExternalUser.java
@@ -0,0 +1,37 @@
+package com.slack.api.model.work_objects;
+
+import com.slack.api.model.block.ImageBlock;
+import com.slack.api.util.annotation.Required;
+import lombok.Builder;
+import lombok.EqualsAndHashCode;
+import lombok.Value;
+
+@Value
+@EqualsAndHashCode(callSuper = true)
+public class ExternalUser extends User {
+ public static final String USER_TYPE = "external";
+ @Required String text;
+ ImageBlock image;
+ String url;
+ String email;
+ String caption;
+ UserMetadata userMetadata;
+
+ @Builder
+ public ExternalUser(
+ String text,
+ ImageBlock image,
+ String url,
+ String email,
+ String caption,
+ UserMetadata userMetadata
+ ) {
+ super(USER_TYPE);
+ this.text = text;
+ this.image = image;
+ this.url = url;
+ this.email = email;
+ this.caption = caption;
+ this.userMetadata = userMetadata;
+ }
+}
diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/Field.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/Field.java
new file mode 100644
index 000000000..fbef375df
--- /dev/null
+++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/Field.java
@@ -0,0 +1,121 @@
+package com.slack.api.model.work_objects;
+
+import com.google.gson.annotations.SerializedName;
+import com.slack.api.model.block.ImageBlock;
+import com.slack.api.model.block.InputBlock;
+import com.slack.api.model.block.RichTextBlock;
+import com.slack.api.util.annotation.Required;
+import lombok.Builder;
+import lombok.Value;
+
+import java.util.List;
+
+@Value
+@Builder
+public class Field {
+ @Required String type;
+ @Required String label;
+ @Required String fieldType;
+
+ /**
+ * The name of the field from {@link com.slack.api.model.EntityMetadata}.
+ */
+ String fieldName;
+
+ /**
+ * Plain text.
+ */
+ String text;
+
+ /**
+ * Rich text block.
+ */
+ RichTextBlock richText;
+
+ /**
+ * List of Unix timestamps.
+ */
+ List timestamp;
+
+ /**
+ * List of image blocks.
+ */
+ List image;
+
+ /**
+ * Should the field take up the full width.
+ */
+ @SerializedName("long")
+ Boolean isLong;
+
+ /**
+ * List of Slack and external users.
+ */
+ List user;
+
+ /**
+ * List of Slack users.
+ * @deprecated - prefer {@link this#user} field instead since this represents both Slack and external users.
+ */
+ @Deprecated
+ List slackUser;
+
+ List tag;
+
+ InputBlock input;
+
+ /**
+ * Array of input blocks for editing. Used when a field has multiple edit inputs.
+ */
+ List inputs;
+
+ /***
+ * List of "YYYY-MM-DD dates".
+ */
+ List date;
+
+ /**
+ * List of channels.
+ */
+ List channel;
+
+ /**
+ * List of entity references.
+ */
+ List entityRef;
+
+ /**
+ * List of checkbox options.
+ */
+ List checkbox;
+
+ /**
+ * List of email addresses.
+ */
+ List email;
+
+ /**
+ * List of links.
+ */
+ List link;
+
+ /**
+ * Header with optional badge.
+ */
+ HeaderWithBadge headerWithBadge;
+
+ /**
+ * List of files.
+ */
+ List file;
+
+ /**
+ * List of date time ranges.
+ */
+ List dateTimeRange;
+
+ /**
+ * Represents the native value for boolean field types.
+ */
+ Boolean booleanValue;
+}
diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/Fields.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/Fields.java
new file mode 100644
index 000000000..601e2362d
--- /dev/null
+++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/Fields.java
@@ -0,0 +1,26 @@
+package com.slack.api.model.work_objects;
+
+import com.slack.api.util.annotation.Required;
+import com.slack.api.util.predicate.FieldPredicate;
+import java.util.List;
+
+import lombok.Builder;
+import lombok.Value;
+
+import static com.google.common.base.Predicates.instanceOf;
+import static com.google.common.base.Predicates.equalTo;
+
+@Value
+@Builder
+public class Fields {
+ @Required(validator = FieldsTypeValuePredicate.class)
+ String type;
+ @Required List elements;
+
+ public static class FieldsTypeValuePredicate implements FieldPredicate {
+ @Override
+ public boolean validate(Object obj) {
+ return instanceOf(String.class).and(equalTo("fields")).test(obj);
+ }
+ }
+}
diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/File.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/File.java
new file mode 100644
index 000000000..03ddeefe8
--- /dev/null
+++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/File.java
@@ -0,0 +1,35 @@
+package com.slack.api.model.work_objects;
+
+import com.slack.api.util.annotation.Required;
+import com.slack.api.util.predicate.FieldPredicate;
+import lombok.Builder;
+import lombok.Value;
+
+import static com.google.common.base.Predicates.equalTo;
+import static com.google.common.base.Predicates.instanceOf;
+
+@Value
+@Builder
+public class File {
+ @Required(validator = FileTypePredicate.class) String type;
+ @Required String fileId;
+
+ public static class FileTypePredicate implements FieldPredicate {
+ @Override
+ public boolean validate(Object obj) {
+ return instanceOf(String.class).and(equalTo("file")).test(obj);
+ }
+ }
+
+ public static FileBuilder builder() {
+ return new CustomFileBuilder();
+ }
+
+ public static class CustomFileBuilder extends FileBuilder {
+ @Override
+ public File build() {
+ super.type("file");
+ return super.build();
+ }
+ }
+}
diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/FullLayout.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/FullLayout.java
new file mode 100644
index 000000000..dc3a22757
--- /dev/null
+++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/FullLayout.java
@@ -0,0 +1,59 @@
+package com.slack.api.model.work_objects;
+
+import com.slack.api.model.block.InputBlock;
+import com.slack.api.model.work_objects.Title;
+import com.slack.api.util.annotation.Required;
+import com.slack.api.util.predicate.FieldPredicate;
+import lombok.Builder;
+import lombok.Value;
+
+import java.util.List;
+
+import static com.google.common.base.Predicates.instanceOf;
+import static java.util.function.Predicate.isEqual;
+
+@Value
+@Builder
+public class FullLayout {
+ public static final String LAYOUT_TYPE = "full";
+ @Required(validator = FullLayoutTypePredicate.class) String layoutType;
+ Title headerTitle;
+ Title headerSubtitle;
+ /**
+ * A title field on an external work object.
+ */
+ @Required FullLayout.Title title;
+ Subtitle subtitle;
+ /**
+ * When true, at least one of the fields in this schema can be edited by the user.
+ */
+ Boolean editable;
+ Fields fields;
+ Actions actions;
+
+ @Value
+ @Builder
+ public static class Title {
+ /**
+ * Plan text fallback of the field value.
+ */
+ @Required String text;
+ InputBlock input;
+ }
+
+ @Value
+ @Builder
+ public static class Actions {
+ @Required List primaryActions;
+ List primaryActionsMenu;
+ @Required OverflowActions overflowActions;
+ @Required ActionBlockPayload blockPayload;
+ }
+
+ public static class FullLayoutTypePredicate implements FieldPredicate {
+ @Override
+ public boolean validate(Object obj) {
+ return instanceOf(String.class).and(isEqual(LAYOUT_TYPE)).test(obj);
+ }
+ }
+}
diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/HeaderWithBadge.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/HeaderWithBadge.java
new file mode 100644
index 000000000..a71a9687a
--- /dev/null
+++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/HeaderWithBadge.java
@@ -0,0 +1,32 @@
+package com.slack.api.model.work_objects;
+
+import com.slack.api.util.annotation.Required;
+import lombok.Builder;
+import lombok.Value;
+
+@Value
+@Builder
+public class HeaderWithBadge {
+ /**
+ * Plain text displayed as the header.
+ */
+ @Required String text;
+ Badge badge;
+
+ @Value
+ @Builder
+ public static class Badge {
+ /**
+ * Plain text displayed inside the badge.
+ */
+ @Required String text;
+ /**
+ * Color of the text.
+ */
+ String Color;
+ /**
+ * Color of the badge.
+ */
+ String backgroundColor;
+ }
+}
diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/Image.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/Image.java
new file mode 100644
index 000000000..88490e924
--- /dev/null
+++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/Image.java
@@ -0,0 +1,16 @@
+package com.slack.api.model.work_objects;
+
+import com.slack.api.model.block.composition.SlackFileObject;
+import com.slack.api.util.annotation.Required;
+import lombok.Builder;
+import lombok.Value;
+
+@Value
+@Builder
+public class Image {
+ @Required String type;
+ @Required String altText;
+ String imageUrl;
+ String title;
+ SlackFileObject slackFile;
+}
diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/Layouts.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/Layouts.java
new file mode 100644
index 000000000..d3a35cd1e
--- /dev/null
+++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/Layouts.java
@@ -0,0 +1,13 @@
+package com.slack.api.model.work_objects;
+
+import lombok.Builder;
+import lombok.Value;
+
+@Value
+@Builder
+public class Layouts {
+ CompactLayout compact;
+ ExpandedLayout expanded;
+ FullLayout full;
+ MinimalLayout minimal;
+}
diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/LookupFunction.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/LookupFunction.java
new file mode 100644
index 000000000..d1e4e36d5
--- /dev/null
+++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/LookupFunction.java
@@ -0,0 +1,14 @@
+package com.slack.api.model.work_objects;
+
+import com.slack.api.util.annotation.Required;
+import lombok.Builder;
+import lombok.Value;
+
+import java.util.Map;
+
+@Value
+@Builder
+public class LookupFunction {
+ @Required String functionId;
+ @Required Map inputs;
+}
diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/OverflowActions.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/OverflowActions.java
new file mode 100644
index 000000000..1013b037b
--- /dev/null
+++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/OverflowActions.java
@@ -0,0 +1,4 @@
+package com.slack.api.model.work_objects;
+
+public class OverflowActions {
+}
diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/PrimaryActions.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/PrimaryActions.java
new file mode 100644
index 000000000..a04dec710
--- /dev/null
+++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/PrimaryActions.java
@@ -0,0 +1,6 @@
+package com.slack.api.model.work_objects;
+
+/**
+ * Primary action buttons that appear in a work object's view. These can either be {@link Button} or {@link DefaultAction}.
+ */
+abstract public class PrimaryActions {}
diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/SlackUser.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/SlackUser.java
new file mode 100644
index 000000000..dbfdd07f4
--- /dev/null
+++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/SlackUser.java
@@ -0,0 +1,33 @@
+package com.slack.api.model.work_objects;
+
+import com.google.common.base.Preconditions;
+import com.slack.api.util.annotation.Required;
+import com.slack.api.util.predicate.IsValidUserIdPredicate;
+import lombok.Builder;
+import lombok.EqualsAndHashCode;
+import lombok.Value;
+
+import static com.slack.api.util.predicate.IsValidUserIdPredicate.USER_ID_REGEX;
+
+@Value
+@EqualsAndHashCode(callSuper = true)
+public class SlackUser extends User {
+ public static final String USER_TYPE = "slack";
+ private static final IsValidUserIdPredicate isValidUserId = new IsValidUserIdPredicate();
+
+ @Required(validator = IsValidUserIdPredicate.class)
+ String userId;
+
+ UserMetadata metadata;
+
+ @Builder
+ public SlackUser(String userId, UserMetadata metadata) {
+ super(USER_TYPE);
+ Preconditions.checkArgument(
+ isValidUserId.test(userId),
+ String.format("Invalid slack userId %s, required format %s", userId, USER_ID_REGEX)
+ );
+ this.userId = userId;
+ this.metadata = metadata;
+ }
+}
diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/Subtitle.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/Subtitle.java
new file mode 100644
index 000000000..53453041a
--- /dev/null
+++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/Subtitle.java
@@ -0,0 +1,14 @@
+package com.slack.api.model.work_objects;
+
+import com.slack.api.model.block.ImageBlock;
+import com.slack.api.util.annotation.Required;
+import lombok.Builder;
+import lombok.Value;
+
+@Value
+@Builder
+public class Subtitle {
+ @Required String text;
+ String url;
+ ImageBlock image;
+}
diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/Tag.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/Tag.java
new file mode 100644
index 000000000..713526a55
--- /dev/null
+++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/Tag.java
@@ -0,0 +1,15 @@
+package com.slack.api.model.work_objects;
+
+import com.slack.api.model.work_objects.external.TagColor;
+import com.slack.api.util.annotation.Required;
+import lombok.Builder;
+import lombok.Value;
+
+@Value
+@Builder
+public class Tag {
+ @Required String text;
+ TagColor color;
+ String link;
+ String iconUrl;
+}
diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/Title.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/Title.java
new file mode 100644
index 000000000..e9eb32c8d
--- /dev/null
+++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/Title.java
@@ -0,0 +1,12 @@
+package com.slack.api.model.work_objects;
+
+
+import com.slack.api.util.annotation.Required;
+import lombok.Builder;
+import lombok.Value;
+
+@Value
+@Builder
+public class Title {
+ @Required String text;
+}
diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/UnknownAction.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/UnknownAction.java
new file mode 100644
index 000000000..c267bc54e
--- /dev/null
+++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/UnknownAction.java
@@ -0,0 +1,6 @@
+package com.slack.api.model.work_objects;
+
+import lombok.NoArgsConstructor;
+
+@NoArgsConstructor
+public class UnknownAction extends PrimaryActions {}
diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/UnknownUser.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/UnknownUser.java
new file mode 100644
index 000000000..bce08422f
--- /dev/null
+++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/UnknownUser.java
@@ -0,0 +1,14 @@
+package com.slack.api.model.work_objects;
+
+import lombok.Builder;
+
+public class UnknownUser extends User {
+ public UnknownUser() {
+ super("unknown");
+ }
+
+ @Builder
+ public UnknownUser(String userType) {
+ super(userType);
+ }
+}
diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/User.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/User.java
new file mode 100644
index 000000000..90ce1a09d
--- /dev/null
+++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/User.java
@@ -0,0 +1,33 @@
+package com.slack.api.model.work_objects;
+
+import com.slack.api.util.annotation.Required;
+import com.slack.api.util.predicate.FieldPredicate;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+import static com.google.common.base.Predicates.equalTo;
+import static com.google.common.base.Predicates.instanceOf;
+import static com.google.common.base.Predicates.or;
+
+@Getter
+@RequiredArgsConstructor(access = AccessLevel.PROTECTED)
+public abstract class User {
+ @Required(validator = IsValidUserTypePredicate.class)
+ protected final String userType;
+
+ public boolean isExternalUser() {
+ return getUserType().equals("external");
+ }
+
+ public boolean isSlackUser() {
+ return getUserType().equals("slack");
+ }
+
+ public static class IsValidUserTypePredicate implements FieldPredicate {
+ @Override
+ public boolean validate(Object obj) {
+ return instanceOf(String.class).and(or(equalTo("external"), equalTo("slack"))).test(obj);
+ }
+ }
+}
diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/UserMetadata.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/UserMetadata.java
new file mode 100644
index 000000000..9b98a3c3a
--- /dev/null
+++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/UserMetadata.java
@@ -0,0 +1,20 @@
+package com.slack.api.model.work_objects;
+
+import com.slack.api.model.block.ImageBlock;
+import com.slack.api.util.annotation.Required;
+import lombok.Builder;
+import lombok.Value;
+
+@Value
+@Builder
+public class UserMetadata {
+ String role;
+ OverlayIcon overlayIcon;
+
+ @Value
+ @Builder
+ public static class OverlayIcon {
+ @Required String iconName;
+ ImageBlock icon;
+ }
+}
diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/WorkObjectUnfurl.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/WorkObjectUnfurl.java
new file mode 100644
index 000000000..60cd71542
--- /dev/null
+++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/WorkObjectUnfurl.java
@@ -0,0 +1,48 @@
+package com.slack.api.model.work_objects;
+
+import com.slack.api.model.work_objects.external.FullSizePreview;
+import com.slack.api.model.work_objects.external.WorkObjectEntityType;
+import com.slack.api.util.annotation.Required;
+import com.slack.api.util.predicate.FieldPredicate;
+import com.slack.api.util.predicate.IsValidAppIdPredicate;
+import lombok.Builder;
+import lombok.Value;
+
+import static com.google.common.base.Predicates.instanceOf;
+import static java.util.function.Predicate.isEqual;
+
+@Value
+@Builder
+public class WorkObjectUnfurl {
+ @Required(validator = WorkObjectTypePredicate.class) String workObjectType;
+ @Required String externalUrl;
+ @Required String entityId;
+ String relatedConversationsEntityId;
+ @Required(validator = IsValidAppIdPredicate.class) String appId;
+ String appName;
+ AppIcons appIcons;
+ String productName;
+ LookupFunction lookupFunction;
+ String authProviderKey;
+ String displayType;
+ Integer ts;
+ Layouts layouts;
+ WorkObjectEntityType workObjectEntityType;
+ FullSizePreview fullSizePreview;
+ String slackFileId;
+ User perspectiveUser;
+ CommentMetadata comments;
+
+ @Value
+ @Builder
+ public static class CommentMetadata {
+ @Required Integer count;
+ }
+
+ public static class WorkObjectTypePredicate implements FieldPredicate {
+ @Override
+ public boolean validate(Object obj) {
+ return instanceOf(String.class).and(isEqual("external")).test(obj);
+ }
+ }
+}
diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/external/ButtonProcessingState.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/external/ButtonProcessingState.java
new file mode 100644
index 000000000..96494c4a3
--- /dev/null
+++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/external/ButtonProcessingState.java
@@ -0,0 +1,12 @@
+package com.slack.api.model.work_objects.external;
+
+import com.slack.api.util.annotation.Required;
+import lombok.Builder;
+import lombok.Value;
+
+@Value
+@Builder
+public class ButtonProcessingState {
+ @Required Boolean enabled;
+ String interstitialText;
+}
diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/external/FullSizePreview.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/external/FullSizePreview.java
new file mode 100644
index 000000000..19a70135e
--- /dev/null
+++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/external/FullSizePreview.java
@@ -0,0 +1,24 @@
+package com.slack.api.model.work_objects.external;
+
+import com.slack.api.util.annotation.Required;
+import lombok.Builder;
+import lombok.Value;
+
+@Value
+@Builder
+public class FullSizePreview {
+ @Required Boolean isSupported;
+ String previewUrl;
+ Boolean isAnimated;
+ String width;
+ String height;
+ String mimeType;
+ Error error;
+
+ @Value
+ @Builder
+ public static class Error {
+ @Required String code;
+ String message;
+ }
+}
diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/external/TagColor.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/external/TagColor.java
new file mode 100644
index 000000000..b26eaac1d
--- /dev/null
+++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/external/TagColor.java
@@ -0,0 +1,22 @@
+package com.slack.api.model.work_objects.external;
+
+import com.google.gson.annotations.SerializedName;
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+@Getter
+@RequiredArgsConstructor
+public enum TagColor {
+ @SerializedName("flamingo") FLAMINGO("flamingo"),
+ @SerializedName("honeycomb") HONEYCOMB("honeycomb"),
+ @SerializedName("grass") GRASS("grass"),
+ @SerializedName("gray") GREY("gray"),
+ @SerializedName("informative") INFORMATIVE("informative"),
+ @SerializedName("indigo") INDIGO("indigo"),
+ @SerializedName("lagooon") LAGOON("lagoon"),
+ @SerializedName("jade") JADE("jade"),
+ @SerializedName("horchata") HORCHATA("horchata"),
+ @SerializedName("aubergine") AUBERGINE("aubergine");
+
+ private final String color;
+}
diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/external/WorkObjectEntityType.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/external/WorkObjectEntityType.java
new file mode 100644
index 000000000..1003979a5
--- /dev/null
+++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/external/WorkObjectEntityType.java
@@ -0,0 +1,19 @@
+package com.slack.api.model.work_objects.external;
+
+import com.google.gson.annotations.SerializedName;
+import lombok.RequiredArgsConstructor;
+import lombok.Getter;
+
+@Getter
+@RequiredArgsConstructor
+public enum WorkObjectEntityType {
+ @SerializedName("slack#/entities/task") TASK("slack#/entities/task"),
+ @SerializedName("slack#/entities/file") FILE("slack#/entities/file"),
+ @SerializedName("slack#/entities/item") ITEM("slack#/entities/item"),
+ @SerializedName("slack#/entities/incident") INCIDENT("slack#/entities/incident"),
+ @SerializedName("slack#/entities/content_item") CONTENT_ITEM("slack#/entities/content_item"),
+ @SerializedName("slack#/entities/tableau_analytics") TABLEAU_ANALYTICS("slack#/entities/tableau_analytics"),
+ @SerializedName("slack#/entities/calendar_event") CALENDAR_EVENT("slack#/entities/calendar_event");
+
+ private final String type;
+}
diff --git a/slack-api-model/src/main/java/com/slack/api/util/json/GsonWorkObjectDateTimeDeserializer.java b/slack-api-model/src/main/java/com/slack/api/util/json/GsonWorkObjectDateTimeDeserializer.java
new file mode 100644
index 000000000..44b59ceb8
--- /dev/null
+++ b/slack-api-model/src/main/java/com/slack/api/util/json/GsonWorkObjectDateTimeDeserializer.java
@@ -0,0 +1,88 @@
+package com.slack.api.util.json;
+
+import com.google.gson.JsonDeserializationContext;
+import com.google.gson.JsonDeserializer;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParseException;
+import com.google.gson.JsonPrimitive;
+import com.slack.api.model.work_objects.DateTimeRange;
+import com.slack.api.model.work_objects.DateTimeRange.DateTimeRangeBuilder;
+import com.slack.api.model.work_objects.DateTimeRange.DateTimeImpl;
+import lombok.RequiredArgsConstructor;
+
+import java.lang.reflect.Type;
+
+/**
+ * Factory for serializing and deserializing {@link com.slack.api.model.work_objects.DateTimeRange} objects, namely
+ * to handle start and end time inputs that are allowed to be in unix time OR YYYY-MM-DD format.
+ */
+@RequiredArgsConstructor
+public class GsonWorkObjectDateTimeDeserializer implements JsonDeserializer {
+ private final boolean failOnUnknownProperties;
+
+ @Override
+ public DateTimeRange deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context)
+ throws JsonParseException {
+ final JsonObject jsonObject = json.getAsJsonObject();
+ final boolean isAllDay = jsonObject.has("all_day") && jsonObject.get("all_day").getAsBoolean();
+ final DateTimeRangeBuilder builder = DateTimeRange.builder().allDay(isAllDay);
+
+ // If we have an all-day event, and the start/end times haven't been provided, set them to the start/end
+ // of the today
+ if (isAllDay) {
+ if (!jsonObject.has("start")) {
+ builder.start(DateTimeImpl.atStartOfDay());
+ }
+ if (!jsonObject.has("end")) {
+ builder.end(DateTimeImpl.atEndOfDay());
+ }
+ }
+
+ // If we don't have an all-day event, we need both start and end dates
+ if (!isAllDay && (!jsonObject.has("start") || !jsonObject.has("end"))) {
+ if (failOnUnknownProperties) {
+ throw new JsonParseException("DateTimeRange object missing start and/or end times");
+ }
+
+ // We can't really do anything in this case, just return null
+ return null;
+ }
+
+ if (jsonObject.has("start")) {
+ JsonPrimitive startDate = jsonObject.getAsJsonPrimitive("start");
+ builder.start(getDateTime(startDate));
+ }
+ if (jsonObject.has("end")) {
+ JsonPrimitive endDate = jsonObject.getAsJsonPrimitive("end");
+ builder.end(getDateTime(endDate));
+ }
+ if (jsonObject.has("recurrence") && !jsonObject.get("recurrence").isJsonNull()) {
+ try {
+ builder.recurrence(jsonObject.getAsJsonPrimitive("recurrence").getAsString());
+ } catch (AssertionError e) {
+ if (failOnUnknownProperties) {
+ throw new JsonParseException(e);
+ }
+ }
+ }
+
+ return builder.build();
+ }
+
+ private DateTimeImpl getDateTime(JsonPrimitive prim) {
+ try {
+ if (prim.isNumber()) {
+ return DateTimeImpl.from(prim.getAsLong());
+ } else if (prim.isString()) {
+ return DateTimeImpl.from(prim.getAsString());
+ }
+ throw new IllegalArgumentException("Unrecognized JSON primitive type");
+ } catch (IllegalArgumentException e) {
+ if (failOnUnknownProperties) {
+ throw new JsonParseException(e);
+ }
+ return DateTimeImpl.unknown();
+ }
+ }
+}
diff --git a/slack-api-model/src/main/java/com/slack/api/util/json/GsonWorkObjectPrimaryActionsFactory.java b/slack-api-model/src/main/java/com/slack/api/util/json/GsonWorkObjectPrimaryActionsFactory.java
new file mode 100644
index 000000000..9aea151f4
--- /dev/null
+++ b/slack-api-model/src/main/java/com/slack/api/util/json/GsonWorkObjectPrimaryActionsFactory.java
@@ -0,0 +1,65 @@
+package com.slack.api.util.json;
+
+import com.google.gson.JsonDeserializationContext;
+import com.google.gson.JsonDeserializer;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParseException;
+import com.google.gson.JsonPrimitive;
+import com.google.gson.JsonSerializationContext;
+import com.google.gson.JsonSerializer;
+import com.slack.api.model.work_objects.Button;
+import com.slack.api.model.work_objects.DefaultAction;
+import com.slack.api.model.work_objects.PrimaryActions;
+import com.slack.api.model.work_objects.UnknownAction;
+import lombok.RequiredArgsConstructor;
+
+import java.lang.reflect.Type;
+
+/**
+ * Factory for serializing and deserializing work object {@link PrimaryActions} into their appropriate concrete types,
+ * namely {@link Button} and {@link DefaultAction}.
+ *
+ * The discriminator is the {@code "type"} field in the JSON object. A value of {@code "button"} maps to
+ * {@link Button}, while any other value is treated as a {@link DefaultAction}.
+ */
+@RequiredArgsConstructor
+public class GsonWorkObjectPrimaryActionsFactory implements JsonDeserializer, JsonSerializer {
+ private final boolean failOnUnknownProperties;
+
+ @Override
+ public PrimaryActions deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context)
+ throws JsonParseException {
+ final JsonObject jsonObject = json.getAsJsonObject();
+ final JsonPrimitive typePrimitive = jsonObject.getAsJsonPrimitive("type");
+ if (typePrimitive == null) {
+ if (failOnUnknownProperties) {
+ throw new JsonParseException("Missing 'type' field in PrimaryActions JSON object");
+ }
+ return new UnknownAction();
+ }
+ final String type = typePrimitive.getAsString();
+ try {
+ return context.deserialize(jsonObject, getClassForType(type));
+ } catch (JsonParseException e) {
+ if (failOnUnknownProperties) {
+ throw e;
+ }
+
+ return new UnknownAction();
+ }
+ }
+
+ @Override
+ public JsonElement serialize(PrimaryActions src, Type typeOfSrc, JsonSerializationContext context) {
+ return context.serialize(src);
+ }
+
+ private Class extends PrimaryActions> getClassForType(String type) {
+ if ("button".equals(type)) {
+ return Button.class;
+ }
+
+ return DefaultAction.class;
+ }
+}
diff --git a/slack-api-model/src/main/java/com/slack/api/util/json/GsonWorkObjectUserFactory.java b/slack-api-model/src/main/java/com/slack/api/util/json/GsonWorkObjectUserFactory.java
new file mode 100644
index 000000000..c7db58437
--- /dev/null
+++ b/slack-api-model/src/main/java/com/slack/api/util/json/GsonWorkObjectUserFactory.java
@@ -0,0 +1,52 @@
+package com.slack.api.util.json;
+
+import com.google.gson.JsonDeserializationContext;
+import com.google.gson.JsonDeserializer;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParseException;
+import com.google.gson.JsonSerializationContext;
+import com.google.gson.JsonSerializer;
+import com.slack.api.model.work_objects.ExternalUser;
+import com.slack.api.model.work_objects.SlackUser;
+import com.slack.api.model.work_objects.UnknownUser;
+import com.slack.api.model.work_objects.User;
+import lombok.RequiredArgsConstructor;
+
+import java.lang.reflect.Type;
+
+/**
+ * Factory for serializing and deserializing work object {@link User} into their appropriate concrete types, namely
+ * {@link com.slack.api.model.work_objects.SlackUser} and {@link com.slack.api.model.work_objects.ExternalUser}.
+ */
+@RequiredArgsConstructor
+public class GsonWorkObjectUserFactory implements JsonDeserializer, JsonSerializer {
+ private final boolean failOnUnknownProperties;
+
+ @Override
+ public User deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context)
+ throws JsonParseException {
+ final JsonObject jsonObject = json.getAsJsonObject();
+ final String userType = jsonObject.getAsJsonPrimitive("user_type").getAsString();
+ return context.deserialize(jsonObject, getUserClassForType(userType));
+ }
+
+ @Override
+ public JsonElement serialize(User src, Type typeOfSrc, JsonSerializationContext context) {
+ return context.serialize(src);
+ }
+
+ private Class extends User> getUserClassForType(String userType) {
+ switch (userType) {
+ case SlackUser.USER_TYPE:
+ return SlackUser.class;
+ case ExternalUser.USER_TYPE:
+ return ExternalUser.class;
+ default:
+ if (failOnUnknownProperties) {
+ throw new JsonParseException("User type " + userType + " is not recognized");
+ }
+ return UnknownUser.class;
+ }
+ }
+}
diff --git a/slack-api-model/src/main/java/com/slack/api/util/json/RequiredPropertyDetectionAdapterFactory.java b/slack-api-model/src/main/java/com/slack/api/util/json/RequiredPropertyDetectionAdapterFactory.java
index 41a17ed88..c49f6fc0c 100644
--- a/slack-api-model/src/main/java/com/slack/api/util/json/RequiredPropertyDetectionAdapterFactory.java
+++ b/slack-api-model/src/main/java/com/slack/api/util/json/RequiredPropertyDetectionAdapterFactory.java
@@ -66,17 +66,19 @@ public T read(JsonReader in) throws IOException {
*/
private List buildRequiredFieldEntries(Class> clazz) {
List entries = new ArrayList<>();
- for (Field field : clazz.getDeclaredFields()) {
- Required annotation = field.getAnnotation(Required.class);
- if (annotation != null) {
- field.setAccessible(true);
- try {
- FieldPredicate predicate = annotation.validator().getDeclaredConstructor().newInstance();
- entries.add(new RequiredFieldEntry(field, predicate));
- } catch (NoSuchMethodException | InstantiationException |
- IllegalAccessException | InvocationTargetException e) {
- throw new JsonParseException(
- "Cannot instantiate validator for field: " + field.getName(), e);
+ for (Class> current = clazz; current != null && current != Object.class; current = current.getSuperclass()) {
+ for (Field field : current.getDeclaredFields()) {
+ Required annotation = field.getAnnotation(Required.class);
+ if (annotation != null) {
+ field.setAccessible(true);
+ try {
+ FieldPredicate predicate = annotation.validator().getDeclaredConstructor().newInstance();
+ entries.add(new RequiredFieldEntry(field, predicate));
+ } catch (NoSuchMethodException | InstantiationException |
+ IllegalAccessException | InvocationTargetException e) {
+ throw new JsonParseException(
+ "Cannot instantiate validator for field: " + field.getName(), e);
+ }
}
}
}
diff --git a/slack-api-model/src/main/java/com/slack/api/util/predicate/IsValidAppIdPredicate.java b/slack-api-model/src/main/java/com/slack/api/util/predicate/IsValidAppIdPredicate.java
new file mode 100644
index 000000000..ea55c5854
--- /dev/null
+++ b/slack-api-model/src/main/java/com/slack/api/util/predicate/IsValidAppIdPredicate.java
@@ -0,0 +1,20 @@
+package com.slack.api.util.predicate;
+
+import java.util.function.Predicate;
+import java.util.regex.Pattern;
+
+import static com.google.common.base.Predicates.instanceOf;
+
+public class IsValidAppIdPredicate implements FieldPredicate, Predicate {
+ public static final Pattern APP_ID_REGEX = Pattern.compile("^A[A-Z0-9]+$");
+
+ @Override
+ public boolean test(String t) {
+ return APP_ID_REGEX.matcher(t).matches();
+ }
+
+ @Override
+ public boolean validate(Object obj) {
+ return instanceOf(String.class).and(o -> test((String) o)).test(obj);
+ }
+}
diff --git a/slack-api-model/src/main/java/com/slack/api/util/predicate/IsValidChannelIdPredicate.java b/slack-api-model/src/main/java/com/slack/api/util/predicate/IsValidChannelIdPredicate.java
new file mode 100644
index 000000000..90e4710d3
--- /dev/null
+++ b/slack-api-model/src/main/java/com/slack/api/util/predicate/IsValidChannelIdPredicate.java
@@ -0,0 +1,23 @@
+package com.slack.api.util.predicate;
+
+import java.util.function.Predicate;
+import java.util.regex.Pattern;
+
+import static com.google.common.base.Predicates.instanceOf;
+
+/**
+ * Predicate for validating that a given ID conforms to Slack's channel ID format.
+ */
+public class IsValidChannelIdPredicate implements FieldPredicate, Predicate {
+ public static final Pattern CHANNEL_ID_REGEX = Pattern.compile("^C[A-Z0-9]{2,}$");
+
+ @Override
+ public boolean test(String t) {
+ return CHANNEL_ID_REGEX.matcher(t).matches();
+ }
+
+ @Override
+ public boolean validate(Object obj) {
+ return instanceOf(String.class).and(o -> test((String) o)).test(obj);
+ }
+}
diff --git a/slack-api-model/src/main/java/com/slack/api/util/predicate/IsValidUserIdPredicate.java b/slack-api-model/src/main/java/com/slack/api/util/predicate/IsValidUserIdPredicate.java
new file mode 100644
index 000000000..343c960df
--- /dev/null
+++ b/slack-api-model/src/main/java/com/slack/api/util/predicate/IsValidUserIdPredicate.java
@@ -0,0 +1,22 @@
+package com.slack.api.util.predicate;
+
+import java.util.function.Predicate;
+import java.util.regex.Pattern;
+import static com.google.common.base.Predicates.instanceOf;
+
+/**
+ * Predicate for validating that a given ID conforms to Slack's user ID format.
+ */
+public final class IsValidUserIdPredicate implements FieldPredicate, Predicate {
+ public static final Pattern USER_ID_REGEX = Pattern.compile("^[WU][A-Z0-9]{8,}$");
+
+ @Override
+ public boolean test(String t) {
+ return USER_ID_REGEX.matcher(t).matches();
+ }
+
+ @Override
+ public boolean validate(Object obj) {
+ return instanceOf(String.class).and(o -> test((String) o)).test(obj);
+ }
+}
diff --git a/slack-api-model/src/test/java/test_locally/api/model/work_objects/DateTimeRangeTest.java b/slack-api-model/src/test/java/test_locally/api/model/work_objects/DateTimeRangeTest.java
new file mode 100644
index 000000000..7a65359df
--- /dev/null
+++ b/slack-api-model/src/test/java/test_locally/api/model/work_objects/DateTimeRangeTest.java
@@ -0,0 +1,92 @@
+package test_locally.api.model.work_objects;
+
+import com.google.gson.Gson;
+import com.google.gson.JsonParseException;
+import com.slack.api.model.work_objects.DateTimeRange;
+import com.slack.api.model.work_objects.DateTimeRange.DateTimeImpl;
+import org.junit.Test;
+import test_locally.unit.GsonFactory;
+
+import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.junit.Assert.*;
+
+public class DateTimeRangeTest {
+ final Gson gson = GsonFactory.createSnakeCaseWithRequiredPropertyDetection();
+
+ @Test
+ public void testJsonSerialization() {
+ // An empty object shout output an empty json object
+ DateTimeRange dateTime = DateTimeRange.builder().build();
+ assertThat(gson.toJson(dateTime), is("{}"));
+
+ // Handles booleans
+ dateTime = DateTimeRange.builder().allDay(true).build();
+ assertThat(gson.toJson(dateTime), is("{\"all_day\":true}"));
+ dateTime = DateTimeRange.builder().allDay(false).build();
+ assertThat(gson.toJson(dateTime), is("{\"all_day\":false}"));
+
+ // Handles the start/end times formatted in YYYY-MM-DD format
+ DateTimeImpl start = DateTimeImpl.from("2026-02-26");
+ DateTimeImpl end = DateTimeImpl.from("2026-02-27");
+ dateTime = DateTimeRange.builder().start(start).end(end).build();
+ assertThat(gson.toJson(dateTime), is("{\"start\":\"2026-02-26\",\"end\":\"2026-02-27\"}"));
+
+ // Handles the start/end times as string inputs in unixtime format
+ start = DateTimeImpl.from("1772215885694");
+ end = DateTimeImpl.from("1772219309359");
+ dateTime = DateTimeRange.builder().start(start).end(end).build();
+ assertThat(gson.toJson(dateTime), is("{\"start\":1772215885694,\"end\":1772219309359}"));
+
+ // Handles the start/end times as integer inputs in unixtime format
+ start = DateTimeImpl.from(1772215885694L);
+ end = DateTimeImpl.from(1772219309359L);
+ dateTime = DateTimeRange.builder().start(start).end(end).build();
+ assertThat(gson.toJson(dateTime), is("{\"start\":1772215885694,\"end\":1772219309359}"));
+ }
+
+ @Test
+ public void throwsOnInvalidDateTimeInput() {
+ assertThrows(IllegalArgumentException.class, () -> DateTimeImpl.from("this just isn't a date"));
+ }
+
+ @Test
+ public void gracefullyHandles_malformedJson_whenFailOnUnknownProperties_isFalse() {
+ // We should return null when the json can't be converted to an actual date time value
+ String jsonMissingStartAndEnd = "{\"all_day\":false}";
+ assertNull(gson.fromJson(jsonMissingStartAndEnd, DateTimeRange.class));
+
+ // Should add the start/end fields if the json just indicates it's an all-day event
+ String allDayOnly = "{\"all_day\":true}";
+ DateTimeRange dateTime = gson.fromJson(allDayOnly, DateTimeRange.class);
+ assertThat(dateTime.getAllDay(), is(true));
+ assertNotNull(dateTime.getStart());
+ assertNotNull(dateTime.getEnd());
+
+ // We should skip adding the recurrence property if it can't be coerced to a string
+ String malformedRecurrence = "{\"all_day\":true, \"recurrence\": null}";
+ dateTime = gson.fromJson(malformedRecurrence, DateTimeRange.class);
+ assertThat(dateTime.getAllDay(), is(true));
+ assertNotNull(dateTime.getStart());
+ assertNotNull(dateTime.getEnd());
+ assertNull(dateTime.getRecurrence());
+
+ // We should use "unknown" start/end values when the input isn't a valid unixtime or date string
+ String malformedStartEnd = "{\"start\":hello, \"end\": world}";
+ dateTime = gson.fromJson(malformedStartEnd, DateTimeRange.class);
+ assertTrue(dateTime.getStart().isUnknown());
+ assertTrue(dateTime.getEnd().isUnknown());
+ }
+
+ @Test
+ public void throws_onInvalidJson_whenFailOnUnknownProperties_isTrue() {
+ final Gson strictGson = GsonFactory.createSnakeCaseWithRequiredPropertyDetection(true);
+
+ // Can't do anything with an empty object
+ assertThrows(JsonParseException.class, () -> strictGson.fromJson("{}", DateTimeRange.class));
+
+ // Should throw when the start/end properties are malformed
+ String badJson = "{\"start\": hello, \"end\": world}";
+ assertThrows(JsonParseException.class, () -> strictGson.fromJson(badJson, DateTimeRange.class));
+ }
+}
diff --git a/slack-api-model/src/test/java/test_locally/api/model/work_objects/UserTest.java b/slack-api-model/src/test/java/test_locally/api/model/work_objects/UserTest.java
new file mode 100644
index 000000000..8a11cf728
--- /dev/null
+++ b/slack-api-model/src/test/java/test_locally/api/model/work_objects/UserTest.java
@@ -0,0 +1,87 @@
+package test_locally.api.model.work_objects;
+
+import com.google.gson.Gson;
+import com.google.gson.JsonParseException;
+import com.slack.api.model.work_objects.ExternalUser;
+import com.slack.api.model.work_objects.SlackUser;
+import com.slack.api.model.work_objects.UnknownUser;
+import com.slack.api.model.work_objects.User;
+import test_locally.unit.GsonFactory;
+import org.junit.Test;
+
+import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.Matchers.equalToIgnoringCase;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.instanceOf;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
+
+public class UserTest {
+ final Gson gson = GsonFactory.createSnakeCaseWithRequiredPropertyDetection();
+
+ @Test
+ public void parseUser_withoutUserType_throwsException() {
+ String json = "{\"user_id\": \"U1234568\"}";
+ assertThrows(JsonParseException.class, () -> gson.fromJson(json, SlackUser.class));
+ }
+
+ @Test
+ public void parseUser_withInvalidUserType_throwsException() {
+ String json = "{\"user_type\": \"test\"}";
+ assertThrows(JsonParseException.class, () -> gson.fromJson(json, SlackUser.class));
+ }
+
+ @Test
+ public void parseExternalUser_withoutText_throwsException() {
+ String json = "{\"user_type\": \"external\"}";
+ JsonParseException e = assertThrows(JsonParseException.class, () -> gson.fromJson(json, ExternalUser.class));
+ assertThat(e.getMessage(), equalToIgnoringCase("Required field 'text' failed validation in ExternalUser using predicate IsNotNullFieldPredicate"));
+ }
+
+ @Test
+ public void parseSlackUser_withoutUserId_throwsException() {
+ String json = "{\"user_type\": \"slack\"}";
+ JsonParseException e = assertThrows(JsonParseException.class, () -> gson.fromJson(json, SlackUser.class));
+ assertThat(e.getMessage(), equalToIgnoringCase("Required field 'userId' failed validation in SlackUser using predicate IsValidUserIdPredicate"));
+ }
+
+ @Test
+ public void parseSlackUser_withInvalidUserId_throwsException() {
+ String json = "{\"user_type\": \"slack\", \"user_id\": \"test\"}";
+ JsonParseException e = assertThrows(JsonParseException.class, () -> gson.fromJson(json, SlackUser.class));
+ assertThat(e.getMessage(), equalToIgnoringCase("Required field 'userId' failed validation in SlackUser using predicate IsValidUserIdPredicate"));
+ }
+
+ @Test
+ public void parseUsers_createsCorrectUserType() {
+ String slackUserJson = "{\"user_type\": \"slack\", \"user_id\": \"U12345678\"}";
+ assertThat(gson.fromJson(slackUserJson, User.class), instanceOf(SlackUser.class));
+
+ String externalUserJson = "{\"user_type\":\"external\", \"text\": \"test\"}";
+ assertThat(gson.fromJson(externalUserJson, User.class), instanceOf(ExternalUser.class));
+ }
+
+ @Test
+ public void parseUsers_withFailurePropertiesOff_returnsUnknownUser() {
+ Gson lenientGson = GsonFactory.createSnakeCase(false, false, false);
+ String badJson = "{\"user_type\": \"something we dont know\"}";
+ User user = lenientGson.fromJson(badJson, User.class);
+ assertThat(user.getUserType(), is("something we dont know"));
+ assertThat(user, instanceOf(UnknownUser.class));
+ }
+
+ @Test
+ public void testUserBuilders() {
+ User slackUser = SlackUser.builder().userId("U12345678").build();
+ User externalUser = ExternalUser.builder().text("test").build();
+ assertTrue(slackUser.isSlackUser());
+ assertFalse(slackUser.isExternalUser());
+ assertTrue(externalUser.isExternalUser());
+ assertFalse(externalUser.isSlackUser());
+ assertThat(slackUser.getUserType(), is("slack"));
+ assertThat(externalUser.getUserType(), is("external"));
+
+ assertThrows(IllegalArgumentException.class, () -> SlackUser.builder().userId("invalid!").build());
+ }
+}
diff --git a/slack-api-model/src/test/java/test_locally/unit/GsonFactory.java b/slack-api-model/src/test/java/test_locally/unit/GsonFactory.java
index 9f6591b02..375aea9fb 100644
--- a/slack-api-model/src/test/java/test_locally/unit/GsonFactory.java
+++ b/slack-api-model/src/test/java/test_locally/unit/GsonFactory.java
@@ -13,6 +13,9 @@
import com.slack.api.model.block.element.RichTextElement;
import com.slack.api.model.event.FunctionExecutedEvent;
import com.slack.api.model.event.MessageChangedEvent;
+import com.slack.api.model.work_objects.DateTimeRange;
+import com.slack.api.model.work_objects.PrimaryActions;
+import com.slack.api.model.work_objects.User;
import com.slack.api.util.json.*;
public class GsonFactory {
@@ -28,7 +31,11 @@ public static Gson createSnakeCaseWithoutUnknownPropertyDetection(boolean failOn
}
public static Gson createSnakeCaseWithRequiredPropertyDetection() {
- return createSnakeCase(false, true, true);
+ return createSnakeCaseWithRequiredPropertyDetection(false);
+ }
+
+ public static Gson createSnakeCaseWithRequiredPropertyDetection(boolean failOnUnknownProperties) {
+ return createSnakeCase(failOnUnknownProperties, true, true);
}
public static Gson createSnakeCase(boolean failOnUnknownProperties, boolean unknownPropertyDetection) {
@@ -54,7 +61,10 @@ public static Gson createSnakeCase(
.registerTypeAdapter(Attachment.VideoHtml.class,
new GsonMessageAttachmentVideoHtmlFactory(failOnUnknownProperties))
.registerTypeAdapter(MessageChangedEvent.PreviousMessage.class,
- new GsonMessageChangedEventPreviousMessageFactory(failOnUnknownProperties));
+ new GsonMessageChangedEventPreviousMessageFactory(failOnUnknownProperties))
+ .registerTypeAdapter(User.class, new GsonWorkObjectUserFactory(failOnUnknownProperties))
+ .registerTypeAdapter(DateTimeRange.class, new GsonWorkObjectDateTimeDeserializer(failOnUnknownProperties))
+ .registerTypeAdapter(PrimaryActions.class, new GsonWorkObjectPrimaryActionsFactory(failOnUnknownProperties));
if (unknownPropertyDetection) {
builder.registerTypeAdapterFactory(new UnknownPropertyDetectionAdapterFactory());