Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions docs/messages/scheduled_messages.md
Original file line number Diff line number Diff line change
@@ -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(true)` (`pending(false)` is accepted).

## 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<ScheduledMessage> 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.
10 changes: 10 additions & 0 deletions src/main/java/io/getstream/chat/java/models/Event.java
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Should Fix] This typed field also takes message_id from events other than scheduled_message.sent. The backend puts a top-level message_id on every message webhook (message.new, message.updated, ...) and on reminder.* and poll.* events. Today Jackson puts that key in getAdditionalFields(); after this change it goes to getMessageId() and is no longer in the map.

Why it matters: a backend that parses webhooks into Event and reads getAdditionalFields().get("message_id") gets null after upgrading, with no compile error.

Fix: keep the typed field, but make the Javadoc say it is the ID of the message the event is about (on scheduled_message.sent, the sent message), and mention the move from getAdditionalFields() to getMessageId() in the commit message so the changelog carries it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks — #278 merged before this was addressed, so the fix is in a follow-up: #279 (commit 8b59fc5).

Rather than only documenting the move, it keeps full backward compatibility: messageId is no longer a Jackson-mapped field; getMessageId()/setMessageId() read and write additionalFields["message_id"]. So getAdditionalFields().get("message_id") keeps working on message.*/reminder.*/poll.* events, JSON round-trips exactly as in 1.50.0, and getMessageId() works on every event including scheduled_message.sent. Tests cover a message.new event with message_id and a scheduled_message.sent event.

private String messageId;

@NotNull @JsonIgnore private Map<String, Object> additionalFields = new HashMap<>();

@JsonAnyGetter
Expand Down
20 changes: 20 additions & 0 deletions src/main/java/io/getstream/chat/java/models/Message.java
Original file line number Diff line number Diff line change
Expand Up @@ -806,6 +806,17 @@ public static class MessageSendRequestData {
@JsonProperty("pending_message_metadata")
private Map<String, Object> 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(true)}; {@code pending(false)} is
* accepted. Slash commands cannot be scheduled.
*/
@Nullable
@JsonProperty("scheduled_at")
private Date scheduledAt;

public static class MessageSendRequest extends StreamRequest<MessageSendResponse> {
@NotNull private String channelId;

Expand Down Expand Up @@ -1405,9 +1416,18 @@ protected Call<StreamResponseObject> 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
Expand Down
Loading
Loading