From 3dd63db768d9466d3bce5ec2fdb264329d57ff18 Mon Sep 17 00:00:00 2001 From: Volodymyr Lisovskyi Date: Thu, 1 Feb 2024 13:26:05 +0000 Subject: [PATCH] Feat/conversation conversations (#36) * feat: implement requests create list list auto stop update delete inject * fix(conversation/messages): use appropriate json structure * fix(messages): include accept_time * feat(tests): add e2e tests for conversations * fix: typo in doc of ConversationChannel.cs * chore: update AccessingPolymorphicType.cs with actual types --- examples/Console/AccessingPolymorhycType.cs | 27 -- examples/Console/AccessingPolymorphicType.cs | 33 +++ src/Sinch/Conversation/Contacts/Contact.cs | 33 +-- src/Sinch/Conversation/ConversationChannel.cs | 5 + .../Conversations/Conversation.cs | 147 +++++++++++ .../Conversations/Conversations.cs | 236 ++++++++++++++++++ .../Create/CreateConversationRequest.cs | 71 ++++++ .../InjectMessage/InjectMessageRequest.cs | 81 ++++++ .../List/ListConversationsRequest.cs | 41 +++ .../List/ListConversationsResponse.cs | 22 ++ .../Conversations/MetadataUpdateStrategy.cs | 21 ++ .../Messages/Message/AppMessage.cs | 91 ++++++- .../Messages/Message/CardMessage.cs | 2 +- .../Messages/Message/CarouselMessage.cs | 2 +- .../Messages/Message/ChoiceMessage.cs | 2 +- .../Messages/Message/ContactMessage.cs | 95 +++++-- .../{Message.cs => ConversationMessage.cs} | 26 +- .../Conversation/Messages/Message/IMessage.cs | 12 - .../Messages/Message/ListMessage.cs | 2 +- .../Messages/Message/LocationMessage.cs | 2 +- .../Messages/Message/MediaMessage.cs | 2 +- .../Messages/Message/TemplateMessage.cs | 2 +- .../Messages/Message/TextMessage.cs | 2 +- src/Sinch/Conversation/Messages/Messages.cs | 2 + ...ersation.cs => SinchConversationClient.cs} | 19 +- src/Sinch/Core/PropertyMaskQuery.cs | 20 ++ src/Sinch/SinchClient.cs | 2 +- .../Conversation/ConversationTestBase.cs | 2 +- .../Conversation/ConversationsTests.cs | 40 +++ .../Sinch.Tests/Conversation/MessagesTests.cs | 53 ++-- .../Conversation/SendMessageTests.cs | 207 +++++++-------- .../e2e/Conversation/ConversationsTests.cs | 145 +++++++++++ 32 files changed, 1192 insertions(+), 255 deletions(-) delete mode 100644 examples/Console/AccessingPolymorhycType.cs create mode 100644 examples/Console/AccessingPolymorphicType.cs create mode 100644 src/Sinch/Conversation/Conversations/Conversation.cs create mode 100644 src/Sinch/Conversation/Conversations/Conversations.cs create mode 100644 src/Sinch/Conversation/Conversations/Create/CreateConversationRequest.cs create mode 100644 src/Sinch/Conversation/Conversations/InjectMessage/InjectMessageRequest.cs create mode 100644 src/Sinch/Conversation/Conversations/List/ListConversationsRequest.cs create mode 100644 src/Sinch/Conversation/Conversations/List/ListConversationsResponse.cs create mode 100644 src/Sinch/Conversation/Conversations/MetadataUpdateStrategy.cs rename src/Sinch/Conversation/Messages/Message/{Message.cs => ConversationMessage.cs} (93%) delete mode 100644 src/Sinch/Conversation/Messages/Message/IMessage.cs rename src/Sinch/Conversation/{Conversation.cs => SinchConversationClient.cs} (68%) create mode 100644 src/Sinch/Core/PropertyMaskQuery.cs create mode 100644 tests/Sinch.Tests/Conversation/ConversationsTests.cs create mode 100644 tests/Sinch.Tests/e2e/Conversation/ConversationsTests.cs diff --git a/examples/Console/AccessingPolymorhycType.cs b/examples/Console/AccessingPolymorhycType.cs deleted file mode 100644 index e9132ada..00000000 --- a/examples/Console/AccessingPolymorhycType.cs +++ /dev/null @@ -1,27 +0,0 @@ -using Sinch; -using Sinch.Conversation.Messages.Message; - -namespace Examples -{ - public class AccessingPolymorphicType - { - public static void Example() - { - var sinchClient = new SinchClient("KEY_ID", "KEY_SECRET", "PROJECT_ID"); - var message = sinchClient.Conversation.Messages.Get("1").Result; - var messageType = message.AppMessage.Message switch - { - CardMessage cardMessage => "card", - CarouselMessage carouselMessage => "carousel", - ChoiceMessage choiceMessage => "choice", - ListMessage listMessage => "list", - LocationMessage locationMessage => "location", - MediaMessage mediaMessage => "media", - TemplateMessage templateMessage => "template", - TextMessage textMessage => "text", - _ => "none" - }; - Console.WriteLine(messageType); - } - } -} diff --git a/examples/Console/AccessingPolymorphicType.cs b/examples/Console/AccessingPolymorphicType.cs new file mode 100644 index 00000000..250d3999 --- /dev/null +++ b/examples/Console/AccessingPolymorphicType.cs @@ -0,0 +1,33 @@ +using Sinch; +using Sinch.Verification.Report.Request; +using Sinch.Verification.Report.Response; + +namespace Examples +{ + public class AccessingPolymorphicType + { + public static void Example() + { + var sinchClient = new SinchClient("KEY_ID", "KEY_SECRET", "PROJECT_ID"); + var response = sinchClient.Verification("APP_KEY", "APP_SECRET").Verification + .ReportId("id", new SmsVerificationReportRequest() + { + Sms = new SmsVerify() + { + Code = "123", + Cli = "it's a cli" + } + }).Result; + var id = response switch + { + FlashCallVerificationReportResponse flashCallVerificationReportResponse => + flashCallVerificationReportResponse.Id, + PhoneCallVerificationReportResponse phoneCallVerificationReportResponse => + phoneCallVerificationReportResponse.Id, + SmsVerificationReportResponse smsVerificationReportResponse => smsVerificationReportResponse.Id, + _ => throw new ArgumentOutOfRangeException(nameof(response)) + }; + Console.WriteLine(id); + } + } +} diff --git a/src/Sinch/Conversation/Contacts/Contact.cs b/src/Sinch/Conversation/Contacts/Contact.cs index 261dbe75..3a0f54e1 100644 --- a/src/Sinch/Conversation/Contacts/Contact.cs +++ b/src/Sinch/Conversation/Contacts/Contact.cs @@ -6,13 +6,8 @@ namespace Sinch.Conversation.Contacts { - public sealed class Contact + public sealed class Contact : PropertyMaskQuery { - /// - /// Tracks the fields which where initialized. - /// - private readonly ISet _setFields = new HashSet(); - private List _channelIdentities; private List _channelPriority; private string _displayName; @@ -30,7 +25,7 @@ public List ChannelIdentities get => _channelIdentities; set { - _setFields.Add(nameof(ChannelIdentities)); + SetFields.Add(nameof(ChannelIdentities)); _channelIdentities = value; } } @@ -44,7 +39,7 @@ public List ChannelPriority get => _channelPriority; set { - _setFields.Add(nameof(ChannelPriority)); + SetFields.Add(nameof(ChannelPriority)); _channelPriority = value; } } @@ -58,7 +53,7 @@ public string DisplayName get => _displayName; set { - _setFields.Add(nameof(DisplayName)); + SetFields.Add(nameof(DisplayName)); _displayName = value; } } @@ -72,7 +67,7 @@ public string Email get => _email; set { - _setFields.Add(nameof(Email)); + SetFields.Add(nameof(Email)); _email = value; } } @@ -86,7 +81,7 @@ public string ExternalId get => _externalId; set { - _setFields.Add(nameof(ExternalId)); + SetFields.Add(nameof(ExternalId)); _externalId = value; } } @@ -100,7 +95,7 @@ public string Id get => _id; set { - _setFields.Add(nameof(Id)); + SetFields.Add(nameof(Id)); _id = value; } } @@ -114,7 +109,7 @@ public ConversationLanguage Language get => _language; set { - _setFields.Add(nameof(Language)); + SetFields.Add(nameof(Language)); _language = value; } } @@ -128,20 +123,12 @@ public string Metadata get => _metadata; set { - _setFields.Add(nameof(Metadata)); + SetFields.Add(nameof(Metadata)); _metadata = value; } } - /// - /// Get the comma separated snake_case list of properties which were directly initialized in this object. - /// If, for example, DisplayName and Metadata were set, will return display_name,metadata - /// - /// - internal string GetPropertiesMask() - { - return string.Join(',', _setFields.Select(StringUtils.ToSnakeCase)); - } + /// /// Returns the string presentation of the object diff --git a/src/Sinch/Conversation/ConversationChannel.cs b/src/Sinch/Conversation/ConversationChannel.cs index de376761..5edb7f6a 100644 --- a/src/Sinch/Conversation/ConversationChannel.cs +++ b/src/Sinch/Conversation/ConversationChannel.cs @@ -73,5 +73,10 @@ public record ConversationChannel(string Value) : EnumRecord(Value) /// WeChat channel. /// public static readonly ConversationChannel WeChat = new("WECHAT"); + + /// + /// Channel has not been specified + /// + public static readonly ConversationChannel Unspecified = new("CHANNEL_UNSPECIFIED"); } } diff --git a/src/Sinch/Conversation/Conversations/Conversation.cs b/src/Sinch/Conversation/Conversations/Conversation.cs new file mode 100644 index 00000000..bdf7eef6 --- /dev/null +++ b/src/Sinch/Conversation/Conversations/Conversation.cs @@ -0,0 +1,147 @@ +using System; +using System.Text; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using Sinch.Core; + +namespace Sinch.Conversation.Conversations +{ + /// + /// A collection of messages exchanged between a contact and an app. Conversations are normally created on the fly by + /// Conversation API once a message is sent and there is no active conversation already. There can be only one active + /// conversation at any given time between a particular contact and an app. + /// + public sealed class Conversation : PropertyMaskQuery + { + private bool? _active; + private ConversationChannel _activeChannel; + private string _appId; + private string _contactId; + private string _correlationId; + private string _metadata; + private JsonObject _metadataJson; + + /// + /// Gets or Sets ActiveChannel + /// + public ConversationChannel ActiveChannel + { + get => _activeChannel; + set + { + SetFields.Add(nameof(ActiveChannel)); + _activeChannel = value; + } + } + + + /// + /// Flag for whether this conversation is active. + /// + public bool? Active + { + get => _active; + set + { + SetFields.Add(nameof(Active)); + _active = value; + } + } + + /// + /// The ID of the participating app. + /// + public string AppId + { + get => _appId; + set + { + SetFields.Add(nameof(AppId)); + _appId = value; + } + } + + /// + /// The ID of the participating contact. + /// + public string ContactId + { + get => _contactId; + set + { + SetFields.Add(nameof(ContactId)); + _contactId = value; + } + } + + /// + /// The ID of the conversation. + /// + public string Id { get; set; } + + /// + /// The timestamp of the latest message in the conversation. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public DateTime LastReceived { get; set; } + + /// + /// Arbitrary data set by the Conversation API clients. Up to 1024 characters long. + /// + public string Metadata + { + get => _metadata; + set + { + SetFields.Add(nameof(Metadata)); + _metadata = value; + } + } + + /// + /// Arbitrary data set by the Conversation API clients and/or provided in the conversation_metadata field of a + /// SendMessageRequest. A valid JSON object. + /// + public JsonObject MetadataJson + { + get => _metadataJson; + set + { + SetFields.Add(nameof(MetadataJson)); + _metadataJson = value; + } + } + + public string CorrelationId + { + get => _correlationId; + set + { + SetFields.Add(nameof(CorrelationId)); + _correlationId = value; + } + } + + + /// + /// Returns the string presentation of the object + /// + /// String presentation of the object + public override string ToString() + { + var sb = new StringBuilder(); + sb.Append("class Conversation {\n"); + sb.Append(" Active: ").Append(Active).Append("\n"); + sb.Append(" ActiveChannel: ").Append(ActiveChannel).Append("\n"); + sb.Append(" AppId: ").Append(AppId).Append("\n"); + sb.Append(" ContactId: ").Append(ContactId).Append("\n"); + sb.Append(" Id: ").Append(Id).Append("\n"); + sb.Append(" LastReceived: ").Append(LastReceived).Append("\n"); + sb.Append(" Metadata: ").Append(Metadata).Append("\n"); + sb.Append(" MetadataJson: ").Append(MetadataJson).Append("\n"); + sb.Append(" CorrelationId: ").Append(CorrelationId).Append("\n"); + sb.Append("}\n"); + return sb.ToString(); + } + } +} diff --git a/src/Sinch/Conversation/Conversations/Conversations.cs b/src/Sinch/Conversation/Conversations/Conversations.cs new file mode 100644 index 00000000..c20d5b95 --- /dev/null +++ b/src/Sinch/Conversation/Conversations/Conversations.cs @@ -0,0 +1,236 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using System.Web; +using Sinch.Conversation.Conversations.Create; +using Sinch.Conversation.Conversations.InjectMessage; +using Sinch.Conversation.Conversations.List; +using Sinch.Core; +using Sinch.Logger; + +namespace Sinch.Conversation.Conversations +{ + /// + /// Endpoints for working with the conversation log. + /// + public interface ISinchConversationConversations + { + /// + /// Creates a new empty conversation. It is generally not needed to create a conversation explicitly since sending or + /// receiving a message automatically creates a new conversation if it does not already exist between the given app and + /// contact. Creating empty conversation is useful if the metadata of the conversation should be populated when the + /// first message in the conversation is a contact message or the first message in the conversation comes out-of-band + /// and needs to be injected with InjectMessage endpoint. + /// + /// + /// + /// + Task Create(CreateConversationRequest request, CancellationToken cancellationToken = default); + + /// + /// This operation lists all conversations that are associated with an app and/or a contact. + /// + /// + /// + /// + Task List(ListConversationsRequest request, + CancellationToken cancellationToken = default); + + /// + /// This operation lists all conversations automatically that are associated with an app and/or a contact. + /// + /// + /// + /// + IAsyncEnumerable ListAuto(ListConversationsRequest request, + CancellationToken cancellationToken = default); + + /// + /// Retrieves a conversation by id. A conversation has two participating entities, an app and a contact. + /// + /// The unique ID of the conversation. This is generated by the system. + /// + /// + Task Get(string conversationId, CancellationToken cancellationToken = default); + + /// + /// Deletes a conversation together with all the messages sent as part of the conversation. + /// + /// The unique ID of the conversation. This is generated by the system. + /// + /// + Task Delete(string conversationId, CancellationToken cancellationToken = default); + + /// + /// This operation stops the referenced conversation, if the conversation is still active. A new conversation will be + /// created if a new message is exchanged between the app or contact that was part of the stopped conversation. + /// + /// The unique ID of the conversation. This is generated by the system. + /// + /// + Task Stop(string conversationId, CancellationToken cancellationToken = default); + + /// + /// This operation updates a conversation which can, for instance, be used to update the metadata associated with a + /// conversation. + /// + /// A conversation to update, Id should be set. + /// Update strategy for the conversation_metadata field. + /// + /// + Task Update(Conversation conversation, + MetadataUpdateStrategy metadataUpdateStrategy = null, + CancellationToken cancellationToken = default); + + /// + /// This operation injects a conversation message in to a specific conversation. + /// + /// + /// + /// + Task InjectMessage(InjectMessageRequest injectMessageRequest, CancellationToken cancellationToken = default); + } + + internal class ConversationsClient : ISinchConversationConversations + { + private readonly Uri _baseAddress; + private readonly IHttp _http; + private readonly ILoggerAdapter _logger; + private readonly string _projectId; + + public ConversationsClient(string projectId, Uri baseAddress, + ILoggerAdapter logger, IHttp http) + { + _projectId = projectId; + _baseAddress = baseAddress; + _logger = logger; + _http = http; + } + + /// + public Task Create(CreateConversationRequest request, + CancellationToken cancellationToken = default) + { + var uri = new Uri(_baseAddress, $"v1/projects/{_projectId}/conversations"); + _logger?.LogDebug("Creating a conversation for {project}", _projectId); + return _http.Send(uri, HttpMethod.Post, request, + cancellationToken); + } + + /// + public Task List(ListConversationsRequest request, + CancellationToken cancellationToken = default) + { + var uri = new Uri(_baseAddress, + $"v1/projects/{_projectId}/conversations?{Utils.ToSnakeCaseQueryString(request)}"); + _logger?.LogDebug("Listing a conversations for {project}", _projectId); + return _http.Send(uri, HttpMethod.Get, + cancellationToken); + } + + /// + public async IAsyncEnumerable ListAuto(ListConversationsRequest request, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + _logger?.LogDebug("Auto Listing conversations for {projectId}", _projectId); + do + { + var query = Utils.ToSnakeCaseQueryString(request); + var uri = new Uri(_baseAddress, $"/v1/projects/{_projectId}/conversations?{query}"); + var response = + await _http.Send(uri, HttpMethod.Get, cancellationToken); + request.PageToken = response.NextPageToken; + foreach (var conversation in response.Conversations) yield return conversation; + } while (!string.IsNullOrEmpty(request.PageToken)); + } + + /// + public Task Get(string conversationId, CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(conversationId)) + throw new ArgumentNullException(nameof(conversationId), "Should have a value"); + + var uri = new Uri(_baseAddress, + $"v1/projects/{_projectId}/conversations/{conversationId}"); + _logger?.LogDebug("Getting a {conversationId} of {project}", conversationId, _projectId); + return _http.Send(uri, HttpMethod.Get, + cancellationToken); + } + + /// + public Task Delete(string conversationId, CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(conversationId)) + throw new ArgumentNullException(nameof(conversationId), "Should have a value"); + + var uri = new Uri(_baseAddress, + $"v1/projects/{_projectId}/conversations/{conversationId}"); + _logger?.LogDebug("Deleting a {conversationId} of {project}", conversationId, _projectId); + return _http.Send(uri, HttpMethod.Delete, + cancellationToken); + } + + /// + public Task Stop(string conversationId, CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(conversationId)) + throw new ArgumentNullException(nameof(conversationId), "Should have a value"); + + var uri = new Uri(_baseAddress, + $"v1/projects/{_projectId}/conversations/{conversationId}:stop"); + _logger?.LogDebug("Stopping a {conversationId} of {project}", conversationId, _projectId); + return _http.Send(uri, HttpMethod.Post, + cancellationToken); + } + + /// + public Task Update(Conversation conversation, + MetadataUpdateStrategy metadataUpdateStrategy = null, + CancellationToken cancellationToken = default) + { + if (conversation == null) + throw new ArgumentNullException(nameof(conversation), "Conversation shouldn't be null"); + + if (string.IsNullOrEmpty(conversation.Id)) + throw new NullReferenceException($"{nameof(conversation.Id)} should have a value"); + + var builder = new UriBuilder(new Uri(_baseAddress, + $"v1/projects/{_projectId}/conversations/{conversation.Id}")); + + var queryString = HttpUtility.ParseQueryString(string.Empty); + var propMask = conversation.GetPropertiesMask(); + if (!string.IsNullOrEmpty(propMask)) queryString.Add("update_mask", propMask); + + if (metadataUpdateStrategy is not null) + queryString.Add("metadata_update_strategy", metadataUpdateStrategy.Value); + + builder.Query = queryString?.ToString()!; // it's okay to pass null. + + _logger?.LogDebug("Updating a {conversationId} of {project}", conversation.Id, _projectId); + return _http.Send(builder.Uri, HttpMethod.Patch, conversation, + cancellationToken); + } + + /// + public Task InjectMessage(InjectMessageRequest injectMessageRequest, + CancellationToken cancellationToken = default) + { + if (injectMessageRequest == null) + throw new ArgumentNullException(nameof(injectMessageRequest), "Shouldn't be null"); + + if (string.IsNullOrEmpty(injectMessageRequest.ConversationId)) + throw new NullReferenceException( + $"{nameof(injectMessageRequest)}.{nameof(injectMessageRequest.ConversationId)} should have a value"); + + var uri = new Uri(_baseAddress, + $"v1/projects/{_projectId}/conversations/{injectMessageRequest.ConversationId}:inject-message"); + _logger?.LogDebug("Injecting a message into {conversationId} of {project}", + injectMessageRequest.ConversationId, _projectId); + return _http.Send(uri, HttpMethod.Post, injectMessageRequest, + cancellationToken); + } + } +} diff --git a/src/Sinch/Conversation/Conversations/Create/CreateConversationRequest.cs b/src/Sinch/Conversation/Conversations/Create/CreateConversationRequest.cs new file mode 100644 index 00000000..f5f1dc99 --- /dev/null +++ b/src/Sinch/Conversation/Conversations/Create/CreateConversationRequest.cs @@ -0,0 +1,71 @@ +using System.Text; +using System.Text.Json.Nodes; +using Sinch.Conversation.Messages; + +namespace Sinch.Conversation.Conversations.Create +{ + public class CreateConversationRequest + { + /// + /// Gets or Sets ActiveChannel + /// + public ConversationChannel ActiveChannel { get; set; } + + /// + /// Flag for whether this conversation is active. + /// + public bool Active { get; set; } + + + /// + /// The ID of the participating app. + /// +#if NET7_0_OR_GREATER + public required string AppId { get; set; } +#else + public string AppId { get; set; } +#endif + + + /// + /// The ID of the participating contact. + /// +#if NET7_0_OR_GREATER + public required string ContactId { get; set; } +#else + public string ContactId { get; set; } +#endif + + + /// + /// Arbitrary data set by the Conversation API clients. Up to 1024 characters long. + /// + public string Metadata { get; set; } + + + /// + /// Arbitrary data set by the Conversation API clients and/or provided in the `conversation_metadata` field + /// of a SendMessageRequest. A valid JSON object. + /// + public JsonObject MetadataJson { get; set; } + + + /// + /// Returns the string presentation of the object + /// + /// String presentation of the object + public override string ToString() + { + var sb = new StringBuilder(); + sb.Append("class CreateConversationRequest {\n"); + sb.Append(" Active: ").Append(Active).Append("\n"); + sb.Append(" ActiveChannel: ").Append(ActiveChannel).Append("\n"); + sb.Append(" AppId: ").Append(AppId).Append("\n"); + sb.Append(" ContactId: ").Append(ContactId).Append("\n"); + sb.Append(" Metadata: ").Append(Metadata).Append("\n"); + sb.Append(" MetadataJson: ").Append(MetadataJson).Append("\n"); + sb.Append("}\n"); + return sb.ToString(); + } + } +} diff --git a/src/Sinch/Conversation/Conversations/InjectMessage/InjectMessageRequest.cs b/src/Sinch/Conversation/Conversations/InjectMessage/InjectMessageRequest.cs new file mode 100644 index 00000000..bddc27ba --- /dev/null +++ b/src/Sinch/Conversation/Conversations/InjectMessage/InjectMessageRequest.cs @@ -0,0 +1,81 @@ +using System; +using System.Text; +using System.Text.Json.Serialization; +using Sinch.Conversation.Messages; +using Sinch.Conversation.Messages.Message; + +namespace Sinch.Conversation.Conversations.InjectMessage +{ + /// + /// A message on a particular channel. + /// + public sealed class InjectMessageRequest + { + [JsonIgnore] + public string ConversationId { get; set; } + + /// + /// Gets or Sets Direction + /// + public ConversationDirection Direction { get; set; } + + /// + /// The processed time of the message in UTC timezone. Must be less than current_time and greater than (current_time - + /// 30 days) + /// +#if NET7_0_OR_GREATER + public required DateTime AcceptTime { get; set; } +#else + public DateTime AcceptTime { get; set; } +#endif + + /// + /// Gets or Sets AppMessage + /// + public AppMessage AppMessage { get; set; } + + + /// + /// Gets or Sets ChannelIdentity + /// + public ChannelIdentity ChannelIdentity { get; set; } + + + /// + /// The ID of the contact registered in the conversation provided. + /// + public string ContactId { get; set; } + + + /// + /// Gets or Sets ContactMessage + /// + public ContactMessage ContactMessage { get; set; } + + + /// + /// Optional. Metadata associated with the contact. Up to 1024 characters long. + /// + public string Metadata { get; set; } + + + /// + /// Returns the string presentation of the object + /// + /// String presentation of the object + public override string ToString() + { + var sb = new StringBuilder(); + sb.Append("class ConversationMessageInjected {\n"); + sb.Append(" AcceptTime: ").Append(AcceptTime).Append("\n"); + sb.Append(" AppMessage: ").Append(AppMessage).Append("\n"); + sb.Append(" ChannelIdentity: ").Append(ChannelIdentity).Append("\n"); + sb.Append(" ContactId: ").Append(ContactId).Append("\n"); + sb.Append(" ContactMessage: ").Append(ContactMessage).Append("\n"); + sb.Append(" Direction: ").Append(Direction).Append("\n"); + sb.Append(" Metadata: ").Append(Metadata).Append("\n"); + sb.Append("}\n"); + return sb.ToString(); + } + } +} diff --git a/src/Sinch/Conversation/Conversations/List/ListConversationsRequest.cs b/src/Sinch/Conversation/Conversations/List/ListConversationsRequest.cs new file mode 100644 index 00000000..a23ce757 --- /dev/null +++ b/src/Sinch/Conversation/Conversations/List/ListConversationsRequest.cs @@ -0,0 +1,41 @@ +using Sinch.Conversation.Messages; + +namespace Sinch.Conversation.Conversations.List +{ + public sealed class ListConversationsRequest + { + /// + /// Required. True if only active conversations should be listed. + /// +#if NET7_0_OR_GREATER + public required bool OnlyActive { get; set; } +#else + public bool OnlyActive { get; set; } +#endif + + /// + /// At least one of app_id or contact_id must be present. + /// + public string AppId { get; set; } + + /// + /// At least one of app_id or contact_id must be present. + /// + public string ContactId { get; set; } + + /// + /// The maximum number of conversations to fetch. Defaults to 10 and the maximum is 20. + /// + public int? PageSize { get; set; } + + /// + /// Next page token previously returned if any. + /// + public string PageToken { get; set; } + + /// + /// Only fetch conversations from the active_channel + /// + public ConversationChannel ActiveChannel { get; set; } + } +} diff --git a/src/Sinch/Conversation/Conversations/List/ListConversationsResponse.cs b/src/Sinch/Conversation/Conversations/List/ListConversationsResponse.cs new file mode 100644 index 00000000..eee9fc7f --- /dev/null +++ b/src/Sinch/Conversation/Conversations/List/ListConversationsResponse.cs @@ -0,0 +1,22 @@ +using System.Collections.Generic; + +namespace Sinch.Conversation.Conversations.List +{ + public sealed class ListConversationsResponse + { + /// + /// List of conversations matching the search query. + /// + public IEnumerable Conversations { get; set; } + + /// + /// Token that should be included in the next request to fetch the next page. + /// + public string NextPageToken { get; set; } + + /// + /// Total count of conversations + /// + public int TotalSize { get; set; } + } +} diff --git a/src/Sinch/Conversation/Conversations/MetadataUpdateStrategy.cs b/src/Sinch/Conversation/Conversations/MetadataUpdateStrategy.cs new file mode 100644 index 00000000..e83bb1d2 --- /dev/null +++ b/src/Sinch/Conversation/Conversations/MetadataUpdateStrategy.cs @@ -0,0 +1,21 @@ +using Sinch.Core; + +namespace Sinch.Conversation.Conversations +{ + /// + /// Update strategy for the conversation_metadata field. + /// + /// + public record MetadataUpdateStrategy(string Value) : EnumRecord(Value) + { + /// + /// The default strategy. Replaces the whole conversation_metadata field with the new value provided. + /// + public static readonly MetadataUpdateStrategy Replace = new("REPLACE"); + + /// + /// Patches the conversation_metadata field with the patch provided according to RFC 7386. + /// + public static readonly MetadataUpdateStrategy MergePatch = new("MERGE_PATCH"); + } +} diff --git a/src/Sinch/Conversation/Messages/Message/AppMessage.cs b/src/Sinch/Conversation/Messages/Message/AppMessage.cs index bfc52754..5c437ba7 100644 --- a/src/Sinch/Conversation/Messages/Message/AppMessage.cs +++ b/src/Sinch/Conversation/Messages/Message/AppMessage.cs @@ -1,24 +1,101 @@ -using System.Text; +using System; +using System.Text; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; namespace Sinch.Conversation.Messages.Message { public class AppMessage { + // Thank you System.Text.Json -_- + [JsonConstructor] + [Obsolete("Needed for System.Text.Json", true)] + public AppMessage() + { + } + + public AppMessage(ChoiceMessage choiceMessage) + { + ChoiceMessage = choiceMessage; + } + + public AppMessage(LocationMessage locationMessage) + { + LocationMessage = locationMessage; + } + + public AppMessage(MediaMessage mediaMessage) + { + MediaMessage = mediaMessage; + } + + public AppMessage(TemplateMessage templateMessage) + { + TemplateMessage = templateMessage; + } + + public AppMessage(ListMessage listMessage) + { + ListMessage = listMessage; + } + + public AppMessage(TextMessage textMessage) + { + TextMessage = textMessage; + } + + public AppMessage(CardMessage cardMessage) + { + CardMessage = cardMessage; + } + + public AppMessage(CarouselMessage carouselMessage) + { + CarouselMessage = carouselMessage; + } + /// /// Optional. Channel specific messages, overriding any transcoding. /// The key in the map must point to a valid conversation channel as defined by the enum ConversationChannel. /// - public object ExplicitChannelMessage { get; set; } + public JsonObject ExplicitChannelMessage { get; set; } /// /// Gets or Sets AdditionalProperties /// public AppMessageAdditionalProperties AdditionalProperties { get; set; } - - /// - /// Message originating from an app - /// - public IMessage Message { get; set; } + + [JsonInclude] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public TextMessage TextMessage { get; private set; } + + [JsonInclude] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public CardMessage CardMessage { get; private set; } + + [JsonInclude] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public CarouselMessage CarouselMessage { get; private set; } + + [JsonInclude] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ChoiceMessage ChoiceMessage { get; private set; } + + [JsonInclude] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public LocationMessage LocationMessage { get; private set; } + + [JsonInclude] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public MediaMessage MediaMessage { get; private set; } + + [JsonInclude] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public TemplateMessage TemplateMessage { get; private set; } + + [JsonInclude] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ListMessage ListMessage { get; private set; } } /// diff --git a/src/Sinch/Conversation/Messages/Message/CardMessage.cs b/src/Sinch/Conversation/Messages/Message/CardMessage.cs index a8fd1abf..466071d6 100644 --- a/src/Sinch/Conversation/Messages/Message/CardMessage.cs +++ b/src/Sinch/Conversation/Messages/Message/CardMessage.cs @@ -7,7 +7,7 @@ namespace Sinch.Conversation.Messages.Message /// /// Message containing text, media and choices. /// - public sealed class CardMessage : IMessage + public sealed class CardMessage { /// /// Gets or Sets Height diff --git a/src/Sinch/Conversation/Messages/Message/CarouselMessage.cs b/src/Sinch/Conversation/Messages/Message/CarouselMessage.cs index fcd16a04..ec0489a5 100644 --- a/src/Sinch/Conversation/Messages/Message/CarouselMessage.cs +++ b/src/Sinch/Conversation/Messages/Message/CarouselMessage.cs @@ -3,7 +3,7 @@ namespace Sinch.Conversation.Messages.Message { - public class CarouselMessage : IMessage + public class CarouselMessage { /// /// A list of up to 10 cards. diff --git a/src/Sinch/Conversation/Messages/Message/ChoiceMessage.cs b/src/Sinch/Conversation/Messages/Message/ChoiceMessage.cs index dddeb0de..5def173b 100644 --- a/src/Sinch/Conversation/Messages/Message/ChoiceMessage.cs +++ b/src/Sinch/Conversation/Messages/Message/ChoiceMessage.cs @@ -4,7 +4,7 @@ namespace Sinch.Conversation.Messages.Message { - public class ChoiceMessage : IMessage + public class ChoiceMessage { /// /// The number of choices is limited to 10. diff --git a/src/Sinch/Conversation/Messages/Message/ContactMessage.cs b/src/Sinch/Conversation/Messages/Message/ContactMessage.cs index b037298f..8d17748f 100644 --- a/src/Sinch/Conversation/Messages/Message/ContactMessage.cs +++ b/src/Sinch/Conversation/Messages/Message/ContactMessage.cs @@ -1,50 +1,108 @@ -using System.Text; +using System; +using System.Text; +using System.Text.Json.Serialization; namespace Sinch.Conversation.Messages.Message { - public class ContactMessage -{ /// + { + // Thank you System.Text.Json -_- + [JsonConstructor] + [Obsolete("Needed for System.Text.Json", true)] + public ContactMessage() + { + } + + public ContactMessage(ChoiceResponseMessage choiceResponseMessage) + { + ChoiceResponseMessage = choiceResponseMessage; + } + + public ContactMessage(FallbackMessage fallbackMessage) + { + FallbackMessage = fallbackMessage; + } + + public ContactMessage(LocationMessage locationMessage) + { + LocationMessage = locationMessage; + } + + public ContactMessage(MediaCarouselMessage mediaCardMessage) + { + MediaCardMessage = mediaCardMessage; + } + + public ContactMessage(MediaMessage mediaMessage) + { + MediaMessage = mediaMessage; + } + + public ContactMessage(ReplyTo replyTo) + { + ReplyTo = replyTo; + } + + public ContactMessage(TextMessage textMessage) + { + TextMessage = textMessage; + } + + /// /// Gets or Sets ChoiceResponseMessage /// - public ChoiceResponseMessage ChoiceResponseMessage { get; set; } - + [JsonInclude] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ChoiceResponseMessage ChoiceResponseMessage { get; private set; } + /// /// Gets or Sets FallbackMessage /// - public FallbackMessage FallbackMessage { get; set; } - + [JsonInclude] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public FallbackMessage FallbackMessage { get; private set; } + /// /// Gets or Sets LocationMessage /// - public LocationMessage LocationMessage { get; set; } - + [JsonInclude] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public LocationMessage LocationMessage { get; private set; } + /// /// Gets or Sets MediaCardMessage /// - public MediaCarouselMessage MediaCardMessage { get; set; } - + [JsonInclude] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public MediaCarouselMessage MediaCardMessage { get; private set; } + /// /// Gets or Sets MediaMessage /// - public MediaMessage MediaMessage { get; set; } - + [JsonInclude] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public MediaMessage MediaMessage { get; private set; } + /// /// Gets or Sets ReplyTo /// - public ReplyTo ReplyTo { get; set; } - + [JsonInclude] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ReplyTo ReplyTo { get; private set; } + /// /// Gets or Sets TextMessage /// - public TextMessage TextMessage { get; set; } - + [JsonInclude] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public TextMessage TextMessage { get; private set; } + /// /// Returns the string presentation of the object @@ -64,6 +122,5 @@ public override string ToString() sb.Append("}\n"); return sb.ToString(); } + } } -} - diff --git a/src/Sinch/Conversation/Messages/Message/Message.cs b/src/Sinch/Conversation/Messages/Message/ConversationMessage.cs similarity index 93% rename from src/Sinch/Conversation/Messages/Message/Message.cs rename to src/Sinch/Conversation/Messages/Message/ConversationMessage.cs index 77f04cf8..1e1fba12 100644 --- a/src/Sinch/Conversation/Messages/Message/Message.cs +++ b/src/Sinch/Conversation/Messages/Message/ConversationMessage.cs @@ -1,69 +1,71 @@ using System; using System.Text; +using System.Text.Json.Serialization; namespace Sinch.Conversation.Messages.Message { public class ConversationMessage { - /// - /// Gets or Sets Direction + /// + /// Gets or Sets Direction /// public ConversationDirection Direction { get; set; } /// /// The time Conversation API processed the message. /// + [JsonInclude] public DateTime AcceptTime { get; private set; } - + /// /// Gets or Sets AppMessage /// public AppMessage AppMessage { get; set; } - + /// /// Gets or Sets ChannelIdentity /// public ChannelIdentity ChannelIdentity { get; set; } - + /// /// The ID of the contact. /// public string ContactId { get; set; } - + /// /// Gets or Sets ContactMessage /// public ContactMessage ContactMessage { get; set; } - + /// /// The ID of the conversation. /// public string ConversationId { get; set; } - + /// /// The ID of the message. /// public string Id { get; set; } - + /// /// Optional. Metadata associated with the contact. Up to 1024 characters long. /// public string Metadata { get; set; } - + /// /// Flag for whether this message was injected. /// - public bool Injected { get; private set; } - + public bool Injected { get; } + /// /// Returns the string presentation of the object diff --git a/src/Sinch/Conversation/Messages/Message/IMessage.cs b/src/Sinch/Conversation/Messages/Message/IMessage.cs deleted file mode 100644 index 9308e75a..00000000 --- a/src/Sinch/Conversation/Messages/Message/IMessage.cs +++ /dev/null @@ -1,12 +0,0 @@ -using Sinch.Core; - -namespace Sinch.Conversation.Messages.Message -{ - /// - /// Marker interface for conversation messages types. - /// - [JsonInterfaceConverter(typeof(InterfaceConverter))] - public interface IMessage - { - } -} diff --git a/src/Sinch/Conversation/Messages/Message/ListMessage.cs b/src/Sinch/Conversation/Messages/Message/ListMessage.cs index eeb80fac..e36bfa01 100644 --- a/src/Sinch/Conversation/Messages/Message/ListMessage.cs +++ b/src/Sinch/Conversation/Messages/Message/ListMessage.cs @@ -7,7 +7,7 @@ namespace Sinch.Conversation.Messages.Message /// /// A message containing a list of options to choose from /// - public sealed class ListMessage : IMessage + public sealed class ListMessage { /// /// A title for the message that is displayed near the products or choices. diff --git a/src/Sinch/Conversation/Messages/Message/LocationMessage.cs b/src/Sinch/Conversation/Messages/Message/LocationMessage.cs index 8693a22b..30b9b2aa 100644 --- a/src/Sinch/Conversation/Messages/Message/LocationMessage.cs +++ b/src/Sinch/Conversation/Messages/Message/LocationMessage.cs @@ -5,7 +5,7 @@ namespace Sinch.Conversation.Messages.Message /// /// Message containing geographic location. /// - public sealed class LocationMessage : IMessage + public sealed class LocationMessage { /// /// Gets or Sets Coordinates diff --git a/src/Sinch/Conversation/Messages/Message/MediaMessage.cs b/src/Sinch/Conversation/Messages/Message/MediaMessage.cs index 86907879..36eaa65b 100644 --- a/src/Sinch/Conversation/Messages/Message/MediaMessage.cs +++ b/src/Sinch/Conversation/Messages/Message/MediaMessage.cs @@ -6,7 +6,7 @@ namespace Sinch.Conversation.Messages.Message /// /// A message containing a media component, such as an image, document, or video. /// - public sealed class MediaMessage : IMessage + public sealed class MediaMessage { /// /// An optional parameter. Will be used where it is natively supported. diff --git a/src/Sinch/Conversation/Messages/Message/TemplateMessage.cs b/src/Sinch/Conversation/Messages/Message/TemplateMessage.cs index 69002e41..a7d87ad7 100644 --- a/src/Sinch/Conversation/Messages/Message/TemplateMessage.cs +++ b/src/Sinch/Conversation/Messages/Message/TemplateMessage.cs @@ -7,7 +7,7 @@ namespace Sinch.Conversation.Messages.Message /// /// TemplateMessage /// - public sealed class TemplateMessage : IMessage + public sealed class TemplateMessage { /// /// Optional. Channel specific template reference with parameters per channel. diff --git a/src/Sinch/Conversation/Messages/Message/TextMessage.cs b/src/Sinch/Conversation/Messages/Message/TextMessage.cs index 08b144dd..5d994dd5 100644 --- a/src/Sinch/Conversation/Messages/Message/TextMessage.cs +++ b/src/Sinch/Conversation/Messages/Message/TextMessage.cs @@ -5,7 +5,7 @@ namespace Sinch.Conversation.Messages.Message /// /// A message containing only text. /// - public sealed class TextMessage : IMessage + public sealed class TextMessage { /// /// A message containing only text. diff --git a/src/Sinch/Conversation/Messages/Messages.cs b/src/Sinch/Conversation/Messages/Messages.cs index 2be65e51..44678122 100644 --- a/src/Sinch/Conversation/Messages/Messages.cs +++ b/src/Sinch/Conversation/Messages/Messages.cs @@ -96,6 +96,8 @@ public Task Send(SendMessageRequest request, CancellationTo _logger?.LogDebug("Sending a message..."); return _http.Send(uri, HttpMethod.Post, request, cancellationToken: cancellationToken); } + + //TODO: add simplified send text to app of recipient /// public Task Get(string messageId, MessageSource messagesSource = default, diff --git a/src/Sinch/Conversation/Conversation.cs b/src/Sinch/Conversation/SinchConversationClient.cs similarity index 68% rename from src/Sinch/Conversation/Conversation.cs rename to src/Sinch/Conversation/SinchConversationClient.cs index 0e6f25d6..68ac711d 100644 --- a/src/Sinch/Conversation/Conversation.cs +++ b/src/Sinch/Conversation/SinchConversationClient.cs @@ -1,6 +1,7 @@ using System; using Sinch.Conversation.Apps; using Sinch.Conversation.Contacts; +using Sinch.Conversation.Conversations; using Sinch.Conversation.Messages; using Sinch.Core; using Sinch.Logger; @@ -9,8 +10,7 @@ namespace Sinch.Conversation { /// /// Send and receive messages globally over SMS, RCS, WhatsApp, Viber Business, - /// Facebook messenger and other popular channels using the Sinch Conversation API.

- /// + /// Facebook messenger and other popular channels using the Sinch Conversation API.

/// The Conversation API endpoint uses built-in transcoding to give you the power of conversation across all /// supported channels and, if required, full control over channel specific features. ///
@@ -24,17 +24,23 @@ public interface ISinchConversation /// ISinchConversationContacts Contacts { get; } + + /// + ISinchConversationConversations Conversations { get; } } /// - internal class Conversation : ISinchConversation + internal class SinchConversationClient : ISinchConversation { - internal Conversation(string projectId, Uri baseAddress, LoggerFactory loggerFactory, IHttp http) + internal SinchConversationClient(string projectId, Uri baseAddress, LoggerFactory loggerFactory, IHttp http) { Messages = new Messages.Messages(projectId, baseAddress, loggerFactory?.Create(), http); Apps = new Apps.Apps(projectId, baseAddress, loggerFactory?.Create(), http); - Contacts = new Contacts.Contacts(projectId, baseAddress, loggerFactory?.Create(), http); + Contacts = new Contacts.Contacts(projectId, baseAddress, + loggerFactory?.Create(), http); + Conversations = new ConversationsClient(projectId, baseAddress, + loggerFactory?.Create(), http); } /// @@ -45,5 +51,8 @@ internal Conversation(string projectId, Uri baseAddress, LoggerFactory loggerFac /// public ISinchConversationContacts Contacts { get; } + + /// + public ISinchConversationConversations Conversations { get; } } } diff --git a/src/Sinch/Core/PropertyMaskQuery.cs b/src/Sinch/Core/PropertyMaskQuery.cs new file mode 100644 index 00000000..5191e348 --- /dev/null +++ b/src/Sinch/Core/PropertyMaskQuery.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; +using System.Linq; + +namespace Sinch.Core +{ + public abstract class PropertyMaskQuery + { + protected readonly ISet SetFields = new HashSet(); + + /// + /// Get the comma separated snake_case list of properties which were directly initialized in this object. + /// If, for example, DisplayName and Metadata were set, will return display_name,metadata + /// + /// + internal string GetPropertiesMask() + { + return string.Join(',', SetFields.Select(StringUtils.ToSnakeCase)); + } + } +} diff --git a/src/Sinch/SinchClient.cs b/src/Sinch/SinchClient.cs index 237c7d2d..b6e2413a 100644 --- a/src/Sinch/SinchClient.cs +++ b/src/Sinch/SinchClient.cs @@ -182,7 +182,7 @@ public SinchClient(string keyId, string keySecret, string projectId, Sms = new Sms(projectId, GetSmsBaseAddress(optionsObj.SmsHostingRegion, _apiUrlOverrides?.SmsUrl), _loggerFactory, httpSnakeCase); - Conversation = new Conversation.Conversation(projectId, + Conversation = new Conversation.SinchConversationClient(projectId, new Uri(_apiUrlOverrides?.ConversationUrl ?? string.Format(ConversationApiUrlTemplate, optionsObj.ConversationRegion.Value)), _loggerFactory, httpSnakeCase); diff --git a/tests/Sinch.Tests/Conversation/ConversationTestBase.cs b/tests/Sinch.Tests/Conversation/ConversationTestBase.cs index 48d43dbe..d5718580 100644 --- a/tests/Sinch.Tests/Conversation/ConversationTestBase.cs +++ b/tests/Sinch.Tests/Conversation/ConversationTestBase.cs @@ -9,7 +9,7 @@ public class ConversationTestBase : TestBase protected ConversationTestBase() { - Conversation = new Sinch.Conversation.Conversation(ProjectId, new Uri("https://us.conversation.api.sinch.com"), + Conversation = new Sinch.Conversation.SinchConversationClient(ProjectId, new Uri("https://us.conversation.api.sinch.com"), default, HttpSnakeCase); } } diff --git a/tests/Sinch.Tests/Conversation/ConversationsTests.cs b/tests/Sinch.Tests/Conversation/ConversationsTests.cs new file mode 100644 index 00000000..edf8b865 --- /dev/null +++ b/tests/Sinch.Tests/Conversation/ConversationsTests.cs @@ -0,0 +1,40 @@ +using System.Text.Json.Nodes; +using FluentAssertions; +using Xunit; + +namespace Sinch.Tests.Conversation +{ + public class ConversationsTests + { + [Fact] + public void UpdateMaskConversation() + { + var conversation = new Sinch.Conversation.Conversations.Conversation + { + ActiveChannel = null, + Active = true, + AppId = "null", + ContactId = "id", + Id = "1", + Metadata = "n", + MetadataJson = new JsonObject(), + CorrelationId = string.Empty + }; + + conversation.GetPropertiesMask().Should().BeEquivalentTo( + "active_channel,active,app_id,contact_id,metadata,metadata_json,correlation_id"); + } + + [Fact] + public void UpdateMaskConversationOnlyOneField() + { + var conversation = new Sinch.Conversation.Conversations.Conversation + { + AppId = "AppId", + }; + + conversation.GetPropertiesMask().Should().BeEquivalentTo( + "app_id"); + } + } +} diff --git a/tests/Sinch.Tests/Conversation/MessagesTests.cs b/tests/Sinch.Tests/Conversation/MessagesTests.cs index 44b7571c..9b0aedcb 100644 --- a/tests/Sinch.Tests/Conversation/MessagesTests.cs +++ b/tests/Sinch.Tests/Conversation/MessagesTests.cs @@ -31,43 +31,42 @@ public async Task GetMessage() var response = await Conversation.Messages.Get(messageId, MessageSource.ConversationSource); response.Should().NotBeNull(); - response.AppMessage.Message.Should().BeOfType() - .Which.Should().BeEquivalentTo(new ListMessage + response.AppMessage.ListMessage.Should().BeEquivalentTo(new ListMessage + { + Title = "title", + Sections = new List() { - Title = "title", - Sections = new List() + new ListSection() { - new ListSection() + Title = "sec1", + Items = new List() { - Title = "sec1", - Items = new List() + new ListItemChoice() { - new ListItemChoice() + Title = "title", + Description = "desc", + Media = new MediaMessage() { - Title = "title", - Description = "desc", - Media = new MediaMessage() - { - Url = new Uri("http://localhost") - }, - PostbackData = "postback" - } + Url = new Uri("http://localhost") + }, + PostbackData = "postback" } - }, - new ListSection() + } + }, + new ListSection() + { + Title = "sec2", + Items = new List() { - Title = "sec2", - Items = new List() + new ListItemProduct() { - new ListItemProduct() - { - Id = "id", - Marketplace = "amazon" - } + Id = "id", + Marketplace = "amazon" } } } - }); + } + }); response.Direction.Should().Be(ConversationDirection.UndefinedDirection); response.ContactMessage.ReplyTo.MessageId.Should().Be("string"); response.ChannelIdentity.Should().BeEquivalentTo(new ChannelIdentity() @@ -193,7 +192,7 @@ private static object Message() accept_time = "2019-08-24T14:15:22Z", app_message = new { - message = new + list_message = new { title = "title", sections = new dynamic[] diff --git a/tests/Sinch.Tests/Conversation/SendMessageTests.cs b/tests/Sinch.Tests/Conversation/SendMessageTests.cs index a6d007b9..867ffc12 100644 --- a/tests/Sinch.Tests/Conversation/SendMessageTests.cs +++ b/tests/Sinch.Tests/Conversation/SendMessageTests.cs @@ -23,9 +23,8 @@ public class SendMessageTests : ConversationTestBase private readonly SendMessageRequest _baseRequest = new SendMessageRequest { AppId = "123", - Message = new AppMessage() + Message = new AppMessage(new TextMessage("I'm a texter")) { - Message = new TextMessage("I'm a texter"), ExplicitChannelMessage = null, AdditionalProperties = null }, @@ -58,7 +57,7 @@ public SendMessageTests() [Fact] public async Task SendText() { - _baseMessageExpected.message.message = new + _baseMessageExpected.message.text_message = new { text = "I'm a texter" }; @@ -78,7 +77,7 @@ public async Task SendText() [Fact] public async Task SendLocation() { - _baseMessageExpected.message.message = new + _baseMessageExpected.message.location_message = new { label = "label", title = "title", @@ -88,15 +87,12 @@ public async Task SendLocation() longitude = 4.20f, } }; - _baseRequest.Message = new AppMessage() + _baseRequest.Message = new AppMessage(new LocationMessage { - Message = new LocationMessage - { - Coordinates = new Coordinates(3.18f, 4.20f), - Label = "label", - Title = "title" - } - }; + Coordinates = new Coordinates(3.18f, 4.20f), + Label = "label", + Title = "title" + }); HttpMessageHandlerMock .When(HttpMethod.Post, @@ -113,7 +109,7 @@ public async Task SendLocation() [Fact] public async Task SendCarousel() { - _baseMessageExpected.message.message = new + _baseMessageExpected.message.carousel_message = new { cards = new[] { @@ -151,40 +147,37 @@ public async Task SendCarousel() } } }; - _baseRequest.Message = new AppMessage() + _baseRequest.Message = new AppMessage(new CarouselMessage() { - Message = new CarouselMessage() + Cards = new List() { - Cards = new List() + new() { - new() + Description = "card description", + Title = "Title Card", + Height = CardHeight.Tall, + MediaMessage = new MediaCarouselMessage() { - Description = "card description", - Title = "Title Card", - Height = CardHeight.Tall, - MediaMessage = new MediaCarouselMessage() - { - Caption = "cap", - Url = new Uri("https://localmob"), - }, - Choices = new List + Caption = "cap", + Url = new Uri("https://localmob"), + }, + Choices = new List + { + new Choice { - new Choice - { - CallMessage = new("123", "Jhon"), - } + CallMessage = new("123", "Jhon"), } } - }, - Choices = new List() + } + }, + Choices = new List() + { + new Choice() { - new Choice() - { - TextMessage = new TextMessage("123") - } + TextMessage = new TextMessage("123") } } - }; + }); HttpMessageHandlerMock .When(HttpMethod.Post, _sendUrl) @@ -199,7 +192,7 @@ public async Task SendCarousel() [Fact] public async Task SendChoice() { - _baseMessageExpected.message.message = new + _baseMessageExpected.message.choice_message = new { choices = new[] { @@ -217,21 +210,18 @@ public async Task SendChoice() text = "123", } }; - _baseRequest.Message = new AppMessage() + _baseRequest.Message = new AppMessage(new ChoiceMessage() { - Message = new ChoiceMessage() + Choices = new List() { - Choices = new List() + new Choice() { - new Choice() - { - TextMessage = new TextMessage("123"), - PostbackData = "postback" - } - }, - TextMessage = new TextMessage("123") - } - }; + TextMessage = new TextMessage("123"), + PostbackData = "postback" + } + }, + TextMessage = new TextMessage("123") + }); HttpMessageHandlerMock .When(HttpMethod.Post, _sendUrl) @@ -246,19 +236,16 @@ public async Task SendChoice() [Fact] public async Task SendMedia() { - _baseMessageExpected.message.message = new + _baseMessageExpected.message.media_message = new { url = "http://yup/ls", thumbnail_url = "https://img.c", }; - _baseRequest.Message = new AppMessage() + _baseRequest.Message = new AppMessage(new MediaMessage { - Message = new MediaMessage - { - Url = new Uri("http://yup/ls"), - ThumbnailUrl = new Uri("https://img.c") - } - }; + Url = new Uri("http://yup/ls"), + ThumbnailUrl = new Uri("https://img.c") + }); HttpMessageHandlerMock .When(HttpMethod.Post, _sendUrl) @@ -273,7 +260,7 @@ public async Task SendMedia() [Fact] public async Task SendTemplate() { - _baseMessageExpected.message.message = new + _baseMessageExpected.message.template_message = new { omni_template = new { @@ -299,37 +286,34 @@ public async Task SendTemplate() } } }; - _baseRequest.Message = new AppMessage() + _baseRequest.Message = new AppMessage(new TemplateMessage() { - Message = new TemplateMessage() + OmniTemplate = new TemplateReference { - OmniTemplate = new TemplateReference + LanguageCode = "es", + Parameters = new Dictionary() { - LanguageCode = "es", - Parameters = new Dictionary() - { - { "key", "val" } - }, - TemplateId = "tempid", - Version = "1.0" + { "key", "val" } }, - ChannelTemplate = new Dictionary() + TemplateId = "tempid", + Version = "1.0" + }, + ChannelTemplate = new Dictionary() + { { + "test", new TemplateReference { - "test", new TemplateReference + TemplateId = "abc", + Version = "305", + Parameters = new Dictionary() { - TemplateId = "abc", - Version = "305", - Parameters = new Dictionary() - { - { "tarnished", "order" } - }, - LanguageCode = "de" - } + { "tarnished", "order" } + }, + LanguageCode = "de" } } } - }; + }); HttpMessageHandlerMock .When(HttpMethod.Post, _sendUrl) @@ -344,7 +328,7 @@ public async Task SendTemplate() [Fact] public async Task SendList() { - _baseMessageExpected.message.message = new + _baseMessageExpected.message.list_message = new { title = "list_title", description = "description", @@ -383,48 +367,45 @@ public async Task SendList() } } }; - _baseRequest.Message = new AppMessage() + _baseRequest.Message = new AppMessage(new ListMessage { - Message = new ListMessage + Title = "list_title", + Description = "description", + Sections = new List() { - Title = "list_title", - Description = "description", - Sections = new List() + new ListSection() { - new ListSection() + Title = "item1", + Items = new List() { - Title = "item1", - Items = new List() + new ListItemChoice() { - new ListItemChoice() - { - Title = "listitemchoice", - PostbackData = "postno", - Description = "desc", - Media = new MediaMessage() - { - Url = new Uri("https://nolocalhost"), - ThumbnailUrl = new Uri("https://knowyourmeme.com/photos/377946") - } - }, - new ListItemProduct + Title = "listitemchoice", + PostbackData = "postno", + Description = "desc", + Media = new MediaMessage() { - Id = "prod_id", - Marketplace = "amazon", - Currency = "eur", - Quantity = 20, - ItemPrice = 12.1000004f, + Url = new Uri("https://nolocalhost"), + ThumbnailUrl = new Uri("https://knowyourmeme.com/photos/377946") } + }, + new ListItemProduct + { + Id = "prod_id", + Marketplace = "amazon", + Currency = "eur", + Quantity = 20, + ItemPrice = 12.1000004f, } } - }, - MessageProperties = new ListMessageMessageProperties() - { - Menu = "omenu", - CatalogId = "id1" } + }, + MessageProperties = new ListMessageMessageProperties() + { + Menu = "omenu", + CatalogId = "id1" } - }; + }); HttpMessageHandlerMock .When(HttpMethod.Post, _sendUrl) @@ -439,7 +420,7 @@ public async Task SendList() [Fact] public async Task SendAllParams() { - _baseMessageExpected.message.message = new + _baseMessageExpected.message.text_message = new { text = "I'm a texter" }; diff --git a/tests/Sinch.Tests/e2e/Conversation/ConversationsTests.cs b/tests/Sinch.Tests/e2e/Conversation/ConversationsTests.cs new file mode 100644 index 00000000..54c009fe --- /dev/null +++ b/tests/Sinch.Tests/e2e/Conversation/ConversationsTests.cs @@ -0,0 +1,145 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text.Json.Nodes; +using System.Threading.Tasks; +using FluentAssertions; +using Sinch.Conversation; +using Sinch.Conversation.Conversations; +using Sinch.Conversation.Conversations.Create; +using Sinch.Conversation.Conversations.InjectMessage; +using Sinch.Conversation.Conversations.List; +using Sinch.Conversation.Messages; +using Sinch.Conversation.Messages.Message; +using Xunit; + +namespace Sinch.Tests.e2e.Conversation +{ + public class ConversationsTests : TestBase + { + private readonly Sinch.Conversation.Conversations.Conversation _conversation = + new() + { + Id = "01HMXSGKNG4HCAW3XXTVK6Q8WD", + AppId = "01HKWRC164GNT3K2GCPK5W2B1J", + ContactId = "01HKWT3XRVH6RP17S8KSBC4PYR", + ActiveChannel = ConversationChannel.Instagram, + Active = true, + Metadata = "meta", + CorrelationId = "cor_id", + MetadataJson = new JsonObject + { + ["hi"] = "hi2", + ["hey"] = new JsonArray("a", "b") + }, + LastReceived = DateTime.Parse("1970-01-01T00:00:00Z", CultureInfo.InvariantCulture).ToUniversalTime() + }; + + [Fact] + public async Task Create() + { + var response = await SinchClientMockServer.Conversation.Conversations.Create(new CreateConversationRequest + { + AppId = "01HKWRC164GNT3K2GCPK5W2B1J", + ActiveChannel = ConversationChannel.Instagram, + Active = true, + Metadata = "meta", + ContactId = "01HKWT3XRVH6RP17S8KSBC4PYR", + MetadataJson = new JsonObject + { + ["hi"] = "hi2", + ["hey"] = new JsonArray("a", "b") + } + }); + + response.Should().BeEquivalentTo(_conversation, options => + options.ExcludingNestedObjects().Excluding(x => x.MetadataJson)); + ValidateMetadata(response); + } + + [Fact] + public async Task Get() + { + var response = await SinchClientMockServer.Conversation.Conversations.Get(_conversation.Id); + + response.Should().BeEquivalentTo(_conversation, options => + options.ExcludingNestedObjects().Excluding(x => x.MetadataJson)); + ValidateMetadata(response); + } + + [Fact] + public async Task List() + { + var response = await SinchClientMockServer.Conversation.Conversations.List(new ListConversationsRequest + { + AppId = "01HKWRC164GNT3K2GCPK5W2B1J", + ActiveChannel = ConversationChannel.KakaoTalkChat, + PageSize = 10, + PageToken = "ABC", + ContactId = "01HKWT3XRVH6RP17S8KSBC4PYR", + OnlyActive = true + }); + response.Conversations.Should().HaveCount(2); + response.TotalSize.Should().Be(2); + response.NextPageToken.Should().BeEquivalentTo("abc"); + } + + [Fact] + public async Task Delete() + { + var op = () => SinchClientMockServer.Conversation.Conversations.Delete(_conversation.Id); + await op.Should().NotThrowAsync(); + } + + [Fact] + public async Task Update() + { + var response = await + SinchClientMockServer.Conversation.Conversations.Update(_conversation, + MetadataUpdateStrategy.MergePatch); + + response.Should().BeEquivalentTo(_conversation, options => + options.ExcludingNestedObjects().Excluding(x => x.MetadataJson)); + ValidateMetadata(response); + } + + [Fact] + public async Task Inject() + { + var op = () => SinchClientMockServer.Conversation.Conversations.InjectMessage(new InjectMessageRequest + { + Direction = ConversationDirection.ToApp, + AcceptTime = DateTime.Parse("1970-01-01T00:00:00Z", CultureInfo.InvariantCulture).ToUniversalTime(), + AppMessage = new AppMessage(new TextMessage("hi")), + ChannelIdentity = new ChannelIdentity + { + Identity = "01HN31W37910AANG1JGE8Y6RFF", + Channel = ConversationChannel.Instagram + }, + ContactId = _conversation.ContactId, + ConversationId = _conversation.Id, + Metadata = "meta", + ContactMessage = new ContactMessage(new TextMessage("oi")) + }); + await op.Should().NotThrowAsync(); + } + + [Fact] + public async Task Stop() + { + var op = () => SinchClientMockServer.Conversation.Conversations.Stop(_conversation.Id); + await op.Should().NotThrowAsync(); + } + + private static void ValidateMetadata(Sinch.Conversation.Conversations.Conversation response) + { + response.MetadataJson["hi"]!.ToString().Should().BeEquivalentTo("hi2"); + response.MetadataJson["hey"]!.AsArray().Select(x => x.ToString()).ToList().Should().BeEquivalentTo( + new List + { + "a", "b" + }); + } + } +}