diff --git a/docs/messages/scheduled_messages.md b/docs/messages/scheduled_messages.md new file mode 100644 index 0000000..4b1284b --- /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`, `GetData("message_id")` returns the ID of the sent message. diff --git a/docs/webhooks/webhook_events.md b/docs/webhooks/webhook_events.md index a87ef06..d6bc392 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 b4b9fa4..d905de5 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. 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); + + /// + /// 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 809a44a..31540ef 100644 --- a/src/Clients/MessageClient.cs +++ b/src/Clients/MessageClient.cs @@ -35,6 +35,7 @@ public async Task SendMessageAsync(string channelType, s SkipPush = options.SkipPush, IsPendingMessage = options.IsPendingMessage, ForceModeration = options.ForceModeration, + ScheduledAt = options.ScheduledAt, }; req.Message.UserId = userId; @@ -296,5 +297,41 @@ 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) + { + // 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) + => 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 6982a53..fcc5000 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,12 @@ 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; } } public class UserCustomEvent : CustomDataBase diff --git a/src/Models/Message.cs b/src/Models/Message.cs index b34b473..0e61c57 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 1e1e49a..838fb19 100644 --- a/src/Models/MessageRequest.cs +++ b/src/Models/MessageRequest.cs @@ -47,4 +47,12 @@ 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. + /// must be false: pending messages cannot be scheduled. + /// + 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 0000000..ffbfa33 --- /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 97b94d0..872a6bb 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 0000000..bb41052 --- /dev/null +++ b/tests/ScheduledMessageRequestTests.cs @@ -0,0 +1,278 @@ +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["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"); + } + + [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.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 }); + + 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"), + }; + } + } + } +}