Skip to content

[CHA-5933] feat: add scheduled messages support - #219

Open
nijeesh-stream wants to merge 4 commits into
masterfrom
feature/cha-5933-scheduled-messages
Open

nijeesh-stream wants to merge 4 commits into
masterfrom
feature/cha-5933-scheduled-messages

Conversation

@nijeesh-stream

@nijeesh-stream nijeesh-stream commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

TLDR

Adds server-side support for Chat scheduled messages: schedule a message for a user, then edit it, cancel it, send it right away, or list a user's scheduled messages. Backend endpoints are behind a per-app flag and not yet released; merge after backend CHA-5924 ships. Risk is low: the change adds new methods and nullable fields. One send-path tweak applies only when ScheduledAt is set (details below).

Linear: https://linear.app/stream/issue/CHA-5933

CLA

  • I have signed the Stream CLA (required).
  • The code changes follow best practices
  • Code changes are tested (add some information if not applicable)
  • Commit message fits conventional commits. Important for CHANGELOG generation.

Description of the pull request

New IMessageClient methods, next to the reminder methods:

Method API
ScheduleMessageAsync(type, id, msg, userId, scheduledAt, options), or SendMessageAsync with SendMessageOptions.ScheduledAt POST /channels/{type}/{id}/message with scheduled_at; the response's new GenericMessageResponse.ScheduledMessage is set
UpdateScheduledMessageAsync(id, UpdateScheduledMessageRequest) PATCH /scheduled_messages/{id}
DeleteScheduledMessageAsync(id) DELETE /scheduled_messages/{id}
SendScheduledMessageNowAsync(id) POST /scheduled_messages/{id}/send_now
QueryScheduledMessagesAsync(QueryScheduledMessagesRequest) POST /scheduled_messages/query (UserId, Filter.Cid, Filter.Status, Sort, Limit, Next)

Models (src/Models/ScheduledMessage.cs): ScheduledMessage, ScheduledMessageContent (custom fields via GetData), ScheduledMessageLocation, ScheduledMessageChannel, the request and response types, and the ScheduledMessageStatus / ScheduledMessageFailureReason string constants. EventResponse gains ScheduledMessage and MessageId, so WebhookHelpers.ParseEvent exposes the scheduled_message.sent / .failed webhooks.

Send-path note: SendMessageAsync always sends is_pending_message (false by default), and scheduled sends do the same, exactly like a live send. An explicit false overrides the channel type's mark_messages_pending setting, so a scheduled message into such a channel is sent normally instead of failing with pending_not_supported. Requires a backend with GetStream/chat#18727, which accepts is_pending_message: false with scheduled_at (older backends answered 400 scheduled_at cannot be combined with pending). Merge and release only after that is deployed everywhere. IsPendingMessage = true with ScheduledAt is still rejected by the API.

Docs: new docs/messages/scheduled_messages.md, plus the webhook events in docs/webhooks/webhook_events.md. CHANGELOG is left to the release tooling.

