From b06e1e08cece35907ec9bc249095751865f1b2d0 Mon Sep 17 00:00:00 2001 From: nijeeshjoshy Date: Thu, 15 Oct 2026 00:29:04 +0200 Subject: [PATCH 1/6] feat: add scheduled messages support (CHA-5933) 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 --- docs/messages/scheduled_messages.md | 80 +++++++ docs/webhooks/webhook_events.md | 2 + src/Clients/IMessageClient.cs | 44 ++++ src/Clients/MessageClient.cs | 37 ++++ src/Models/Event.cs | 13 ++ src/Models/Message.cs | 7 + src/Models/MessageRequest.cs | 7 + src/Models/ScheduledMessage.cs | 305 ++++++++++++++++++++++++++ tests/MessageClientTests.cs | 102 +++++++++ tests/ScheduledMessageRequestTests.cs | 244 +++++++++++++++++++++ 10 files changed, 841 insertions(+) create mode 100644 docs/messages/scheduled_messages.md create mode 100644 src/Models/ScheduledMessage.cs create mode 100644 tests/ScheduledMessageRequestTests.cs diff --git a/docs/messages/scheduled_messages.md b/docs/messages/scheduled_messages.md new file mode 100644 index 00000000..5ef44603 --- /dev/null +++ b/docs/messages/scheduled_messages.md @@ -0,0 +1,80 @@ +Scheduled messages let a user write a message now and have it sent to a channel later. Server-side, you can schedule, edit, cancel and list scheduled messages on behalf of any user. + +Scheduled messages must be enabled for your app. Until the message is sent, only the user who scheduled it can see it; nothing is sent to the channel. + +## Scheduling a Message + +Send a message with a `scheduled_at` time. It must be at least 1 minute and at most the app's maximum lead time (120 days by default) from now. Slash commands and pending messages cannot be scheduled. + +```csharp +var resp = await messageClient.ScheduleMessageAsync("messaging", "general", + new MessageRequest { Text = "Good morning!" }, "user-id", DateTimeOffset.UtcNow.AddHours(1)); + +var scheduledMessageId = resp.ScheduledMessage.Id; +``` + +`SendMessageAsync` with `SendMessageOptions.ScheduledAt` does the same. The response carries `ScheduledMessage`. Its `Id` is used to edit, cancel or send the message now. The `Message` in the response holds the content as it will be sent; it is not in the channel yet. + +## Updating a Scheduled Message + +Change the message, the send time, or both. A new message replaces the scheduled one as a whole; the sender stays the user it was scheduled for. + +```csharp +await messageClient.UpdateScheduledMessageAsync(scheduledMessageId, new UpdateScheduledMessageRequest +{ + Message = new MessageRequest { Text = "Good afternoon!" }, + ScheduledAt = DateTimeOffset.UtcNow.AddHours(2), +}); +``` + +## Sending a Scheduled Message Now + +```csharp +await messageClient.SendScheduledMessageNowAsync(scheduledMessageId); +``` + +## Deleting a Scheduled Message + +Deleting cancels a scheduled message so it is never sent, or removes a failed one. + +```csharp +await messageClient.DeleteScheduledMessageAsync(scheduledMessageId); +``` + +Updating, deleting or sending now fails with a 409 error while the message is being sent. + +## Querying Scheduled Messages + +List the scheduled messages of a user across channels: pending ones first, then failed ones, each by `scheduled_at`. You can filter by channel and status (`scheduled`, `sending`, `failed`). + +```csharp +var page = await messageClient.QueryScheduledMessagesAsync(new QueryScheduledMessagesRequest +{ + UserId = "user-id", + Filter = new ScheduledMessagesFilter + { + Cid = "messaging:general", + Status = new List { ScheduledMessageStatus.Failed }, + }, + Limit = 20, +}); + +// next page +if (!string.IsNullOrEmpty(page.Next)) +{ + page = await messageClient.QueryScheduledMessagesAsync(new QueryScheduledMessagesRequest + { + UserId = "user-id", + Limit = 20, + Next = page.Next, + }); +} +``` + +## Failed Messages + +When a scheduled message cannot be sent, its `Status` becomes `failed` and `FailureReason` says why (see `ScheduledMessageFailureReason`): `banned`, `no_permission`, `channel_frozen`, `channel_deleted`, `user_deleted`, `moderation_blocked`, `hook_rejected`, `expired`, `internal_error`, `invalid_message` or `pending_not_supported`. A failed message is not retried; delete it, and schedule it again if needed. + +## Webhooks + +The `scheduled_message.sent` and `scheduled_message.failed` events are sent to webhooks. `EventResponse.ScheduledMessage` holds the scheduled message, and on `scheduled_message.sent`, `EventResponse.MessageId` is the ID of the sent message. diff --git a/docs/webhooks/webhook_events.md b/docs/webhooks/webhook_events.md index a87ef06d..d6bc3927 100644 --- a/docs/webhooks/webhook_events.md +++ b/docs/webhooks/webhook_events.md @@ -50,6 +50,8 @@ When applicable, the following attributes are included to the event user and to | reminder.updated | when a message reminder is updated. | | reminder.deleted | when a message reminder is deleted. | | notification.reminder_due | when a message reminder's due time is reached. | +| scheduled_message.sent | when a scheduled message is sent; message_id is the sent message. | +| scheduled_message.failed | when a scheduled message fails to send (see failure_reason). | ### `message.new` diff --git a/src/Clients/IMessageClient.cs b/src/Clients/IMessageClient.cs index b4b9fa41..aad68611 100644 --- a/src/Clients/IMessageClient.cs +++ b/src/Clients/IMessageClient.cs @@ -241,5 +241,49 @@ Task UploadImageAsync(string channelType, /// Additional query options like limit, offset /// API response with reminders Task QueryRemindersAsync(string userId, Dictionary filterConditions = null, List> sort = null, Dictionary options = null); + + /// + /// Schedules a message to be sent to a channel at a later time, on behalf of . + /// The message is not sent now; the response carries , + /// whose ID is used to update, delete or send the scheduled message now. + /// + /// The type of the channel + /// The ID of the channel + /// The message to send + /// The ID of the user the message is sent as + /// When to send the message. Must be at least 1 minute and at most the app's maximum lead time from now + /// Other send options. Its is replaced by + Task ScheduleMessageAsync(string channelType, string channelId, MessageRequest msg, string userId, DateTimeOffset scheduledAt, SendMessageOptions options = null); + + /// + /// Updates the message and/or the send time of a scheduled message. + /// The message, when set, replaces the scheduled message as a whole. + /// + /// The ID of the scheduled message + /// The changes. At least one of message or scheduled_at is required + /// API response with the updated scheduled message + Task UpdateScheduledMessageAsync(string scheduledMessageId, UpdateScheduledMessageRequest request); + + /// + /// Deletes a scheduled message: cancels it so it is never sent, or removes a failed one. + /// + /// The ID of the scheduled message + /// API response with the deleted scheduled message + Task DeleteScheduledMessageAsync(string scheduledMessageId); + + /// + /// Sends a scheduled message right away. + /// + /// The ID of the scheduled message + /// API response with the scheduled message + Task SendScheduledMessageNowAsync(string scheduledMessageId); + + /// + /// Queries the scheduled messages of one user across channels: pending ones first, + /// then failed ones, each by scheduled_at. + /// + /// The query. is required + /// API response with a page of scheduled messages + Task QueryScheduledMessagesAsync(QueryScheduledMessagesRequest request); } } \ No newline at end of file diff --git a/src/Clients/MessageClient.cs b/src/Clients/MessageClient.cs index 809a44a9..a2aa7384 100644 --- a/src/Clients/MessageClient.cs +++ b/src/Clients/MessageClient.cs @@ -35,7 +35,15 @@ public async Task SendMessageAsync(string channelType, s SkipPush = options.SkipPush, IsPendingMessage = options.IsPendingMessage, ForceModeration = options.ForceModeration, + ScheduledAt = options.ScheduledAt, }; + + // The API rejects scheduled_at combined with any pending field, even false, + // so leave out the default instead of sending is_pending_message: false. + if (options.ScheduledAt.HasValue && !options.IsPendingMessage) + { + req.IsPendingMessage = null; + } req.Message.UserId = userId; return await ExecuteRequestAsync($"channels/{channelType}/{channelId}/message", @@ -296,5 +304,34 @@ public async Task QueryRemindersAsync(string userId, Dic HttpStatusCode.Created, data); } + + public async Task ScheduleMessageAsync(string channelType, string channelId, MessageRequest msg, string userId, DateTimeOffset scheduledAt, SendMessageOptions options = null) + { + options = options ?? new SendMessageOptions(); + options.ScheduledAt = scheduledAt; + return await SendMessageAsync(channelType, channelId, msg, userId, options); + } + + public async Task UpdateScheduledMessageAsync(string scheduledMessageId, UpdateScheduledMessageRequest request) + => await ExecuteRequestAsync($"scheduled_messages/{scheduledMessageId}", + HttpMethod.PATCH, + HttpStatusCode.OK, + request); + + public async Task DeleteScheduledMessageAsync(string scheduledMessageId) + => await ExecuteRequestAsync($"scheduled_messages/{scheduledMessageId}", + HttpMethod.DELETE, + HttpStatusCode.OK); + + public async Task SendScheduledMessageNowAsync(string scheduledMessageId) + => await ExecuteRequestAsync($"scheduled_messages/{scheduledMessageId}/send_now", + HttpMethod.POST, + HttpStatusCode.Created); + + public async Task QueryScheduledMessagesAsync(QueryScheduledMessagesRequest request) + => await ExecuteRequestAsync("scheduled_messages/query", + HttpMethod.POST, + HttpStatusCode.Created, + request); } } \ No newline at end of file diff --git a/src/Models/Event.cs b/src/Models/Event.cs index 6982a53b..5a4d2b01 100644 --- a/src/Models/Event.cs +++ b/src/Models/Event.cs @@ -1,4 +1,5 @@ using System; +using Newtonsoft.Json; namespace StreamChat.Models { @@ -54,6 +55,18 @@ public class EventResponse : CustomDataBase public string ParentId { get; set; } public string Team { get; set; } public DateTimeOffset? CreatedAt { get; set; } + + /// + /// The scheduled message of a scheduled_message.* event. + /// + [JsonProperty("scheduled_message")] + public ScheduledMessage ScheduledMessage { get; set; } + + /// + /// The ID of the message a scheduled message was sent as. Only set on scheduled_message.sent. + /// + [JsonProperty("message_id")] + public string MessageId { get; set; } } public class UserCustomEvent : CustomDataBase diff --git a/src/Models/Message.cs b/src/Models/Message.cs index b34b4735..0e61c579 100644 --- a/src/Models/Message.cs +++ b/src/Models/Message.cs @@ -64,6 +64,7 @@ public class MessageSendRequest public bool? SkipPush { get; set; } public bool? IsPendingMessage { get; set; } public bool? ForceModeration { get; set; } + public DateTimeOffset? ScheduledAt { get; set; } } public class MessageSearchResponse : ApiResponse @@ -91,6 +92,12 @@ public class MessagePartialUpdateRequest public class GenericMessageResponse : ApiResponse { public Message Message { get; set; } + + /// + /// The scheduled message. Only set when the message was sent with a scheduled_at; + /// then holds the content as it will be sent and is not in the channel. + /// + public ScheduledMessage ScheduledMessage { get; set; } } public class MessagePartialUpdateResponse : GenericMessageResponse diff --git a/src/Models/MessageRequest.cs b/src/Models/MessageRequest.cs index 1e1e49a2..2a71e294 100644 --- a/src/Models/MessageRequest.cs +++ b/src/Models/MessageRequest.cs @@ -47,4 +47,11 @@ public class SendMessageOptions public bool SkipPush { get; set; } public bool IsPendingMessage { get; set; } public bool? ForceModeration { get; set; } + + /// + /// When set, the message is not sent now: it is stored and sent at this time. + /// The response then carries the scheduled message in GenericMessageResponse.ScheduledMessage. + /// Must be at least 1 minute and at most the app's maximum lead time from now. + /// + public DateTimeOffset? ScheduledAt { get; set; } } \ No newline at end of file diff --git a/src/Models/ScheduledMessage.cs b/src/Models/ScheduledMessage.cs new file mode 100644 index 00000000..ffbfa33c --- /dev/null +++ b/src/Models/ScheduledMessage.cs @@ -0,0 +1,305 @@ +using System; +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace StreamChat.Models +{ + /// + /// Statuses of a scheduled message. + /// + public static class ScheduledMessageStatus + { + /// Waiting for its send time. + public const string Scheduled = "scheduled"; + + /// Being sent. It cannot be updated, deleted or sent now in this state. + public const string Sending = "sending"; + + /// Sending failed; says why. + public const string Failed = "failed"; + + /// Deleted before it was sent. + public const string Canceled = "canceled"; + + /// Sent. Only reported by the scheduled_message.sent event. + public const string Sent = "sent"; + } + + /// + /// Reasons a scheduled message failed to send. + /// + public static class ScheduledMessageFailureReason + { + public const string Banned = "banned"; + public const string NoPermission = "no_permission"; + public const string ChannelFrozen = "channel_frozen"; + public const string ChannelDeleted = "channel_deleted"; + public const string UserDeleted = "user_deleted"; + public const string ModerationBlocked = "moderation_blocked"; + public const string HookRejected = "hook_rejected"; + public const string Expired = "expired"; + public const string InternalError = "internal_error"; + public const string InvalidMessage = "invalid_message"; + public const string PendingNotSupported = "pending_not_supported"; + } + + /// + /// A message scheduled to be sent later. + /// + public class ScheduledMessage + { + /// + /// The ID of the scheduled message, used to update, delete or send it now. + /// + [JsonProperty("id")] + public string Id { get; set; } + + /// + /// The CID of the channel the message will be sent to. + /// + [JsonProperty("cid")] + public string Cid { get; set; } + + /// + /// The user who scheduled the message, and who will send it. + /// + [JsonProperty("user")] + public User User { get; set; } + + /// + /// The message that will be sent. + /// + [JsonProperty("message")] + public ScheduledMessageContent Message { get; set; } + + /// + /// When the message is scheduled to be sent. + /// + [JsonProperty("scheduled_at")] + public DateTimeOffset? ScheduledAt { get; set; } + + /// + /// The status of the scheduled message. See . + /// + [JsonProperty("status")] + public string Status { get; set; } + + /// + /// Why sending failed. Only set when is failed. See . + /// + [JsonProperty("failure_reason")] + public string FailureReason { get; set; } + + [JsonProperty("created_at")] + public DateTimeOffset? CreatedAt { get; set; } + + [JsonProperty("updated_at")] + public DateTimeOffset? UpdatedAt { get; set; } + + /// + /// The channel of the scheduled message. Only set in query results. + /// + [JsonProperty("channel")] + public ScheduledMessageChannel Channel { get; set; } + } + + /// + /// The message a scheduled message will send. Custom fields are available + /// through . + /// + public class ScheduledMessageContent : CustomDataBase + { + [JsonProperty("text")] + public string Text { get; set; } + + [JsonProperty("mml")] + public string Mml { get; set; } + + [JsonProperty("html")] + public string Html { get; set; } + + [JsonProperty("type")] + public string Type { get; set; } + + [JsonProperty("attachments")] + public List Attachments { get; set; } + + [JsonProperty("parent_id")] + public string ParentId { get; set; } + + [JsonProperty("show_in_channel")] + public bool? ShowInChannel { get; set; } + + [JsonProperty("quoted_message_id")] + public string QuotedMessageId { get; set; } + + /// + /// The IDs of the mentioned users. + /// + [JsonProperty("mentioned_users")] + public List MentionedUsers { get; set; } + + [JsonProperty("mentioned_roles")] + public List MentionedRoles { get; set; } + + [JsonProperty("mentioned_group_ids")] + public List MentionedGroupIds { get; set; } + + [JsonProperty("mentioned_channel")] + public bool? MentionedChannel { get; set; } + + [JsonProperty("mentioned_here")] + public bool? MentionedHere { get; set; } + + [JsonProperty("restricted_visibility")] + public List RestrictedVisibility { get; set; } + + [JsonProperty("silent")] + public bool? Silent { get; set; } + + [JsonProperty("pinned")] + public bool? Pinned { get; set; } + + [JsonProperty("pin_expires")] + public DateTimeOffset? PinExpires { get; set; } + + [JsonProperty("poll_id")] + public string PollId { get; set; } + + [JsonProperty("shared_location")] + public ScheduledMessageLocation SharedLocation { get; set; } + } + + /// + /// The location a scheduled message will share. + /// + public class ScheduledMessageLocation + { + [JsonProperty("latitude")] + public double? Latitude { get; set; } + + [JsonProperty("longitude")] + public double? Longitude { get; set; } + + [JsonProperty("created_by_device_id")] + public string CreatedByDeviceId { get; set; } + + [JsonProperty("end_at")] + public DateTimeOffset? EndAt { get; set; } + } + + /// + /// The channel of a scheduled message. + /// + public class ScheduledMessageChannel + { + [JsonProperty("cid")] + public string Cid { get; set; } + + [JsonProperty("type")] + public string Type { get; set; } + + [JsonProperty("id")] + public string Id { get; set; } + + [JsonProperty("name")] + public string Name { get; set; } + + [JsonProperty("image")] + public string Image { get; set; } + } + + /// + /// Changes to a scheduled message. At least one of or + /// is required. + /// + public class UpdateScheduledMessageRequest + { + /// + /// Replaces the scheduled message as a whole. The sender stays the user the message was scheduled for. + /// + [JsonProperty("message")] + public MessageRequest Message { get; set; } + + /// + /// The new send time. Must be at least 1 minute and at most the app's maximum lead time from now. + /// + [JsonProperty("scheduled_at")] + public DateTimeOffset? ScheduledAt { get; set; } + } + + /// + /// Narrows a scheduled messages query. Empty fields mean no restriction. + /// + public class ScheduledMessagesFilter + { + /// + /// Only scheduled messages of this channel. + /// + [JsonProperty("cid")] + public string Cid { get; set; } + + /// + /// Only these statuses: scheduled, sending and/or failed. + /// + [JsonProperty("status")] + public List Status { get; set; } + } + + /// + /// Lists the scheduled messages of one user across channels. + /// + public class QueryScheduledMessagesRequest + { + /// + /// The user whose scheduled messages to list. Required. + /// + [JsonProperty("user_id")] + public string UserId { get; set; } + + [JsonProperty("filter")] + public ScheduledMessagesFilter Filter { get; set; } + + /// + /// Only scheduled_at ascending is supported. + /// + [JsonProperty("sort")] + public List Sort { get; set; } + + /// + /// Page size, 1 to 100. Defaults to 20. + /// + [JsonProperty("limit")] + public int? Limit { get; set; } + + /// + /// Cursor from the previous page. + /// + [JsonProperty("next")] + public string Next { get; set; } + } + + /// + /// Response of the scheduled message update, delete and send now operations. + /// + public class ScheduledMessageResponse : ApiResponse + { + [JsonProperty("scheduled_message")] + public ScheduledMessage ScheduledMessage { get; set; } + } + + /// + /// One page of scheduled messages: pending ones first, then failed ones, each by scheduled_at. + /// + public class QueryScheduledMessagesResponse : ApiResponse + { + [JsonProperty("scheduled_messages")] + public List ScheduledMessages { get; set; } + + /// + /// Cursor for the next page; empty on the last page. + /// + [JsonProperty("next")] + public string Next { get; set; } + } +} diff --git a/tests/MessageClientTests.cs b/tests/MessageClientTests.cs index 97b94d06..872a6bbb 100644 --- a/tests/MessageClientTests.cs +++ b/tests/MessageClientTests.cs @@ -661,5 +661,107 @@ public async Task TestReminderFullLifecycleAsync() await DisableUserMessageRemindersAsync(); } } + + /// + /// Schedules a message an hour from now, or ignores the test when the app cannot schedule. + /// Scheduled messages are behind a per-app flag: an app without it rejects scheduled_at + /// (scheduled_messages_disabled), and a backend that predates the feature ignores + /// scheduled_at and sends the message right away. + /// + private async Task ScheduleOrIgnoreAsync(string text) + { + GenericMessageResponse resp; + try + { + resp = await _messageClient.ScheduleMessageAsync(_channel.Type, _channel.Id, new MessageRequest { Text = text }, _user.Id, + DateTimeOffset.UtcNow.AddHours(1)); + } + catch (StreamChatException ex) when (ex.Message.Contains("scheduled messages are not enabled")) + { + Assert.Ignore($"Scheduled messages are not enabled for this Stream app: {ex.Message}"); + throw; + } + + if (resp.ScheduledMessage == null) + { + Assert.Ignore("Scheduled messages are not supported by this Stream app (no scheduled_message in the response)"); + } + + return resp.ScheduledMessage; + } + + [Test] + public async Task TestScheduleMessageAsync() + { + var scheduled = await ScheduleOrIgnoreAsync("scheduled hello"); + + scheduled.Cid.Should().Be($"{_channel.Type}:{_channel.Id}"); + scheduled.Status.Should().Be(ScheduledMessageStatus.Scheduled); + scheduled.User.Id.Should().Be(_user.Id); + scheduled.Message.Text.Should().Be("scheduled hello"); + + await _messageClient.DeleteScheduledMessageAsync(scheduled.Id); + } + + [Test] + public async Task TestQueryScheduledMessagesAsync() + { + var scheduled = await ScheduleOrIgnoreAsync("scheduled for query"); + + var resp = await _messageClient.QueryScheduledMessagesAsync(new QueryScheduledMessagesRequest + { + UserId = _user.Id, + Filter = new ScheduledMessagesFilter { Cid = scheduled.Cid }, + Limit = 10, + }); + + resp.ScheduledMessages.Select(sm => sm.Id).Should().Contain(scheduled.Id); + + await _messageClient.DeleteScheduledMessageAsync(scheduled.Id); + } + + [Test] + public async Task TestUpdateScheduledMessageAsync() + { + var scheduled = await ScheduleOrIgnoreAsync("scheduled to edit"); + var scheduledAt = DateTimeOffset.UtcNow.AddHours(2); + + var resp = await _messageClient.UpdateScheduledMessageAsync(scheduled.Id, new UpdateScheduledMessageRequest + { + Message = new MessageRequest { Text = "edited" }, + ScheduledAt = scheduledAt, + }); + + resp.ScheduledMessage.Message.Text.Should().Be("edited"); + resp.ScheduledMessage.ScheduledAt.Should().BeCloseTo(scheduledAt, TimeSpan.FromSeconds(1)); + + await _messageClient.DeleteScheduledMessageAsync(scheduled.Id); + } + + [Test] + public async Task TestSendScheduledMessageNowAsync() + { + var scheduled = await ScheduleOrIgnoreAsync("scheduled to send now"); + + var resp = await _messageClient.SendScheduledMessageNowAsync(scheduled.Id); + + resp.ScheduledMessage.Id.Should().Be(scheduled.Id); + } + + [Test] + public async Task TestDeleteScheduledMessageAsync() + { + var scheduled = await ScheduleOrIgnoreAsync("scheduled to delete"); + + var resp = await _messageClient.DeleteScheduledMessageAsync(scheduled.Id); + + resp.ScheduledMessage.Id.Should().Be(scheduled.Id); + var remaining = await _messageClient.QueryScheduledMessagesAsync(new QueryScheduledMessagesRequest + { + UserId = _user.Id, + Filter = new ScheduledMessagesFilter { Cid = scheduled.Cid }, + }); + remaining.ScheduledMessages.Select(sm => sm.Id).Should().NotContain(scheduled.Id); + } } } diff --git a/tests/ScheduledMessageRequestTests.cs b/tests/ScheduledMessageRequestTests.cs new file mode 100644 index 00000000..e6e7c14e --- /dev/null +++ b/tests/ScheduledMessageRequestTests.cs @@ -0,0 +1,244 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using StreamChat.Clients; +using StreamChat.Models; + +namespace StreamChatTests +{ + /// + /// Request and response shape tests for the scheduled message methods of . + /// They run against a stubbed HTTP handler, so they need no Stream credentials; + /// the round trip against a live app is covered in . + /// + [TestFixture] + public class ScheduledMessageRequestTests + { + private const string ScheduledMessageJson = @"{ + ""scheduled_message"": { + ""id"": ""sm-1"", + ""cid"": ""messaging:general"", + ""user"": { ""id"": ""jane"" }, + ""message"": { ""text"": ""later"", ""mentioned_users"": [""bob""], ""color"": ""red"" }, + ""scheduled_at"": ""2030-01-02T03:04:05Z"", + ""status"": ""failed"", + ""failure_reason"": ""channel_frozen"", + ""created_at"": ""2029-12-31T00:00:00Z"", + ""updated_at"": ""2029-12-31T00:00:00Z"" + }, + ""duration"": ""1ms"" + }"; + + private CapturingHandler _handler; + private IMessageClient _client; + + [SetUp] + public void Setup() + { + _handler = new CapturingHandler(); + var factory = new StreamClientFactory("key", "secret", opts => + { + opts.HttpClient = new HttpClient(_handler); + opts.BaseUrl = new Uri("https://chat.stream-io-api.com"); + }); + _client = factory.GetMessageClient(); + } + + [Test] + public async Task TestScheduleMessageSendsScheduledAtAsync() + { + _handler.Respond(HttpStatusCode.Created, ScheduledMessageJson); + var scheduledAt = new DateTimeOffset(2030, 1, 2, 3, 4, 5, TimeSpan.Zero); + + var resp = await _client.ScheduleMessageAsync("messaging", "general", new MessageRequest { Text = "later" }, "jane", scheduledAt, + new SendMessageOptions { SkipPush = true }); + + _handler.Method.Should().Be(HttpMethod.Post); + _handler.Path.Should().Be("/channels/messaging/general/message"); + var body = ParseBody(_handler.Body); + body["message"]["text"].Value().Should().Be("later"); + body["message"]["user_id"].Value().Should().Be("jane"); + body["skip_push"].Value().Should().BeTrue(); + body.ContainsKey("is_pending_message").Should().BeFalse("the API rejects scheduled_at with any pending field"); + DateTimeOffset.Parse(body["scheduled_at"].Value()).Should().Be(scheduledAt); + resp.ScheduledMessage.Id.Should().Be("sm-1"); + } + + [Test] + public async Task TestSendMessageWithoutScheduledAtOmitsItAsync() + { + _handler.Respond(HttpStatusCode.Created, @"{ ""message"": { ""id"": ""m-1"" } }"); + + var resp = await _client.SendMessageAsync("messaging", "general", "jane", "now"); + + JObject.Parse(_handler.Body).ContainsKey("scheduled_at").Should().BeFalse(); + resp.ScheduledMessage.Should().BeNull(); + } + + [Test] + public async Task TestUpdateScheduledMessageAsync() + { + _handler.Respond(HttpStatusCode.OK, ScheduledMessageJson); + var scheduledAt = new DateTimeOffset(2030, 1, 2, 3, 4, 5, TimeSpan.Zero); + + await _client.UpdateScheduledMessageAsync("sm-1", new UpdateScheduledMessageRequest + { + Message = new MessageRequest { Text = "edited" }, + ScheduledAt = scheduledAt, + }); + + _handler.Method.Should().Be(new HttpMethod("PATCH")); + _handler.Path.Should().Be("/scheduled_messages/sm-1"); + var body = ParseBody(_handler.Body); + body["message"]["text"].Value().Should().Be("edited"); + DateTimeOffset.Parse(body["scheduled_at"].Value()).Should().Be(scheduledAt); + } + + [Test] + public async Task TestUpdateScheduledMessageSendsOnlyGivenFieldsAsync() + { + _handler.Respond(HttpStatusCode.OK, ScheduledMessageJson); + + await _client.UpdateScheduledMessageAsync("sm-1", new UpdateScheduledMessageRequest + { + ScheduledAt = new DateTimeOffset(2030, 1, 2, 3, 4, 5, TimeSpan.Zero), + }); + + var body = ParseBody(_handler.Body); + body.ContainsKey("message").Should().BeFalse(); + body.ContainsKey("scheduled_at").Should().BeTrue(); + } + + [Test] + public async Task TestDeleteScheduledMessageAsync() + { + _handler.Respond(HttpStatusCode.OK, ScheduledMessageJson); + + var resp = await _client.DeleteScheduledMessageAsync("sm-1"); + + _handler.Method.Should().Be(HttpMethod.Delete); + _handler.Path.Should().Be("/scheduled_messages/sm-1"); + resp.ScheduledMessage.Id.Should().Be("sm-1"); + } + + [Test] + public async Task TestSendScheduledMessageNowAsync() + { + _handler.Respond(HttpStatusCode.Created, ScheduledMessageJson); + + var resp = await _client.SendScheduledMessageNowAsync("sm-1"); + + _handler.Method.Should().Be(HttpMethod.Post); + _handler.Path.Should().Be("/scheduled_messages/sm-1/send_now"); + resp.ScheduledMessage.Id.Should().Be("sm-1"); + } + + [Test] + public async Task TestQueryScheduledMessagesAsync() + { + _handler.Respond(HttpStatusCode.Created, + @"{ ""scheduled_messages"": [ { ""id"": ""sm-1"", ""channel"": { ""cid"": ""messaging:general"", ""type"": ""messaging"", ""id"": ""general"" } } ], ""next"": ""cursor-2"" }"); + + var resp = await _client.QueryScheduledMessagesAsync(new QueryScheduledMessagesRequest + { + UserId = "jane", + Filter = new ScheduledMessagesFilter + { + Cid = "messaging:general", + Status = new List { ScheduledMessageStatus.Scheduled, ScheduledMessageStatus.Failed }, + }, + Sort = new List { new SortParameter { Field = "scheduled_at", Direction = SortDirection.Ascending } }, + Limit = 10, + Next = "cursor-1", + }); + + _handler.Method.Should().Be(HttpMethod.Post); + _handler.Path.Should().Be("/scheduled_messages/query"); + JToken.DeepEquals(JObject.Parse(_handler.Body), JObject.Parse(@"{ + ""user_id"": ""jane"", + ""filter"": { ""cid"": ""messaging:general"", ""status"": [""scheduled"", ""failed""] }, + ""sort"": [ { ""field"": ""scheduled_at"", ""direction"": 1 } ], + ""limit"": 10, + ""next"": ""cursor-1"" + }")).Should().BeTrue(_handler.Body); + resp.ScheduledMessages.Should().ContainSingle(); + resp.ScheduledMessages[0].Channel.Id.Should().Be("general"); + resp.Next.Should().Be("cursor-2"); + } + + [Test] + public async Task TestScheduledMessageResponseIsDeserializedAsync() + { + _handler.Respond(HttpStatusCode.OK, ScheduledMessageJson); + + var sm = (await _client.DeleteScheduledMessageAsync("sm-1")).ScheduledMessage; + + sm.Cid.Should().Be("messaging:general"); + sm.User.Id.Should().Be("jane"); + sm.Message.Text.Should().Be("later"); + sm.Message.MentionedUsers.Should().Equal("bob"); + sm.Message.GetData("color").Should().Be("red"); + sm.ScheduledAt.Should().Be(new DateTimeOffset(2030, 1, 2, 3, 4, 5, TimeSpan.Zero)); + sm.Status.Should().Be(ScheduledMessageStatus.Failed); + sm.FailureReason.Should().Be(ScheduledMessageFailureReason.ChannelFrozen); + } + + [Test] + public void TestScheduledMessageSentWebhookIsParsed() + { + var payload = Encoding.UTF8.GetBytes(@"{ + ""type"": ""scheduled_message.sent"", + ""cid"": ""messaging:general"", + ""message_id"": ""m-1"", + ""scheduled_message"": { ""id"": ""sm-1"", ""status"": ""sent"" } + }"); + + var ev = WebhookHelpers.ParseEvent(payload); + + ev.Type.Should().Be("scheduled_message.sent"); + ev.MessageId.Should().Be("m-1"); + ev.ScheduledMessage.Id.Should().Be("sm-1"); + ev.ScheduledMessage.Status.Should().Be(ScheduledMessageStatus.Sent); + } + + // Keeps dates as the strings that were sent instead of converting them to local DateTime. + private static JObject ParseBody(string body) + => JObject.Load(new JsonTextReader(new StringReader(body)) { DateParseHandling = DateParseHandling.None }); + + private class CapturingHandler : HttpMessageHandler + { + private HttpStatusCode _status; + private string _responseBody; + + public HttpMethod Method { get; private set; } + public string Path { get; private set; } + public string Body { get; private set; } + + public void Respond(HttpStatusCode status, string body) + { + _status = status; + _responseBody = body; + } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Method = request.Method; + Path = request.RequestUri.AbsolutePath; + Body = request.Content == null ? null : await request.Content.ReadAsStringAsync(); + return new HttpResponseMessage(_status) + { + Content = new StringContent(_responseBody, Encoding.UTF8, "application/json"), + }; + } + } + } +} From 232ddd0f7e795b942f1d11c94b7d025b28a01175 Mon Sep 17 00:00:00 2001 From: nijeeshjoshy Date: Thu, 15 Oct 2026 04:01:55 +0200 Subject: [PATCH 2/6] docs: note the API now accepts is_pending_message false with scheduled_at Co-Authored-By: Claude Opus 5.5 --- src/Clients/MessageClient.cs | 6 ++++-- tests/ScheduledMessageRequestTests.cs | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/Clients/MessageClient.cs b/src/Clients/MessageClient.cs index a2aa7384..0a100038 100644 --- a/src/Clients/MessageClient.cs +++ b/src/Clients/MessageClient.cs @@ -38,8 +38,10 @@ public async Task SendMessageAsync(string channelType, s ScheduledAt = options.ScheduledAt, }; - // The API rejects scheduled_at combined with any pending field, even false, - // so leave out the default instead of sending is_pending_message: false. + // Older API versions rejected scheduled_at combined with any pending field, + // even false, so the default is left out. Current versions accept false. + // Without it the channel type's mark_messages_pending setting applies when + // the message is sent, as it does for a request that omits the field. if (options.ScheduledAt.HasValue && !options.IsPendingMessage) { req.IsPendingMessage = null; diff --git a/tests/ScheduledMessageRequestTests.cs b/tests/ScheduledMessageRequestTests.cs index e6e7c14e..44641283 100644 --- a/tests/ScheduledMessageRequestTests.cs +++ b/tests/ScheduledMessageRequestTests.cs @@ -68,7 +68,7 @@ public async Task TestScheduleMessageSendsScheduledAtAsync() body["message"]["text"].Value().Should().Be("later"); body["message"]["user_id"].Value().Should().Be("jane"); body["skip_push"].Value().Should().BeTrue(); - body.ContainsKey("is_pending_message").Should().BeFalse("the API rejects scheduled_at with any pending field"); + body.ContainsKey("is_pending_message").Should().BeFalse("older API versions reject scheduled_at with any pending field, even false"); DateTimeOffset.Parse(body["scheduled_at"].Value()).Should().Be(scheduledAt); resp.ScheduledMessage.Id.Should().Be("sm-1"); } From b399ee8fc37c78d00fadf74f1c924d074fba4018 Mon Sep 17 00:00:00 2001 From: nijeeshjoshy Date: Thu, 15 Oct 2026 06:00:13 +0200 Subject: [PATCH 3/6] fix: send is_pending_message on scheduled sends as on live sends 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 --- src/Clients/MessageClient.cs | 9 --------- src/Models/MessageRequest.cs | 1 + tests/ScheduledMessageRequestTests.cs | 2 +- 3 files changed, 2 insertions(+), 10 deletions(-) diff --git a/src/Clients/MessageClient.cs b/src/Clients/MessageClient.cs index 0a100038..bbb070b6 100644 --- a/src/Clients/MessageClient.cs +++ b/src/Clients/MessageClient.cs @@ -37,15 +37,6 @@ public async Task SendMessageAsync(string channelType, s ForceModeration = options.ForceModeration, ScheduledAt = options.ScheduledAt, }; - - // Older API versions rejected scheduled_at combined with any pending field, - // even false, so the default is left out. Current versions accept false. - // Without it the channel type's mark_messages_pending setting applies when - // the message is sent, as it does for a request that omits the field. - if (options.ScheduledAt.HasValue && !options.IsPendingMessage) - { - req.IsPendingMessage = null; - } req.Message.UserId = userId; return await ExecuteRequestAsync($"channels/{channelType}/{channelId}/message", diff --git a/src/Models/MessageRequest.cs b/src/Models/MessageRequest.cs index 2a71e294..838fb198 100644 --- a/src/Models/MessageRequest.cs +++ b/src/Models/MessageRequest.cs @@ -52,6 +52,7 @@ public class SendMessageOptions /// When set, the message is not sent now: it is stored and sent at this time. /// The response then carries the scheduled message in GenericMessageResponse.ScheduledMessage. /// Must be at least 1 minute and at most the app's maximum lead time from now. + /// must be false: pending messages cannot be scheduled. /// public DateTimeOffset? ScheduledAt { get; set; } } \ No newline at end of file diff --git a/tests/ScheduledMessageRequestTests.cs b/tests/ScheduledMessageRequestTests.cs index 44641283..fddc99cf 100644 --- a/tests/ScheduledMessageRequestTests.cs +++ b/tests/ScheduledMessageRequestTests.cs @@ -68,7 +68,7 @@ public async Task TestScheduleMessageSendsScheduledAtAsync() body["message"]["text"].Value().Should().Be("later"); body["message"]["user_id"].Value().Should().Be("jane"); body["skip_push"].Value().Should().BeTrue(); - body.ContainsKey("is_pending_message").Should().BeFalse("older API versions reject scheduled_at with any pending field, even false"); + body["is_pending_message"].Value().Should().BeFalse("a scheduled send carries is_pending_message exactly as a live send does"); DateTimeOffset.Parse(body["scheduled_at"].Value()).Should().Be(scheduledAt); resp.ScheduledMessage.Id.Should().Be("sm-1"); } From 910f8234b5fbbb3535145524889d3c4b20e2b53c Mon Sep 17 00:00:00 2001 From: nijeeshjoshy Date: Mon, 19 Oct 2026 15:23:55 +0200 Subject: [PATCH 4/6] fix: keep event message_id parsing and caller options unchanged 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("message_id"). ScheduleMessageAsync now copies the options instead of setting ScheduledAt on the caller's object. Co-Authored-By: Claude Opus 5.5 --- docs/messages/scheduled_messages.md | 2 +- src/Clients/IMessageClient.cs | 2 +- src/Clients/MessageClient.cs | 13 +++++++--- src/Models/Event.cs | 6 ----- tests/ScheduledMessageRequestTests.cs | 36 ++++++++++++++++++++++++++- 5 files changed, 47 insertions(+), 12 deletions(-) diff --git a/docs/messages/scheduled_messages.md b/docs/messages/scheduled_messages.md index 5ef44603..4b1284b3 100644 --- a/docs/messages/scheduled_messages.md +++ b/docs/messages/scheduled_messages.md @@ -77,4 +77,4 @@ When a scheduled message cannot be sent, its `Status` becomes `failed` and `Fail ## Webhooks -The `scheduled_message.sent` and `scheduled_message.failed` events are sent to webhooks. `EventResponse.ScheduledMessage` holds the scheduled message, and on `scheduled_message.sent`, `EventResponse.MessageId` is the ID of the sent message. +The `scheduled_message.sent` and `scheduled_message.failed` events are sent to webhooks. `EventResponse.ScheduledMessage` holds the scheduled message, and on `scheduled_message.sent`, `GetData("message_id")` returns the ID of the sent message. diff --git a/src/Clients/IMessageClient.cs b/src/Clients/IMessageClient.cs index aad68611..d905de5f 100644 --- a/src/Clients/IMessageClient.cs +++ b/src/Clients/IMessageClient.cs @@ -252,7 +252,7 @@ Task UploadImageAsync(string channelType, /// The message to send /// The ID of the user the message is sent as /// When to send the message. Must be at least 1 minute and at most the app's maximum lead time from now - /// Other send options. Its is replaced by + /// Other send options. Their is ignored in favor of ; the object passed in is not changed Task ScheduleMessageAsync(string channelType, string channelId, MessageRequest msg, string userId, DateTimeOffset scheduledAt, SendMessageOptions options = null); /// diff --git a/src/Clients/MessageClient.cs b/src/Clients/MessageClient.cs index bbb070b6..31540ef7 100644 --- a/src/Clients/MessageClient.cs +++ b/src/Clients/MessageClient.cs @@ -300,9 +300,16 @@ public async Task QueryRemindersAsync(string userId, Dic public async Task ScheduleMessageAsync(string channelType, string channelId, MessageRequest msg, string userId, DateTimeOffset scheduledAt, SendMessageOptions options = null) { - options = options ?? new SendMessageOptions(); - options.ScheduledAt = scheduledAt; - return await SendMessageAsync(channelType, channelId, msg, userId, options); + // Copy instead of setting ScheduledAt on the caller's options: a reused options object + // would otherwise schedule every later send made with it. + 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); } public async Task UpdateScheduledMessageAsync(string scheduledMessageId, UpdateScheduledMessageRequest request) diff --git a/src/Models/Event.cs b/src/Models/Event.cs index 5a4d2b01..fcc5000e 100644 --- a/src/Models/Event.cs +++ b/src/Models/Event.cs @@ -61,12 +61,6 @@ public class EventResponse : CustomDataBase /// [JsonProperty("scheduled_message")] public ScheduledMessage ScheduledMessage { get; set; } - - /// - /// The ID of the message a scheduled message was sent as. Only set on scheduled_message.sent. - /// - [JsonProperty("message_id")] - public string MessageId { get; set; } } public class UserCustomEvent : CustomDataBase diff --git a/tests/ScheduledMessageRequestTests.cs b/tests/ScheduledMessageRequestTests.cs index fddc99cf..bb41052f 100644 --- a/tests/ScheduledMessageRequestTests.cs +++ b/tests/ScheduledMessageRequestTests.cs @@ -205,11 +205,45 @@ public void TestScheduledMessageSentWebhookIsParsed() var ev = WebhookHelpers.ParseEvent(payload); ev.Type.Should().Be("scheduled_message.sent"); - ev.MessageId.Should().Be("m-1"); + ev.GetData("message_id").Should().Be("m-1"); ev.ScheduledMessage.Id.Should().Be("sm-1"); ev.ScheduledMessage.Status.Should().Be(ScheduledMessageStatus.Sent); } + [Test] + public void TestNonScheduledEventMessageIdStaysInExtraData() + { + var payload = Encoding.UTF8.GetBytes(@"{ + ""type"": ""reminder.created"", + ""cid"": ""messaging:general"", + ""message_id"": ""msg_12345"" + }"); + + var ev = WebhookHelpers.ParseEvent(payload); + + ev.Type.Should().Be("reminder.created"); + ev.GetData("message_id").Should().Be("msg_12345"); + } + + [Test] + public async Task TestScheduleMessageDoesNotChangeCallerOptionsAsync() + { + var shared = new SendMessageOptions { SkipPush = true }; + + _handler.Respond(HttpStatusCode.Created, ScheduledMessageJson); + await _client.ScheduleMessageAsync("messaging", "general", new MessageRequest { Text = "later" }, "jane", + new DateTimeOffset(2030, 1, 2, 3, 4, 5, TimeSpan.Zero), shared); + + shared.ScheduledAt.Should().BeNull(); + + _handler.Respond(HttpStatusCode.Created, @"{ ""message"": { ""id"": ""m-1"" } }"); + await _client.SendMessageAsync("messaging", "general", new MessageRequest { Text = "now" }, "jane", shared); + + var body = ParseBody(_handler.Body); + body.ContainsKey("scheduled_at").Should().BeFalse("a reused options object must not schedule later sends"); + body["skip_push"].Value().Should().BeTrue(); + } + // Keeps dates as the strings that were sent instead of converting them to local DateTime. private static JObject ParseBody(string body) => JObject.Load(new JsonTextReader(new StringReader(body)) { DateParseHandling = DateParseHandling.None }); From 0764d0bc2a7e0e3d26c49cd903e2859796afb532 Mon Sep 17 00:00:00 2001 From: nijeeshjoshy Date: Thu, 22 Oct 2026 11:24:45 +0200 Subject: [PATCH 5/6] test: poll for archived_at in batch updater archive test The batch update task reports success before its per-channel subtasks finish, so ArchivedAt can still be null right after. Co-Authored-By: Claude Opus 5.5 --- tests/ChannelBatchUpdaterTests.cs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/tests/ChannelBatchUpdaterTests.cs b/tests/ChannelBatchUpdaterTests.cs index c00e0e06..10c306a5 100644 --- a/tests/ChannelBatchUpdaterTests.cs +++ b/tests/ChannelBatchUpdaterTests.cs @@ -172,11 +172,13 @@ public async Task TestChannelBatchUpdaterArchiveAsync() response.TaskId.Should().NotBeNullOrEmpty(); await WaitUntilTaskSucceedsAsync(response.TaskId); - var ch1State = await _channelClient.GetOrCreateAsync(ch1.Type, ch1.Id, new ChannelGetRequest()); - var ch1Member = ch1State.Members.FirstOrDefault(m => m.UserId == user1.Id); - - ch1Member.Should().NotBeNull(); - ch1Member.ArchivedAt.Should().NotBeNull(); + // The batch task reports success before its per-channel subtasks finish, so poll. + await WaitForAsync(async () => + { + var ch1State = await _channelClient.GetOrCreateAsync(ch1.Type, ch1.Id, new ChannelGetRequest()); + var ch1Member = ch1State.Members.FirstOrDefault(m => m.UserId == user1.Id); + return ch1Member?.ArchivedAt != null; + }, timeout: 20000, delay: 1000); } } } From 521a43c293f60f583dac003d75d87325398d1458 Mon Sep 17 00:00:00 2001 From: nijeeshjoshy Date: Thu, 22 Oct 2026 11:29:46 +0200 Subject: [PATCH 6/6] test: poll for added members in batch updater add-members test Same race as the archive test: the batch task succeeds before its per-channel subtasks have added the members. Co-Authored-By: Claude Opus 5.5 --- tests/ChannelBatchUpdaterTests.cs | 35 +++++++++++++++++-------------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/tests/ChannelBatchUpdaterTests.cs b/tests/ChannelBatchUpdaterTests.cs index 10c306a5..2e3b6894 100644 --- a/tests/ChannelBatchUpdaterTests.cs +++ b/tests/ChannelBatchUpdaterTests.cs @@ -74,24 +74,27 @@ public async Task TestChannelBatchUpdaterAddMembersAsync() response.TaskId.Should().NotBeNullOrEmpty(); await WaitUntilTaskSucceedsAsync(response.TaskId); - var ch1Members = await _channelClient.QueryMembersAsync(new QueryMembersRequest + // The batch task reports success before its per-channel subtasks finish, so poll. + await WaitForAsync(async () => { - Type = ch1.Type, - Id = ch1.Id, - FilterConditions = new Dictionary(), - }); + var ch1Members = await _channelClient.QueryMembersAsync(new QueryMembersRequest + { + Type = ch1.Type, + Id = ch1.Id, + FilterConditions = new Dictionary(), + }); - var ch2Members = await _channelClient.QueryMembersAsync(new QueryMembersRequest - { - Type = ch2.Type, - Id = ch2.Id, - FilterConditions = new Dictionary(), - }); - - ch1Members.Members.Should().Contain(m => m.UserId == user2.Id); - ch1Members.Members.Should().Contain(m => m.UserId == user3.Id); - ch2Members.Members.Should().Contain(m => m.UserId == user2.Id); - ch2Members.Members.Should().Contain(m => m.UserId == user3.Id); + var ch2Members = await _channelClient.QueryMembersAsync(new QueryMembersRequest + { + Type = ch2.Type, + Id = ch2.Id, + FilterConditions = new Dictionary(), + }); + + return new[] { user2.Id, user3.Id }.All(id => + ch1Members.Members.Any(m => m.UserId == id) + && ch2Members.Members.Any(m => m.UserId == id)); + }, timeout: 20000, delay: 1000); } [Test]