From ed1c26eb2be0183df05dc9448eb519d9014cc404 Mon Sep 17 00:00:00 2001 From: nijeeshjoshy Date: Thu, 15 Oct 2026 00:17:50 +0200 Subject: [PATCH 1/4] feat: support scheduled messages Message.send gains scheduledAt, and its response carries the stored scheduled message. A new ScheduledMessage model lists (query), edits (update), cancels (delete) and sends early (sendNow) the scheduled messages of any user. Event reads scheduled_message and message_id for the scheduled_message.* events. Co-Authored-By: Claude Opus 5.5 --- docs/messages/scheduled_messages.md | 109 +++++ .../io/getstream/chat/java/models/Event.java | 10 + .../getstream/chat/java/models/Message.java | 20 + .../chat/java/models/ScheduledMessage.java | 451 ++++++++++++++++++ .../services/ScheduledMessageService.java | 32 ++ .../java/ScheduledMessageIntegrationTest.java | 131 +++++ .../chat/java/ScheduledMessageTest.java | 185 +++++++ 7 files changed, 938 insertions(+) create mode 100644 docs/messages/scheduled_messages.md create mode 100644 src/main/java/io/getstream/chat/java/models/ScheduledMessage.java create mode 100644 src/main/java/io/getstream/chat/java/services/ScheduledMessageService.java create mode 100644 src/test/java/io/getstream/chat/java/ScheduledMessageIntegrationTest.java create mode 100644 src/test/java/io/getstream/chat/java/ScheduledMessageTest.java diff --git a/docs/messages/scheduled_messages.md b/docs/messages/scheduled_messages.md new file mode 100644 index 000000000..64f0db962 --- /dev/null +++ b/docs/messages/scheduled_messages.md @@ -0,0 +1,109 @@ +Scheduled messages let a user write a message now and have it sent later. A server-side client can schedule, list, edit, cancel and send early the scheduled messages of any user. + +Scheduled messages must be enabled for your app. Until the scheduled message is sent it is visible only to the user who scheduled it (and to your backend); the channel's other members see nothing until the message is sent. + +## Scheduling a Message + +Pass `scheduledAt` to a regular send. The message is not sent: it is stored and sent at that time. The send time must be at least 1 minute and at most the app's maximum lead time (120 days by default) away. + +```java +// Backend SDK +Date in1Hour = new Date(System.currentTimeMillis() + 60 * 60 * 1000); + +MessageSendResponse response = + Message.send("messaging", "general") + .message(MessageRequestObject.builder().text("Good morning!").userId("john").build()) + .scheduledAt(in1Hour) + .request(); + +ScheduledMessage scheduled = response.getScheduledMessage(); +scheduled.getId(); // ID of the scheduled message +scheduled.getStatus(); // "scheduled" +scheduled.getScheduledAt(); // when it will be sent +``` + +`response.getMessage()` holds the message as it will be sent; it is not in the channel yet. Slash commands cannot be scheduled, and `scheduledAt` cannot be combined with `pending`. + +## Listing Scheduled Messages + +Lists one user's scheduled messages across channels: pending ones first, then failed ones, each by `scheduled_at`. `userId` is required. Each item carries a summary of its channel. + +```java +ScheduledMessageQueryResponse page = + ScheduledMessage.query() + .userId("john") + .filter( + ScheduledMessagesFilter.builder() + .cid("messaging:general") // optional: one channel only + .status("scheduled") // optional: scheduled, sending, failed + .build()) + .limit(100) // 1 to 100, defaults to 20 + .request(); + +List items = page.getScheduledMessages(); + +// Next page +if (page.getNext() != null && !page.getNext().isEmpty()) { + ScheduledMessage.query().userId("john").next(page.getNext()).request(); +} +``` + +Only sorting by `scheduled_at` ascending is supported. + +## Editing a Scheduled Message + +Replace the message, move the send time, or both. A new `message` replaces the stored one as a whole; the sender stays the user who scheduled it. + +```java +ScheduledMessage.update(scheduled.getId()) + .message(MessageRequestObject.builder().text("Good morning, everyone!").build()) + .scheduledAt(new Date(System.currentTimeMillis() + 2 * 60 * 60 * 1000)) + .request(); +``` + +## Canceling a Scheduled Message + +Cancels a scheduled message so it is never sent, or removes one that failed. + +```java +ScheduledMessage canceled = ScheduledMessage.delete(scheduled.getId()).request().getScheduledMessage(); +canceled.getStatus(); // "canceled" +``` + +## Sending a Scheduled Message Now + +Moves the send time to now and queues the send. + +```java +ScheduledMessage.sendNow(scheduled.getId()).request(); +``` + +Editing, canceling and sending now return a 409 error while the message is being sent. + +## Statuses and Failures + +| Status | Meaning | +| ----------- | ------------------------------------------------------------------------------- | +| `scheduled` | Waiting for its send time | +| `sending` | Being sent | +| `failed` | The send failed; `getFailureReason()` says why. It can only be deleted | +| `canceled` | Canceled; returned by `delete` | +| `sent` | Sent; reported only by the `scheduled_message.sent` event | + +Failure reasons: `banned`, `no_permission`, `channel_frozen`, `channel_deleted`, `user_deleted`, `moderation_blocked`, `hook_rejected`, `expired`, `internal_error`, `invalid_message`, `pending_not_supported`. + +## Events and Webhooks + +| Event | Webhook | When | +| --------------------------- | ------- | -------------------------------------------------------------------- | +| `scheduled_message.created` | No | A message was scheduled | +| `scheduled_message.updated` | No | A scheduled message was edited or sent now | +| `scheduled_message.deleted` | No | A scheduled message was canceled | +| `scheduled_message.sent` | Yes | A scheduled message was sent; `getMessageId()` is the sent message ID | +| `scheduled_message.failed` | Yes | A scheduled message failed to send | + +`Event.getScheduledMessage()` holds the scheduled message on all of them. The sent message itself also triggers the usual `message.new`. + +## Permissions + +Scheduling needs the `ScheduleMessage` permission (channel capability `schedule-message`) in addition to the permission to send the message. diff --git a/src/main/java/io/getstream/chat/java/models/Event.java b/src/main/java/io/getstream/chat/java/models/Event.java index 8307a2a15..a09d1612a 100644 --- a/src/main/java/io/getstream/chat/java/models/Event.java +++ b/src/main/java/io/getstream/chat/java/models/Event.java @@ -112,6 +112,16 @@ public class Event { @JsonProperty("created_at") private Date createdAt; + /** Set on scheduled_message.* events. */ + @Nullable + @JsonProperty("scheduled_message") + private ScheduledMessage scheduledMessage; + + /** On scheduled_message.sent, the ID of the message that was sent. */ + @Nullable + @JsonProperty("message_id") + private String messageId; + @NotNull @JsonIgnore private Map additionalFields = new HashMap<>(); @JsonAnyGetter diff --git a/src/main/java/io/getstream/chat/java/models/Message.java b/src/main/java/io/getstream/chat/java/models/Message.java index eca2abe32..0a96a25f8 100644 --- a/src/main/java/io/getstream/chat/java/models/Message.java +++ b/src/main/java/io/getstream/chat/java/models/Message.java @@ -806,6 +806,17 @@ public static class MessageSendRequestData { @JsonProperty("pending_message_metadata") private Map pendingMessageMetadata; + /** + * When set, the message is not sent now: it is stored and sent at this time, and the response + * carries {@link MessageSendResponse#getScheduledMessage()}. Must be at least 1 minute and at + * most the app's maximum lead time (120 days by default) away. Requires the scheduled messages + * feature on the app. Cannot be combined with {@code pending}; slash commands cannot be + * scheduled. + */ + @Nullable + @JsonProperty("scheduled_at") + private Date scheduledAt; + public static class MessageSendRequest extends StreamRequest { @NotNull private String channelId; @@ -1405,9 +1416,18 @@ protected Call generateCall(Client client) { @NoArgsConstructor @EqualsAndHashCode(callSuper = true) public static class MessageSendResponse extends StreamResponseObject { + /** + * The sent message. When the request had {@code scheduledAt}, the message as it will be sent; + * it is not in the channel yet. + */ @NotNull @JsonProperty("message") private Message message; + + /** The scheduled message. Set only when the request had {@code scheduledAt}. */ + @Nullable + @JsonProperty("scheduled_message") + private ScheduledMessage scheduledMessage; } @Data diff --git a/src/main/java/io/getstream/chat/java/models/ScheduledMessage.java b/src/main/java/io/getstream/chat/java/models/ScheduledMessage.java new file mode 100644 index 000000000..457e95bc1 --- /dev/null +++ b/src/main/java/io/getstream/chat/java/models/ScheduledMessage.java @@ -0,0 +1,451 @@ +package io.getstream.chat.java.models; + +import com.fasterxml.jackson.annotation.JsonAnyGetter; +import com.fasterxml.jackson.annotation.JsonAnySetter; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.getstream.chat.java.models.Message.Attachment; +import io.getstream.chat.java.models.Message.MessageRequestObject; +import io.getstream.chat.java.models.Message.MessageType; +import io.getstream.chat.java.models.ScheduledMessage.ScheduledMessageQueryRequestData.ScheduledMessageQueryRequest; +import io.getstream.chat.java.models.ScheduledMessage.ScheduledMessageUpdateRequestData.ScheduledMessageUpdateRequest; +import io.getstream.chat.java.models.framework.StreamRequest; +import io.getstream.chat.java.models.framework.StreamResponseObject; +import io.getstream.chat.java.services.ScheduledMessageService; +import io.getstream.chat.java.services.framework.Client; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.RequiredArgsConstructor; +import lombok.Singular; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import retrofit2.Call; + +/** + * A message stored to be sent later. Created by {@link Message#send(String, String)} with {@code + * scheduledAt} set; listed, edited, canceled and sent early with the methods of this class. + * + *

