Repository navigation
[CHA-5933] feat: add scheduled messages support - #219
nijeesh-stream wants to merge 4 commits into
Conversation
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>
| /// 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; } |
There was a problem hiding this comment.
[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.
There was a problem hiding this comment.
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.
| 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; |
There was a problem hiding this comment.
[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);There was a problem hiding this comment.
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>
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
ScheduledAtis set (details below).Linear: https://linear.app/stream/issue/CHA-5933
CLA
Description of the pull request
New
IMessageClientmethods, next to the reminder methods:ScheduleMessageAsync(type, id, msg, userId, scheduledAt, options), orSendMessageAsyncwithSendMessageOptions.ScheduledAtPOST /channels/{type}/{id}/messagewithscheduled_at; the response's newGenericMessageResponse.ScheduledMessageis setUpdateScheduledMessageAsync(id, UpdateScheduledMessageRequest)PATCH /scheduled_messages/{id}DeleteScheduledMessageAsync(id)DELETE /scheduled_messages/{id}SendScheduledMessageNowAsync(id)POST /scheduled_messages/{id}/send_nowQueryScheduledMessagesAsync(QueryScheduledMessagesRequest)POST /scheduled_messages/query(UserId,Filter.Cid,Filter.Status,Sort,Limit,Next)Models (
src/Models/ScheduledMessage.cs):ScheduledMessage,ScheduledMessageContent(custom fields viaGetData),ScheduledMessageLocation,ScheduledMessageChannel, the request and response types, and theScheduledMessageStatus/ScheduledMessageFailureReasonstring constants.EventResponsegainsScheduledMessageandMessageId, soWebhookHelpers.ParseEventexposes thescheduled_message.sent/.failedwebhooks.Send-path note:
SendMessageAsyncalways sendsis_pending_message(falseby default), and scheduled sends do the same, exactly like a live send. An explicitfalseoverrides the channel type'smark_messages_pendingsetting, so a scheduled message into such a channel is sent normally instead of failing withpending_not_supported. Requires a backend with GetStream/chat#18727, which acceptsis_pending_message: falsewithscheduled_at(older backends answered 400scheduled_at cannot be combined with pending). Merge and release only after that is deployed everywhere.IsPendingMessage = truewithScheduledAtis still rejected by the API.Docs: new
docs/messages/scheduled_messages.md, plus the webhook events indocs/webhooks/webhook_events.md. CHANGELOG is left to the release tooling.Not added:
include_scheduled_messageson GetOrCreateChannel (the backend ignores it for server-side calls), app settings (thechat_scheduled_messages_*flags aren't exposed through the app settings API), and a permission constant (Resourceonly lists legacy v1 permissions).Tests
tests/ScheduledMessageRequestTests.cs: checks request and response shapes, and webhook parsing, against a stubbedHttpMessageHandler. It needs no credentials and always runs.MessageClientTests: live round trip (schedule, query, update, send now, delete). It callsAssert.Ignorewhen the app has the feature off or the backend ignoresscheduled_at, so CI stays green until the flag is on for the CI app.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