diff --git a/slack-api-client/src/main/java/com/slack/api/util/json/GsonFactory.java b/slack-api-client/src/main/java/com/slack/api/util/json/GsonFactory.java index a823465cc..15dab7a95 100644 --- a/slack-api-client/src/main/java/com/slack/api/util/json/GsonFactory.java +++ b/slack-api-client/src/main/java/com/slack/api/util/json/GsonFactory.java @@ -16,6 +16,7 @@ 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.list.ListRecord; import com.slack.api.model.list.ListView; import java.time.Instant; @@ -87,6 +88,7 @@ public static void registerTypeAdapters(GsonBuilder builder, boolean failOnUnkno .registerTypeAdapter(AppWorkflow.StepInputValueElementDefault.class, new GsonAppWorkflowStepInputValueDefaultFactory(failOnUnknownProps)) .registerTypeAdapter(LogsResponse.DetailsChangedValue.class, new GsonAuditLogsDetailsChangedValueFactory(failOnUnknownProps)) .registerTypeAdapter(LogsResponse.UserIDs.class, new GsonAuditLogsDetailsUserIDsFactory(failOnUnknownProps)) - .registerTypeAdapter(ListView.Grouping.class, new GsonListViewGroupingFactory(failOnUnknownProps)); + .registerTypeAdapter(ListView.Grouping.class, new GsonListViewGroupingFactory(failOnUnknownProps)) + .registerTypeAdapter(ListRecord.MessageRef.class, new GsonListRecordMessageRefFactory(failOnUnknownProps)); } } diff --git a/slack-api-client/src/test/java/test_with_remote_apis/methods/slacklists_Test.java b/slack-api-client/src/test/java/test_with_remote_apis/methods/slacklists_Test.java index cd1271502..b002e2a3e 100644 --- a/slack-api-client/src/test/java/test_with_remote_apis/methods/slacklists_Test.java +++ b/slack-api-client/src/test/java/test_with_remote_apis/methods/slacklists_Test.java @@ -3,6 +3,8 @@ import com.slack.api.Slack; import com.slack.api.methods.SlackApiException; import com.slack.api.methods.response.auth.AuthTestResponse; +import com.slack.api.methods.response.chat.ChatGetPermalinkResponse; +import com.slack.api.methods.response.chat.ChatPostMessageResponse; import com.slack.api.methods.response.slack_lists.SlackListsAccessDeleteResponse; import com.slack.api.methods.response.slack_lists.SlackListsAccessSetResponse; import com.slack.api.methods.response.slack_lists.SlackListsCreateResponse; @@ -148,6 +150,14 @@ public void fullSlackListsWorkflow() throws IOException, SlackApiException { .build()) .build(); + // A message column so the response echoes back the message-reference shape + // ({value, channel_id, ts, thread_ts?}). A message column cannot be primary. + ListColumn relatedMessageCol = ListColumn.builder() + .key("related_message") + .name("Related Message") + .type("message") + .build(); + // create list SlackListsCreateResponse createResponse = slack.methods().slackListsCreate(r -> r .token(botToken) @@ -159,7 +169,7 @@ public void fullSlackListsWorkflow() throws IOException, SlackApiException { .build())) .build())) .build())) - .schema(Arrays.asList(taskNameCol, dueDateCol, estimateCol, ratingCol, statusCol, assigneeCol))); + .schema(Arrays.asList(taskNameCol, dueDateCol, estimateCol, ratingCol, statusCol, assigneeCol, relatedMessageCol))); assertThat(createResponse.getError(), is(nullValue())); assertThat(createResponse.isOk(), is(true)); @@ -174,9 +184,10 @@ public void fullSlackListsWorkflow() throws IOException, SlackApiException { keyToId.put(col.getKey(), col.getId()); }); } - String taskNameColId = keyToId.get("task_name"); - - // set access + String taskNameColId = keyToId.get("task_name"); + String relatedMessageColId = keyToId.get("related_message"); + + // set access SlackListsAccessSetResponse accessSetResponse = slack.methods().slackListsAccessSet(r -> r .token(botToken) .listId(listId) @@ -185,6 +196,24 @@ public void fullSlackListsWorkflow() throws IOException, SlackApiException { assertThat(accessSetResponse.getError(), is(nullValue())); assertThat(accessSetResponse.isOk(), is(true)); + // Post a message and resolve its permalink so the item can reference it in the message + // field. The message field takes an array of permalink URL strings on the request side + // (a MessageRef serializes to its value) and echoes back {value, channel_id, ts, + // thread_ts?} objects on the response side. + ChatPostMessageResponse relatedMessage = slack.methods().chatPostMessage(r -> r + .token(botToken) + .channel(channelId) + .text("Related message for the SlackLists remote test")); + assertThat(relatedMessage.getError(), is(nullValue())); + assertThat(relatedMessage.isOk(), is(true)); + ChatGetPermalinkResponse relatedPermalink = slack.methods().chatGetPermalink(r -> r + .token(botToken) + .channel(channelId) + .messageTs(relatedMessage.getTs())); + assertThat(relatedPermalink.getError(), is(nullValue())); + String relatedMessageUrl = relatedPermalink.getPermalink(); + assertThat(relatedMessageUrl, is(notNullValue())); + // Build initial fields for item creation ListRecord.Field field = ListRecord.Field.builder() .columnId(taskNameColId) @@ -196,12 +225,18 @@ public void fullSlackListsWorkflow() throws IOException, SlackApiException { .build())) .build())) .build(); + // The message field references the posted message by permalink. On the request the + // MessageRef serializes to the permalink string; the response echoes the full reference. + ListRecord.Field messageField = ListRecord.Field.builder() + .columnId(relatedMessageColId) + .message(Arrays.asList(ListRecord.MessageRef.builder().value(relatedMessageUrl).build())) + .build(); // create an item SlackListsItemsCreateResponse createItemResponse = slack.methods().slackListsItemsCreate(r -> r .token(botToken) .listId(listId) - .initialFields(Arrays.asList(field))); + .initialFields(Arrays.asList(field, messageField))); assertThat(createItemResponse.getError(), is(nullValue())); assertThat(createItemResponse.isOk(), is(true)); assertThat(createItemResponse.getItem(), is(notNullValue())); @@ -215,6 +250,16 @@ public void fullSlackListsWorkflow() throws IOException, SlackApiException { assertThat(taskNameField, is(notNullValue())); assertThat(taskNameField.getText(), is("Test task item")); + // The message field should echo back the reference object shape. + ListRecord.Field echoedMessageField = createItemResponse.getItem().getFields().stream() + .filter(f -> relatedMessageColId.equals(f.getColumnId())) + .findFirst() + .orElse(null); + assertThat(echoedMessageField, is(notNullValue())); + assertThat(echoedMessageField.getMessage(), is(notNullValue())); + assertThat(echoedMessageField.getMessage().isEmpty(), is(false)); + assertThat(echoedMessageField.getMessage().get(0).getValue(), is(notNullValue())); + String itemId = createItemResponse.getItem().getId(); assertThat(itemId, is(notNullValue())); diff --git a/slack-api-model/src/main/java/com/slack/api/model/list/ListRecord.java b/slack-api-model/src/main/java/com/slack/api/model/list/ListRecord.java index 951ac828d..2e2b03a77 100644 --- a/slack-api-model/src/main/java/com/slack/api/model/list/ListRecord.java +++ b/slack-api-model/src/main/java/com/slack/api/model/list/ListRecord.java @@ -2,7 +2,6 @@ import com.google.gson.annotations.SerializedName; import com.slack.api.model.File; -import com.slack.api.model.Message; import com.slack.api.model.block.RichTextBlock; import lombok.AllArgsConstructor; import lombok.Builder; @@ -11,7 +10,6 @@ import lombok.NoArgsConstructor; import lombok.experimental.SuperBuilder; -import java.util.Collections; import java.util.List; import java.util.Map; @@ -47,8 +45,12 @@ public static class Field { private String text; @SerializedName("rich_text") private List richText; - private transient List messages; - private Message message; + // The message field is an array of message references, verified against the live + // API (slackLists.items.list and the conversations.replies/history nested + // list_record path both return List<{value, channel_id, ts, thread_ts?}>). + // EXPERIMENTAL: this replaces the earlier Message-typed modeling (#1590), which + // did not match the actual response shape. See MessageRef. + private List message; private List number; private List select; private List date; @@ -62,20 +64,27 @@ public static class Field { private List timestamp; private List link; private List reference; + } - public List getMessages() { - if (messages == null && message != null) { - return Collections.singletonList(message); - } - return messages; - } - - public void setMessages(List messages) { - this.messages = messages; - if (messages != null && !messages.isEmpty()) { - this.message = messages.get(0); - } - } + /** + * Message field reference for Slack Lists items. The API returns the message field as + * an array of these references. Verified against the live API — every element carries + * value/channel_id/ts, and thread_ts is present only when the referenced message is a + * threaded reply. + * EXPERIMENTAL: introduced to replace the earlier Message-typed modeling. + */ + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class MessageRef { + // Permalink URL of the referenced message. + private String value; + @SerializedName("channel_id") + private String channelId; + private String ts; + @SerializedName("thread_ts") + private String threadTs; } /** diff --git a/slack-api-model/src/main/java/com/slack/api/util/json/GsonListRecordMessageRefFactory.java b/slack-api-model/src/main/java/com/slack/api/util/json/GsonListRecordMessageRefFactory.java new file mode 100644 index 000000000..53ea733f6 --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/util/json/GsonListRecordMessageRefFactory.java @@ -0,0 +1,73 @@ +package com.slack.api.util.json; + +import com.google.gson.*; +import com.slack.api.model.list.ListRecord.MessageRef; + +import java.lang.reflect.Type; + +/** + * Direction-aware (de)serialization for the Slack Lists message field element. + * + * The field is asymmetric on the wire (verified against the live API): + *
    + *
  • Request: an array of message permalink URL strings — e.g. {@code "message": ["https://.../p123"]}.
  • + *
  • Response: an array of message reference objects — {@code {"value","channel_id","ts","thread_ts"?}}.
  • + *
+ * + * This adapter lets a single {@code List} model both directions: it serializes a + * MessageRef to its {@code value} (the permalink string) so requests carry the string array the + * API expects, and it deserializes either an object (normal response) or a bare string (defensive) + * back into a MessageRef. + */ +public class GsonListRecordMessageRefFactory implements JsonDeserializer, JsonSerializer { + + private final boolean failOnUnknownProperties; + + public GsonListRecordMessageRefFactory() { + this(false); + } + + public GsonListRecordMessageRefFactory(boolean failOnUnknownProperties) { + this.failOnUnknownProperties = failOnUnknownProperties; + } + + @Override + public MessageRef deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) + throws JsonParseException { + if (json == null || json.isJsonNull()) { + return null; + } + MessageRef ref = new MessageRef(); + if (json.isJsonPrimitive()) { + // Request-shaped or degenerate value: a bare permalink string. + ref.setValue(json.getAsString()); + return ref; + } + if (json.isJsonObject()) { + JsonObject obj = json.getAsJsonObject(); + if (obj.has("value") && !obj.get("value").isJsonNull()) { + ref.setValue(obj.get("value").getAsString()); + } + if (obj.has("channel_id") && !obj.get("channel_id").isJsonNull()) { + ref.setChannelId(obj.get("channel_id").getAsString()); + } + if (obj.has("ts") && !obj.get("ts").isJsonNull()) { + ref.setTs(obj.get("ts").getAsString()); + } + if (obj.has("thread_ts") && !obj.get("thread_ts").isJsonNull()) { + ref.setThreadTs(obj.get("thread_ts").getAsString()); + } + return ref; + } + return null; + } + + @Override + public JsonElement serialize(MessageRef src, Type typeOfSrc, JsonSerializationContext context) { + // The request side expects the message field as an array of permalink URL strings. + if (src == null || src.getValue() == null) { + return JsonNull.INSTANCE; + } + return new JsonPrimitive(src.getValue()); + } +} diff --git a/slack-api-model/src/test/java/test_locally/api/model/list/ListRecordFieldTest.java b/slack-api-model/src/test/java/test_locally/api/model/list/ListRecordFieldTest.java index 8b8ded91b..393b693b1 100644 --- a/slack-api-model/src/test/java/test_locally/api/model/list/ListRecordFieldTest.java +++ b/slack-api-model/src/test/java/test_locally/api/model/list/ListRecordFieldTest.java @@ -1,13 +1,10 @@ package test_locally.api.model.list; import com.google.gson.Gson; -import com.slack.api.model.Message; import com.slack.api.model.list.ListRecord; import org.junit.Test; import test_locally.unit.GsonFactory; -import java.util.List; - import static org.hamcrest.CoreMatchers.*; import static org.hamcrest.MatcherAssert.assertThat; @@ -15,48 +12,29 @@ public class ListRecordFieldTest { private final Gson gson = GsonFactory.createSnakeCase(); - @Test - public void messageAsObject() { - String json = "{\"id\":\"r1\",\"fields\":[{" + - "\"key\":\"k1\"," + "\"message\":{\"text\":\"hello\",\"ts\":\"1.0\"}" + - "}]}"; - ListRecord record = gson.fromJson(json, ListRecord.class); - ListRecord.Field field = record.getFields().get(0); - - // Old API works - assertThat(field.getMessage(), is(notNullValue())); - assertThat(field.getMessage().getText(), is("hello")); - - // New API works - assertThat(field.getMessages(), is(notNullValue())); - assertThat(field.getMessages().size(), is(1)); - - assertThat(field.getMessages().get(0).getText(), is("hello")); - } + // The Lists message field is an array of message references. Verified against the live + // API (slackLists.items.list and the conversations.replies/history nested list_record + // path both return List<{value, channel_id, ts, thread_ts?}>). thread_ts is present only + // for threaded replies. There is no single-object form on these endpoints. @Test - public void messageAsArray() { - String json = "{\"id\":\"r2\",\"fields\":[{" + - "\"key\":\"k1\"," + - "\"message\":[" + - " {\"text\":\"first\",\"ts\":\"1.0\"}," + - " {\"text\":\"second\",\"ts\":\"2.0\"}" + - "]" + - "}]}"; - + public void messageArray() { + String json = "{\"id\":\"r1\",\"fields\":[{" + + "\"key\":\"k1\",\"message\":[" + + "{\"value\":\"https://example.slack.com/archives/C1/p1\",\"channel_id\":\"C1\",\"ts\":\"1.0\"}," + + "{\"value\":\"https://example.slack.com/archives/C1/p2?thread_ts=0.5\",\"channel_id\":\"C1\",\"ts\":\"2.0\",\"thread_ts\":\"0.5\"}" + + "]}]}"; ListRecord record = gson.fromJson(json, ListRecord.class); ListRecord.Field field = record.getFields().get(0); - // Old API returns first assertThat(field.getMessage(), is(notNullValue())); - assertThat(field.getMessage().getText(), is("first")); - - // New API returns all - assertThat(field.getMessages().size(), is(2)); - - assertThat(field.getMessages().get(0).getText(), is("first")); - - assertThat(field.getMessages().get(1).getText(), is("second")); + assertThat(field.getMessage().size(), is(2)); + assertThat(field.getMessage().get(0).getValue(), is("https://example.slack.com/archives/C1/p1")); + assertThat(field.getMessage().get(0).getChannelId(), is("C1")); + assertThat(field.getMessage().get(0).getTs(), is("1.0")); + assertThat(field.getMessage().get(0).getThreadTs(), is(nullValue())); + // thread_ts is populated only for a threaded reply reference + assertThat(field.getMessage().get(1).getThreadTs(), is("0.5")); } @Test @@ -66,17 +44,6 @@ public void messageAbsent() { ListRecord.Field field = record.getFields().get(0); assertThat(field.getMessage(), is(nullValue())); - assertThat(field.getMessages(), is(nullValue())); - } - - @Test - public void messageNull() { - String json = "{\"id\":\"r4\",\"fields\":[{\"key\":\"k1\",\"message\":null}]}"; - ListRecord record = gson.fromJson(json, ListRecord.class); - ListRecord.Field field = record.getFields().get(0); - - assertThat(field.getMessage(), is(nullValue())); - assertThat(field.getMessages(), is(nullValue())); } @Test @@ -85,32 +52,58 @@ public void messageEmptyArray() { ListRecord record = gson.fromJson(json, ListRecord.class); ListRecord.Field field = record.getFields().get(0); - assertThat(field.getMessage(), is(nullValue())); - assertThat(field.getMessages(), is(notNullValue())); - assertThat(field.getMessages().size(), is(0)); + assertThat(field.getMessage(), is(notNullValue())); + assertThat(field.getMessage().size(), is(0)); } @Test - public void serializeSingleMessage() { - String json = "{\"id\":\"r6\",\"fields\":[{\"key\":\"k1\",\"message\":{\"text\":\"hi\"}}]}"; + public void roundTrip() { + String json = "{\"id\":\"r6\",\"fields\":[{\"key\":\"k1\",\"message\":[" + + "{\"value\":\"https://example.slack.com/archives/C1/p1\",\"channel_id\":\"C1\",\"ts\":\"1.0\"}]}]}"; ListRecord record = gson.fromJson(json, ListRecord.class); String output = gson.toJson(record); - // Round-trips cleanly ListRecord reparsed = gson.fromJson(output, ListRecord.class); - assertThat(reparsed.getFields().get(0).getMessage().getText(), is("hi")); + assertThat(reparsed.getFields().get(0).getMessage().get(0).getValue(), + is("https://example.slack.com/archives/C1/p1")); } @Test public void builder() { - Message msg = new Message(); - msg.setText("built"); + ListRecord.MessageRef ref = ListRecord.MessageRef.builder() + .value("https://example.slack.com/archives/C1/p1") + .channelId("C1") + .ts("1.0") + .build(); ListRecord.Field field = ListRecord.Field.builder() .key("k1") - .message(msg) + .message(java.util.Collections.singletonList(ref)) .build(); - assertThat(field.getMessage().getText(), is("built")); + assertThat(field.getMessage().get(0).getValue(), is("https://example.slack.com/archives/C1/p1")); + } + + @Test + public void serializesToPermalinkStrings() { + // The request side of the message field takes an array of permalink URL strings, so a + // MessageRef must serialize to its value (the permalink), not to an object. + ListRecord.Field field = ListRecord.Field.builder() + .columnId("Col1") + .message(java.util.Collections.singletonList( + ListRecord.MessageRef.builder().value("https://example.slack.com/archives/C1/p1").build())) + .build(); + String json = gson.toJson(field); + assertThat(json.contains("\"message\":[\"https://example.slack.com/archives/C1/p1\"]"), is(true)); + } + + @Test + public void deserializesBareStringElements() { + // Defensive: tolerate a request-shaped (bare string) message element on read. + String json = "{\"id\":\"r7\",\"fields\":[{\"key\":\"k1\",\"message\":[\"https://example.slack.com/archives/C1/p1\"]}]}"; + ListRecord record = gson.fromJson(json, ListRecord.class); + ListRecord.Field field = record.getFields().get(0); + assertThat(field.getMessage().size(), is(1)); + assertThat(field.getMessage().get(0).getValue(), is("https://example.slack.com/archives/C1/p1")); } -} \ No newline at end of file +} 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 0d30bab1c..12bbf4aee 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 @@ -14,8 +14,6 @@ import com.slack.api.model.event.FunctionExecutedEvent; import com.slack.api.model.event.MessageChangedEvent; import com.slack.api.util.json.*; -import com.slack.api.model.list.ListRecord; -import com.slack.api.util.json.GsonListRecordFieldFactory; public class GsonFactory { private GsonFactory() { @@ -45,7 +43,8 @@ public static Gson createSnakeCase(boolean failOnUnknownProperties, boolean unkn new GsonMessageAttachmentVideoHtmlFactory(failOnUnknownProperties)) .registerTypeAdapter(MessageChangedEvent.PreviousMessage.class, new GsonMessageChangedEventPreviousMessageFactory(failOnUnknownProperties)) - .registerTypeAdapter(ListRecord.Field.class, new GsonListRecordFieldFactory(failOnUnknownProperties)); + .registerTypeAdapter(com.slack.api.model.list.ListRecord.MessageRef.class, + new GsonListRecordMessageRefFactory(failOnUnknownProperties)); if (unknownPropertyDetection) { return builder.registerTypeAdapterFactory(new UnknownPropertyDetectionAdapterFactory()).create(); diff --git a/slack-api-model/src/test/java/test_locally/util/list/GsonListRecordFieldFactory.java b/slack-api-model/src/test/java/test_locally/util/list/GsonListRecordFieldFactory.java deleted file mode 100644 index 4eabbb4ba..000000000 --- a/slack-api-model/src/test/java/test_locally/util/list/GsonListRecordFieldFactory.java +++ /dev/null @@ -1,59 +0,0 @@ -package com.slack.api.util.json; - -import com.google.gson.*; -import com.slack.api.model.Message; -import com.slack.api.model.list.ListRecord; - -import java.lang.reflect.Type; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; - -public class GsonListRecordFieldFactory implements JsonDeserializer, JsonSerializer { - - static class NormalizedField extends ListRecord.Field { - } - - private final boolean failOnUnknownProperties; - - public GsonListRecordFieldFactory() { - this(false); - } - - public GsonListRecordFieldFactory(boolean failOnUnknownProperties) { - this.failOnUnknownProperties = failOnUnknownProperties; - } - - @Override - public ListRecord.Field deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException { - JsonObject jsonObject = json.getAsJsonObject(); - // The "message" field can be a single object or an array. - // Normalize to array before deserializing. - if (jsonObject.has("message") && jsonObject.get("message").isJsonArray()) { - JsonArray messageArray = jsonObject.getAsJsonArray("message"); - // Store the full array as "messages" and keep first element as "message" - jsonObject.remove("message"); - if (messageArray.size() > 0) { - jsonObject.add("message", messageArray.get(0)); - } - ListRecord.Field field = context.deserialize(jsonObject, NormalizedField.class); - List messages = new ArrayList<>(); - for (JsonElement element : messageArray) { - messages.add(context.deserialize(element, Message.class)); - } - field.setMessages(messages); - return field; - } - // Single object or absent — standard deserialization - ListRecord.Field field = context.deserialize(jsonObject, NormalizedField.class); - if (field.getMessage() != null) { - field.setMessages(Collections.singletonList(field.getMessage())); - } - return field; - } - - @Override - public JsonElement serialize(ListRecord.Field src, Type typeOfSrc, JsonSerializationContext context) { - return context.serialize(src, NormalizedField.class); - } -} \ No newline at end of file