A scheduled message never carries the ID the message will get when sent: {@link #getId()} is + * the ID of the scheduled message, and the {@code scheduled_message.sent} event reports the ID of + * the message that was sent. + */ +@Data +@NoArgsConstructor +public class ScheduledMessage { + /** Waiting for its send time. Can be edited, canceled or sent now. */ + public static final String STATUS_SCHEDULED = "scheduled"; + + /** Being sent. Edit, cancel and send now return 409 in this state. */ + public static final String STATUS_SENDING = "sending"; + + /** Sending failed; {@link #getFailureReason()} says why. Can only be deleted. */ + public static final String STATUS_FAILED = "failed"; + + /** Canceled. Returned by {@link #delete(String)} for a message that was still scheduled. */ + public static final String STATUS_CANCELED = "canceled"; + + /** Sent. Reported by the {@code scheduled_message.sent} event only. */ + public static final String STATUS_SENT = "sent"; + + @NotNull + @JsonProperty("id") + private String id; + + @NotNull + @JsonProperty("cid") + private String cid; + + @Nullable + @JsonProperty("user") + private User user; + + @Nullable + @JsonProperty("message") + private ScheduledMessageContent message; + + @NotNull + @JsonProperty("scheduled_at") + private Date scheduledAt; + + /** One of the {@code STATUS_*} constants. */ + @NotNull + @JsonProperty("status") + private String status; + + /** + * Why sending failed. Set only when the status is {@code failed}. One of: banned, no_permission, + * channel_frozen, channel_deleted, user_deleted, moderation_blocked, hook_rejected, expired, + * internal_error, invalid_message, pending_not_supported. + */ + @Nullable + @JsonProperty("failure_reason") + private String failureReason; + + @Nullable + @JsonProperty("created_at") + private Date createdAt; + + @Nullable + @JsonProperty("updated_at") + private Date updatedAt; + + /** The channel of the scheduled message. Set only in query results. */ + @Nullable + @JsonProperty("channel") + private ScheduledMessageChannel channel; + + @NotNull @JsonIgnore private Map additionalFields = new HashMap<>(); + + @JsonAnyGetter + public Map getAdditionalFields() { + return additionalFields; + } + + @JsonAnySetter + public void setAdditionalField(String name, Object value) { + additionalFields.put(name, value); + } + + /** + * The message a scheduled message will send. It has no ID, and {@code mentioned_users} holds the + * IDs of the mentioned users. + */ + @Data + @NoArgsConstructor + public static class ScheduledMessageContent { + @Nullable + @JsonProperty("text") + private String text; + + @Nullable + @JsonProperty("mml") + private String mml; + + @Nullable + @JsonProperty("html") + private String html; + + @Nullable + @JsonProperty("type") + private MessageType type; + + @Nullable + @JsonProperty("attachments") + private List attachments; + + @Nullable + @JsonProperty("parent_id") + private String parentId; + + @Nullable + @JsonProperty("show_in_channel") + private Boolean showInChannel; + + @Nullable + @JsonProperty("quoted_message_id") + private String quotedMessageId; + + @Nullable + @JsonProperty("mentioned_users") + private List mentionedUsers; + + @Nullable + @JsonProperty("mentioned_roles") + private List mentionedRoles; + + @Nullable + @JsonProperty("mentioned_group_ids") + private List mentionedGroupIds; + + @Nullable + @JsonProperty("mentioned_channel") + private Boolean mentionedChannel; + + @Nullable + @JsonProperty("mentioned_here") + private Boolean mentionedHere; + + @Nullable + @JsonProperty("restricted_visibility") + private List restrictedVisibility; + + @Nullable + @JsonProperty("silent") + private Boolean silent; + + @Nullable + @JsonProperty("pinned") + private Boolean pinned; + + @Nullable + @JsonProperty("pin_expires") + private Date pinExpires; + + @Nullable + @JsonProperty("poll_id") + private String pollId; + + @Nullable + @JsonProperty("shared_location") + private SharedLocation sharedLocation; + + @Nullable + @JsonProperty("custom") + private Map custom; + + @NotNull @JsonIgnore private Map additionalFields = new HashMap<>(); + + @JsonAnyGetter + public Map getAdditionalFields() { + return additionalFields; + } + + @JsonAnySetter + public void setAdditionalField(String name, Object value) { + additionalFields.put(name, value); + } + } + + /** Enough of a channel to list a scheduled message without loading the channel. */ + @Data + @NoArgsConstructor + public static class ScheduledMessageChannel { + @NotNull + @JsonProperty("cid") + private String cid; + + @NotNull + @JsonProperty("type") + private String type; + + @NotNull + @JsonProperty("id") + private String id; + + @Nullable + @JsonProperty("name") + private String name; + + @Nullable + @JsonProperty("image") + private String image; + } + + @Builder + @Getter + @EqualsAndHashCode + public static class ScheduledMessagesFilter { + /** Only scheduled messages of this channel ({@code type:id}). */ + @Nullable + @JsonProperty("cid") + private String cid; + + /** Only these statuses. One or more of: scheduled, sending, failed. */ + @Singular("status") + @Nullable + @JsonProperty("status") + private List status; + } + + @Builder( + builderClassName = "ScheduledMessageUpdateRequest", + builderMethodName = "", + buildMethodName = "internalBuild") + @Getter + @EqualsAndHashCode(callSuper = false) + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class ScheduledMessageUpdateRequestData { + /** + * Replaces the stored message as a whole. The sender stays the user who scheduled the message. + */ + @Nullable + @JsonProperty("message") + private MessageRequestObject message; + + /** + * The new send time. Must be at least 1 minute and at most the app's maximum lead time away. + */ + @Nullable + @JsonProperty("scheduled_at") + private Date scheduledAt; + + public static class ScheduledMessageUpdateRequest + extends StreamRequest { + @NotNull private String id; + + private ScheduledMessageUpdateRequest(@NotNull String id) { + this.id = id; + } + + @Override + protected Call generateCall(Client client) { + return client.create(ScheduledMessageService.class).update(id, this.internalBuild()); + } + } + } + + @Getter + @EqualsAndHashCode(callSuper = false) + @RequiredArgsConstructor + public static class ScheduledMessageDeleteRequest + extends StreamRequest { + @NotNull private String id; + + @Override + protected Call generateCall(Client client) { + return client.create(ScheduledMessageService.class).delete(id); + } + } + + @Getter + @EqualsAndHashCode(callSuper = false) + @RequiredArgsConstructor + public static class ScheduledMessageSendNowRequest + extends StreamRequest { + @NotNull private String id; + + @Override + protected Call generateCall(Client client) { + return client.create(ScheduledMessageService.class).sendNow(id); + } + } + + @Builder( + builderClassName = "ScheduledMessageQueryRequest", + builderMethodName = "", + buildMethodName = "internalBuild") + @Getter + @EqualsAndHashCode(callSuper = false) + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class ScheduledMessageQueryRequestData { + /** The user whose scheduled messages to list. Required. */ + @NotNull + @JsonProperty("user_id") + private String userId; + + @Nullable + @JsonProperty("filter") + private ScheduledMessagesFilter filter; + + /** Only {@code scheduled_at} ascending is supported. */ + @Singular + @Nullable + @JsonProperty("sort") + private List sorts; + + /** Page size, 1 to 100. Defaults to 20. */ + @Nullable + @JsonProperty("limit") + private Integer limit; + + /** Cursor from the previous page. */ + @Nullable + @JsonProperty("next") + private String next; + + public static class ScheduledMessageQueryRequest + extends StreamRequest { + @Override + protected Call generateCall(Client client) { + return client.create(ScheduledMessageService.class).query(this.internalBuild()); + } + } + } + + @Data + @NoArgsConstructor + @EqualsAndHashCode(callSuper = true) + public static class ScheduledMessageUpdateResponse extends StreamResponseObject { + @NotNull + @JsonProperty("scheduled_message") + private ScheduledMessage scheduledMessage; + } + + @Data + @NoArgsConstructor + @EqualsAndHashCode(callSuper = true) + public static class ScheduledMessageDeleteResponse extends StreamResponseObject { + @NotNull + @JsonProperty("scheduled_message") + private ScheduledMessage scheduledMessage; + } + + @Data + @NoArgsConstructor + @EqualsAndHashCode(callSuper = true) + public static class ScheduledMessageSendNowResponse extends StreamResponseObject { + @NotNull + @JsonProperty("scheduled_message") + private ScheduledMessage scheduledMessage; + } + + @Data + @NoArgsConstructor + @EqualsAndHashCode(callSuper = true) + public static class ScheduledMessageQueryResponse extends StreamResponseObject { + /** Pending messages first, then failed ones, each by scheduled_at. */ + @NotNull + @JsonProperty("scheduled_messages") + private List scheduledMessages; + + /** Cursor for the next page; empty on the last page. */ + @Nullable + @JsonProperty("next") + private String next; + } + + /** + * Updates a scheduled message: its message, its send time, or both. Returns 409 while the message + * is being sent. + * + * @param id the scheduled message ID + * @return the created request + */ + @NotNull + public static ScheduledMessageUpdateRequest update(@NotNull String id) { + return new ScheduledMessageUpdateRequest(id); + } + + /** + * Cancels a scheduled message so it is never sent, or removes a failed one. Returns 409 while the + * message is being sent. + * + * @param id the scheduled message ID + * @return the created request + */ + @NotNull + public static ScheduledMessageDeleteRequest delete(@NotNull String id) { + return new ScheduledMessageDeleteRequest(id); + } + + /** + * Moves the send time of a scheduled message to now and queues the send. Returns 409 while the + * message is being sent. + * + * @param id the scheduled message ID + * @return the created request + */ + @NotNull + public static ScheduledMessageSendNowRequest sendNow(@NotNull String id) { + return new ScheduledMessageSendNowRequest(id); + } + + /** + * Lists the scheduled messages of one user across channels: pending ones first, then failed ones, + * each by scheduled_at. {@code userId} is required. + * + * @return the created request + */ + @NotNull + public static ScheduledMessageQueryRequest query() { + return new ScheduledMessageQueryRequest(); + } +} diff --git a/src/main/java/io/getstream/chat/java/services/ScheduledMessageService.java b/src/main/java/io/getstream/chat/java/services/ScheduledMessageService.java new file mode 100644 index 000000000..535115312 --- /dev/null +++ b/src/main/java/io/getstream/chat/java/services/ScheduledMessageService.java @@ -0,0 +1,32 @@ +package io.getstream.chat.java.services; + +import io.getstream.chat.java.models.ScheduledMessage.ScheduledMessageDeleteResponse; +import io.getstream.chat.java.models.ScheduledMessage.ScheduledMessageQueryRequestData; +import io.getstream.chat.java.models.ScheduledMessage.ScheduledMessageQueryResponse; +import io.getstream.chat.java.models.ScheduledMessage.ScheduledMessageSendNowResponse; +import io.getstream.chat.java.models.ScheduledMessage.ScheduledMessageUpdateRequestData; +import io.getstream.chat.java.models.ScheduledMessage.ScheduledMessageUpdateResponse; +import org.jetbrains.annotations.NotNull; +import retrofit2.Call; +import retrofit2.http.Body; +import retrofit2.http.DELETE; +import retrofit2.http.PATCH; +import retrofit2.http.POST; +import retrofit2.http.Path; + +public interface ScheduledMessageService { + @PATCH("scheduled_messages/{id}") + Call update( + @NotNull @Path("id") String id, + @NotNull @Body ScheduledMessageUpdateRequestData scheduledMessageUpdateRequestData); + + @DELETE("scheduled_messages/{id}") + Call delete(@NotNull @Path("id") String id); + + @POST("scheduled_messages/{id}/send_now") + Call sendNow(@NotNull @Path("id") String id); + + @POST("scheduled_messages/query") + Call query( + @NotNull @Body ScheduledMessageQueryRequestData scheduledMessageQueryRequestData); +} diff --git a/src/test/java/io/getstream/chat/java/ScheduledMessageIntegrationTest.java b/src/test/java/io/getstream/chat/java/ScheduledMessageIntegrationTest.java new file mode 100644 index 000000000..776a305cf --- /dev/null +++ b/src/test/java/io/getstream/chat/java/ScheduledMessageIntegrationTest.java @@ -0,0 +1,131 @@ +package io.getstream.chat.java; + +import io.getstream.chat.java.models.Message; +import io.getstream.chat.java.models.Message.MessageRequestObject; +import io.getstream.chat.java.models.Message.MessageSendResponse; +import io.getstream.chat.java.models.ScheduledMessage; +import io.getstream.chat.java.models.ScheduledMessage.ScheduledMessagesFilter; +import java.util.Date; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; + +/** + * Runs against a real app. The scheduled messages endpoints are behind a per-app flag, so these + * tests run only when STREAM_SCHEDULED_MESSAGES_ENABLED=true says the flag is on for the app of + * STREAM_KEY. + */ +@EnabledIfEnvironmentVariable(named = "STREAM_SCHEDULED_MESSAGES_ENABLED", matches = "true") +public class ScheduledMessageIntegrationTest extends BasicTest { + + private static final long ONE_HOUR = 60 * 60 * 1000L; + + private static MessageSendResponse schedule(String text, Date scheduledAt) { + return Assertions.assertDoesNotThrow( + () -> + Message.send(testChannel.getType(), testChannel.getId()) + .message( + MessageRequestObject.builder() + .text(text) + .userId(testUserRequestObject.getId()) + .build()) + .scheduledAt(scheduledAt) + .request()); + } + + @DisplayName("Can schedule a message on behalf of a user") + @Test + void whenSchedulingAMessage_thenResponseCarriesTheScheduledMessage() { + String text = UUID.randomUUID().toString(); + Date scheduledAt = new Date(System.currentTimeMillis() + ONE_HOUR); + + MessageSendResponse response = schedule(text, scheduledAt); + + ScheduledMessage sm = response.getScheduledMessage(); + Assertions.assertNotNull(sm); + Assertions.assertEquals(ScheduledMessage.STATUS_SCHEDULED, sm.getStatus()); + Assertions.assertEquals(testChannel.getType() + ":" + testChannel.getId(), sm.getCid()); + Assertions.assertEquals(testUserRequestObject.getId(), sm.getUser().getId()); + Assertions.assertEquals(text, sm.getMessage().getText()); + Assertions.assertEquals(scheduledAt.getTime() / 1000, sm.getScheduledAt().getTime() / 1000); + } + + @DisplayName("Can query a user's scheduled messages in a channel") + @Test + void whenQueryingScheduledMessages_thenTheScheduledMessageIsListed() { + ScheduledMessage sm = + schedule(UUID.randomUUID().toString(), new Date(System.currentTimeMillis() + ONE_HOUR)) + .getScheduledMessage(); + + List listed = + Assertions.assertDoesNotThrow( + () -> + ScheduledMessage.query() + .userId(testUserRequestObject.getId()) + .filter(ScheduledMessagesFilter.builder().cid(sm.getCid()).build()) + .limit(100) + .request()) + .getScheduledMessages(); + + ScheduledMessage found = + listed.stream().filter(m -> m.getId().equals(sm.getId())).findFirst().orElse(null); + Assertions.assertNotNull(found); + Assertions.assertNotNull(found.getChannel()); + Assertions.assertEquals(testChannel.getId(), found.getChannel().getId()); + } + + @DisplayName("Can update the message and send time of a scheduled message") + @Test + void whenUpdatingAScheduledMessage_thenTheChangeIsReturned() { + ScheduledMessage sm = + schedule(UUID.randomUUID().toString(), new Date(System.currentTimeMillis() + ONE_HOUR)) + .getScheduledMessage(); + Date newTime = new Date(System.currentTimeMillis() + 2 * ONE_HOUR); + + ScheduledMessage updated = + Assertions.assertDoesNotThrow( + () -> + ScheduledMessage.update(sm.getId()) + .message(MessageRequestObject.builder().text("edited").build()) + .scheduledAt(newTime) + .request()) + .getScheduledMessage(); + + Assertions.assertEquals(sm.getId(), updated.getId()); + Assertions.assertEquals("edited", updated.getMessage().getText()); + Assertions.assertEquals(newTime.getTime() / 1000, updated.getScheduledAt().getTime() / 1000); + } + + @DisplayName("Can cancel a scheduled message") + @Test + void whenDeletingAScheduledMessage_thenItIsCanceled() { + ScheduledMessage sm = + schedule(UUID.randomUUID().toString(), new Date(System.currentTimeMillis() + ONE_HOUR)) + .getScheduledMessage(); + + ScheduledMessage deleted = + Assertions.assertDoesNotThrow(() -> ScheduledMessage.delete(sm.getId()).request()) + .getScheduledMessage(); + + Assertions.assertEquals(sm.getId(), deleted.getId()); + Assertions.assertEquals(ScheduledMessage.STATUS_CANCELED, deleted.getStatus()); + } + + @DisplayName("Can send a scheduled message now") + @Test + void whenSendingAScheduledMessageNow_thenItsSendTimeMovesToNow() { + ScheduledMessage sm = + schedule(UUID.randomUUID().toString(), new Date(System.currentTimeMillis() + ONE_HOUR)) + .getScheduledMessage(); + + ScheduledMessage sent = + Assertions.assertDoesNotThrow(() -> ScheduledMessage.sendNow(sm.getId()).request()) + .getScheduledMessage(); + + Assertions.assertEquals(sm.getId(), sent.getId()); + Assertions.assertTrue(sent.getScheduledAt().before(sm.getScheduledAt())); + } +} diff --git a/src/test/java/io/getstream/chat/java/ScheduledMessageTest.java b/src/test/java/io/getstream/chat/java/ScheduledMessageTest.java new file mode 100644 index 000000000..5395f7eae --- /dev/null +++ b/src/test/java/io/getstream/chat/java/ScheduledMessageTest.java @@ -0,0 +1,185 @@ +package io.getstream.chat.java; + +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.PropertyAccessor; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.util.StdDateFormat; +import io.getstream.chat.java.models.Event; +import io.getstream.chat.java.models.Message; +import io.getstream.chat.java.models.Message.MessageRequestObject; +import io.getstream.chat.java.models.Message.MessageSendResponse; +import io.getstream.chat.java.models.ScheduledMessage; +import io.getstream.chat.java.models.ScheduledMessage.ScheduledMessageQueryResponse; +import io.getstream.chat.java.models.ScheduledMessage.ScheduledMessagesFilter; +import io.getstream.chat.java.models.Sort; +import java.util.Date; +import java.util.List; +import java.util.TimeZone; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +public class ScheduledMessageTest { + + // Mirrors the visibility, date and unknown-property configuration of DefaultClient's mapper. + private static final ObjectMapper MAPPER = + new ObjectMapper() + .setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.NONE) + .setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY) + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + .enable(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE) + .setDateFormat( + new StdDateFormat() + .withColonInTimeZone(true) + .withTimeZone(TimeZone.getTimeZone("UTC"))); + + private static final Date SCHEDULED_AT = new Date(1893456000000L); // 2030-01-01T00:00:00Z + private static final String SCHEDULED_AT_JSON = "\"2030-01-01T00:00:00.000+00:00\""; + + private static final String SCHEDULED_MESSAGE_JSON = + "{\"id\":\"sm-1\",\"cid\":\"messaging:general\"," + + "\"user\":{\"id\":\"gandalf\"}," + + "\"message\":{\"text\":\"hello\",\"mentioned_users\":[\"frodo\"]," + + "\"shared_location\":{\"latitude\":1.5,\"longitude\":2.5," + + "\"created_by_device_id\":\"dev\"},\"custom\":{\"color\":\"red\"}}," + + "\"scheduled_at\":\"2030-01-01T00:00:00Z\",\"status\":\"failed\"," + + "\"failure_reason\":\"channel_frozen\"," + + "\"created_at\":\"2029-12-31T00:00:00Z\",\"updated_at\":\"2029-12-31T00:00:00Z\"," + + "\"channel\":{\"cid\":\"messaging:general\",\"type\":\"messaging\"," + + "\"id\":\"general\",\"name\":\"General\"}}"; + + @DisplayName("Send carries scheduled_at when set") + @Test + void whenSendingWithScheduledAt_thenBodyCarriesIt() throws Exception { + String body = + MAPPER.writeValueAsString( + Message.send("messaging", "general") + .message(MessageRequestObject.builder().text("hi").userId("gandalf").build()) + .scheduledAt(SCHEDULED_AT) + .internalBuild()); + + Assertions.assertTrue(body.contains("\"scheduled_at\":" + SCHEDULED_AT_JSON), body); + } + + @DisplayName("Send response reads scheduled_message") + @Test + void whenSendResponseHasScheduledMessage_thenGetterReturnsIt() throws Exception { + MessageSendResponse response = + MAPPER.readValue( + "{\"message\":{\"id\":\"sm-1\",\"text\":\"hello\"},\"scheduled_message\":" + + SCHEDULED_MESSAGE_JSON + + ",\"duration\":\"1ms\"}", + MessageSendResponse.class); + + Assertions.assertEquals("sm-1", response.getScheduledMessage().getId()); + Assertions.assertEquals("hello", response.getMessage().getText()); + } + + @DisplayName("Update sends only the fields that are set") + @Test + void whenUpdatingOnlyScheduledAt_thenMessageIsOmitted() throws Exception { + String body = + MAPPER.writeValueAsString( + ScheduledMessage.update("sm-1").scheduledAt(SCHEDULED_AT).internalBuild()); + + Assertions.assertEquals("{\"scheduled_at\":" + SCHEDULED_AT_JSON + "}", body); + } + + @DisplayName("Update sends the replacement message") + @Test + void whenUpdatingMessage_thenBodyCarriesIt() throws Exception { + String body = + MAPPER.writeValueAsString( + ScheduledMessage.update("sm-1") + .message(MessageRequestObject.builder().text("edited").build()) + .internalBuild()); + + Assertions.assertTrue(body.contains("\"message\":{"), body); + Assertions.assertTrue(body.contains("\"text\":\"edited\""), body); + Assertions.assertFalse(body.contains("scheduled_at"), body); + } + + @DisplayName("Query sends user_id, filter, sort, limit and next") + @Test + void whenQuerying_thenBodyCarriesAllFields() throws Exception { + String body = + MAPPER.writeValueAsString( + ScheduledMessage.query() + .userId("gandalf") + .filter( + ScheduledMessagesFilter.builder() + .cid("messaging:general") + .status(ScheduledMessage.STATUS_SCHEDULED) + .status(ScheduledMessage.STATUS_FAILED) + .build()) + .sort(Sort.builder().field("scheduled_at").direction(Sort.Direction.ASC).build()) + .limit(50) + .next("cursor") + .internalBuild()); + + Assertions.assertTrue(body.contains("\"user_id\":\"gandalf\""), body); + Assertions.assertTrue( + body.contains( + "\"filter\":{\"cid\":\"messaging:general\",\"status\":[\"scheduled\",\"failed\"]}"), + body); + Assertions.assertTrue( + body.contains("\"sort\":[{\"field\":\"scheduled_at\",\"direction\":1}]"), body); + Assertions.assertTrue(body.contains("\"limit\":50"), body); + Assertions.assertTrue(body.contains("\"next\":\"cursor\""), body); + } + + @DisplayName("Query without options sends user_id and no sort") + @Test + void whenQueryingWithoutOptions_thenOnlyUserIdIsSent() throws Exception { + String body = + MAPPER.writeValueAsString(ScheduledMessage.query().userId("gandalf").internalBuild()); + + Assertions.assertEquals("{\"user_id\":\"gandalf\",\"sort\":[]}", body); + } + + @DisplayName("Query response reads every scheduled message field") + @Test + void whenQueryResponseIsRead_thenFieldsAreTyped() throws Exception { + ScheduledMessageQueryResponse response = + MAPPER.readValue( + "{\"scheduled_messages\":[" + + SCHEDULED_MESSAGE_JSON + + "],\"next\":\"n\"," + + "\"duration\":\"1ms\"}", + ScheduledMessageQueryResponse.class); + + Assertions.assertEquals("n", response.getNext()); + List items = response.getScheduledMessages(); + Assertions.assertEquals(1, items.size()); + ScheduledMessage sm = items.get(0); + Assertions.assertEquals("sm-1", sm.getId()); + Assertions.assertEquals("messaging:general", sm.getCid()); + Assertions.assertEquals("gandalf", sm.getUser().getId()); + Assertions.assertEquals(SCHEDULED_AT, sm.getScheduledAt()); + Assertions.assertEquals(ScheduledMessage.STATUS_FAILED, sm.getStatus()); + Assertions.assertEquals("channel_frozen", sm.getFailureReason()); + Assertions.assertEquals("hello", sm.getMessage().getText()); + Assertions.assertEquals(List.of("frodo"), sm.getMessage().getMentionedUsers()); + Assertions.assertEquals(1.5, sm.getMessage().getSharedLocation().getLatitude()); + Assertions.assertEquals("red", sm.getMessage().getCustom().get("color")); + Assertions.assertEquals("General", sm.getChannel().getName()); + Assertions.assertTrue(sm.getAdditionalFields().isEmpty(), sm.getAdditionalFields().toString()); + } + + @DisplayName("scheduled_message.sent events read the scheduled message and the sent message ID") + @Test + void whenEventIsScheduledMessageSent_thenFieldsAreTyped() throws Exception { + Event event = + MAPPER.readValue( + "{\"type\":\"scheduled_message.sent\",\"cid\":\"messaging:general\"," + + "\"message_id\":\"m-1\",\"scheduled_message\":" + + SCHEDULED_MESSAGE_JSON + + "}", + Event.class); + + Assertions.assertEquals("m-1", event.getMessageId()); + Assertions.assertEquals("sm-1", event.getScheduledMessage().getId()); + Assertions.assertFalse(event.getAdditionalFields().containsKey("scheduled_message")); + } +} From 6093ccb3393e76bbec371eff9774617310c929cc Mon Sep 17 00:00:00 2001 From: nijeeshjoshy Date: Thu, 15 Oct 2026 04:01:38 +0200 Subject: [PATCH 2/4] docs: note pending(false) is accepted with scheduledAt Co-Authored-By: Claude Opus 5.5 --- docs/messages/scheduled_messages.md | 2 +- src/main/java/io/getstream/chat/java/models/Message.java | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/messages/scheduled_messages.md b/docs/messages/scheduled_messages.md index 64f0db962..f4ea391e9 100644 --- a/docs/messages/scheduled_messages.md +++ b/docs/messages/scheduled_messages.md @@ -22,7 +22,7 @@ scheduled.getStatus(); // "scheduled" scheduled.getScheduledAt(); // when it will be sent ``` -`response.getMessage()` holds the message as it will be sent; it is not in the channel yet. Slash commands cannot be scheduled, and `scheduledAt` cannot be combined with `pending`. +`response.getMessage()` holds the message as it will be sent; it is not in the channel yet. Slash commands cannot be scheduled, and `scheduledAt` cannot be combined with `pending(true)` (`pending(false)` is accepted). ## Listing Scheduled Messages diff --git a/src/main/java/io/getstream/chat/java/models/Message.java b/src/main/java/io/getstream/chat/java/models/Message.java index 0a96a25f8..f0cd39cad 100644 --- a/src/main/java/io/getstream/chat/java/models/Message.java +++ b/src/main/java/io/getstream/chat/java/models/Message.java @@ -810,8 +810,8 @@ public static class MessageSendRequestData { * When set, the message is not sent now: it is stored and sent at this time, and the response * carries {@link MessageSendResponse#getScheduledMessage()}. Must be at least 1 minute and at * most the app's maximum lead time (120 days by default) away. Requires the scheduled messages - * feature on the app. Cannot be combined with {@code pending}; slash commands cannot be - * scheduled. + * feature on the app. Cannot be combined with {@code pending(true)}; {@code pending(false)} is + * accepted. Slash commands cannot be scheduled. */ @Nullable @JsonProperty("scheduled_at") From 706390216a282bee666c5374e7ec6e832648e39e Mon Sep 17 00:00:00 2001 From: nijeeshjoshy Date: Thu, 15 Oct 2026 04:02:40 +0200 Subject: [PATCH 3/4] docs: drop the editable claim from STATUS_SCHEDULED Co-Authored-By: Claude Opus 5.5 --- .../java/io/getstream/chat/java/models/ScheduledMessage.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/io/getstream/chat/java/models/ScheduledMessage.java b/src/main/java/io/getstream/chat/java/models/ScheduledMessage.java index 457e95bc1..f0db8032c 100644 --- a/src/main/java/io/getstream/chat/java/models/ScheduledMessage.java +++ b/src/main/java/io/getstream/chat/java/models/ScheduledMessage.java @@ -40,7 +40,7 @@ @Data @NoArgsConstructor public class ScheduledMessage { - /** Waiting for its send time. Can be edited, canceled or sent now. */ + /** Waiting for its send time. */ public static final String STATUS_SCHEDULED = "scheduled"; /** Being sent. Edit, cancel and send now return 409 in this state. */ From 7dd8e6ed1b930d6c5d5498ff4d50036a3d7c2fe3 Mon Sep 17 00:00:00 2001 From: nijeeshjoshy Date: Thu, 15 Oct 2026 04:13:27 +0200 Subject: [PATCH 4/4] fix: read scheduled message custom fields from the top level The v1 API returns a scheduled message's custom fields as top-level keys of its message object, as for regular messages, not under "custom". Drop the custom field so they land in getAdditionalFields(), as on Message. Co-Authored-By: Claude Opus 5.5 --- .../io/getstream/chat/java/models/ScheduledMessage.java | 8 ++++---- .../java/io/getstream/chat/java/ScheduledMessageTest.java | 5 +++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/main/java/io/getstream/chat/java/models/ScheduledMessage.java b/src/main/java/io/getstream/chat/java/models/ScheduledMessage.java index f0db8032c..525a6b4df 100644 --- a/src/main/java/io/getstream/chat/java/models/ScheduledMessage.java +++ b/src/main/java/io/getstream/chat/java/models/ScheduledMessage.java @@ -197,10 +197,10 @@ public static class ScheduledMessageContent { @JsonProperty("shared_location") private SharedLocation sharedLocation; - @Nullable - @JsonProperty("custom") - private Map custom; - + /** + * Custom fields of the message. As on {@link Message}, they are top-level keys of the message + * object. + */ @NotNull @JsonIgnore private Map additionalFields = new HashMap<>(); @JsonAnyGetter diff --git a/src/test/java/io/getstream/chat/java/ScheduledMessageTest.java b/src/test/java/io/getstream/chat/java/ScheduledMessageTest.java index 5395f7eae..430358a31 100644 --- a/src/test/java/io/getstream/chat/java/ScheduledMessageTest.java +++ b/src/test/java/io/getstream/chat/java/ScheduledMessageTest.java @@ -15,6 +15,7 @@ import io.getstream.chat.java.models.Sort; import java.util.Date; import java.util.List; +import java.util.Map; import java.util.TimeZone; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.DisplayName; @@ -42,7 +43,7 @@ public class ScheduledMessageTest { + "\"user\":{\"id\":\"gandalf\"}," + "\"message\":{\"text\":\"hello\",\"mentioned_users\":[\"frodo\"]," + "\"shared_location\":{\"latitude\":1.5,\"longitude\":2.5," - + "\"created_by_device_id\":\"dev\"},\"custom\":{\"color\":\"red\"}}," + + "\"created_by_device_id\":\"dev\"},\"color\":\"red\"}," + "\"scheduled_at\":\"2030-01-01T00:00:00Z\",\"status\":\"failed\"," + "\"failure_reason\":\"channel_frozen\"," + "\"created_at\":\"2029-12-31T00:00:00Z\",\"updated_at\":\"2029-12-31T00:00:00Z\"," @@ -162,7 +163,7 @@ void whenQueryResponseIsRead_thenFieldsAreTyped() throws Exception { Assertions.assertEquals("hello", sm.getMessage().getText()); Assertions.assertEquals(List.of("frodo"), sm.getMessage().getMentionedUsers()); Assertions.assertEquals(1.5, sm.getMessage().getSharedLocation().getLatitude()); - Assertions.assertEquals("red", sm.getMessage().getCustom().get("color")); + Assertions.assertEquals(Map.of("color", "red"), sm.getMessage().getAdditionalFields()); Assertions.assertEquals("General", sm.getChannel().getName()); Assertions.assertTrue(sm.getAdditionalFields().isEmpty(), sm.getAdditionalFields().toString()); }