Not added: include_scheduled_messages on GetOrCreateChannel (the backend ignores it for server-side calls), app settings (the chat_scheduled_messages_* flags aren't exposed through the app settings API), and a permission constant (Resource only lists legacy v1 permissions).

Tests

  • tests/ScheduledMessageRequestTests.cs: checks request and response shapes, and webhook parsing, against a stubbed HttpMessageHandler. It needs no credentials and always runs.
  • MessageClientTests: live round trip (schedule, query, update, send now, delete). It calls Assert.Ignore when the app has the feature off or the backend ignores scheduled_at, so CI stays green until the flag is on for the CI app.
  • Local results: the live tests pass (5/5) against a local backend built from the backend branch feature/cha-5924-scheduled-messages-channel-state, using an app with the flag on. They show as ignored against an app with the flag off. The net6.0 build has no new warnings.

🤖 Generated with Claude Code

nijeesh-stream and others added 3 commits October 15, 2026 00:29
Add SendMessageOptions.ScheduledAt and ScheduleMessageAsync (SendMessage
with scheduled_at), plus UpdateScheduledMessageAsync,
DeleteScheduledMessageAsync, SendScheduledMessageNowAsync and
QueryScheduledMessagesAsync, the ScheduledMessage models, and the
scheduled_message fields on GenericMessageResponse and EventResponse.

A scheduled send leaves out is_pending_message when it is false: the API
rejects scheduled_at combined with any pending field.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d_at

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Leaving out is_pending_message: false made scheduled sends into channel
types that mark messages pending fail with pending_not_supported, while
the same live send succeeds. The API accepts false with scheduled_at
since GetStream/chat#18727.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment thread src/Models/Event.cs Outdated
/// The ID of the message a scheduled message was sent as. Only set on scheduled_message.sent.
/// </summary>
[JsonProperty("message_id")]
public string MessageId { get; set; }

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.

[Must Fix] This changes how existing webhooks parse, not only scheduled_message.sent. reminder.created/updated/deleted, notification.reminder_due, poll events and ai_indicator.* events also carry a top-level message_id (backend: lib/chat/event/reminder.go:14, poll.go:11, ai_indicator_update.go:21; see the reminder.created example in docs/webhooks/webhook_events.md). Until now the only way to read it was evt.GetData<string>("message_id"). With a typed property, Newtonsoft no longer puts that key into the extension data, so that call returns null after upgrading.

Checked with WebhookHelpers.ParseEvent on {"type":"reminder.created","message_id":"msg_12345"}: GetData<string>("message_id") returns msg_12345 on master and null on this branch.

Fix: drop MessageId and point the docs at GetData<string>("message_id") for scheduled_message.sent. If the typed property stays, call out the change in the release notes, and the summary "Only set on scheduled_message.sent" needs updating since it is set for all the events above.

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.

Fixed in 910f823. Dropped the typed Event.MessageId, so message_id stays in the event extra data and GetData<string>("message_id") returns it for reminder.*, notification.reminder_due, poll and ai_indicator.* events exactly as on master. The scheduled-messages doc now points to GetData<string>("message_id") for scheduled_message.sent. Added TestNonScheduledEventMessageIdStaysInExtraData (parses reminder.created with message_id through WebhookHelpers.ParseEvent), and the scheduled_message.sent test now reads it the same way.

Comment thread src/Clients/MessageClient.cs Outdated
public async Task<GenericMessageResponse> ScheduleMessageAsync(string channelType, string channelId, MessageRequest msg, string userId, DateTimeOffset scheduledAt, SendMessageOptions options = null)
{
options = options ?? new SendMessageOptions();
options.ScheduledAt = scheduledAt;

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 writes ScheduledAt into the caller's own SendMessageOptions. Code that reuses one options object (for example a shared new SendMessageOptions { SkipPush = true }) gets every later SendMessageAsync call with it scheduled for that time instead of sent, with no error. Two concurrent schedules sharing one instance also race on the time.

Fix: build a new options object instead of changing the one passed in:

var opts = new SendMessageOptions
{
    SkipPush = options?.SkipPush ?? false,
    IsPendingMessage = options?.IsPendingMessage ?? false,
    ForceModeration = options?.ForceModeration,
    ScheduledAt = scheduledAt,
};
return await SendMessageAsync(channelType, channelId, msg, userId, opts);

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.

Fixed in 910f823. ScheduleMessageAsync now builds a new SendMessageOptions (copying SkipPush, IsPendingMessage, ForceModeration, plus ScheduledAt = scheduledAt) and never writes to the object passed in. Updated the options param doc to say so. Added TestScheduleMessageDoesNotChangeCallerOptionsAsync: schedules with a shared { SkipPush = true } object, checks its ScheduledAt is still null, then sends with it and checks the request has no scheduled_at and keeps skip_push.

Drop the typed Event.MessageId so message_id stays in the event's extra
data, as reminder, poll and ai_indicator webhooks expect via
GetData<string>("message_id"). ScheduleMessageAsync now copies the
options instead of setting ScheduledAt on the caller's object.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nijeesh-stream
nijeesh-stream enabled auto-merge (squash) October 20, 2026 09:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants