diff --git a/examples/Console/Program.cs b/examples/Console/Program.cs index dc68da64..a79ad1f2 100644 --- a/examples/Console/Program.cs +++ b/examples/Console/Program.cs @@ -9,7 +9,7 @@ Environment.GetEnvironmentVariable("SINCH_KEY_SECRET")!, Environment.GetEnvironmentVariable("SINCH_PROJECT_ID")!); -var verification = sinch.Verification(Environment.GetEnvironmentVariable("SINCH_APP_KEY")!, - Environment.GetEnvironmentVariable("SINCH_APP_SECRET")!, AuthStrategy.ApplicationSign); +_ = sinch.Verification(Environment.GetEnvironmentVariable("SINCH_APP_KEY")!, + Environment.GetEnvironmentVariable("SINCH_APP_SECRET")!); Console.ReadLine(); diff --git a/src/Sinch/Conversation/Messages/Messages.cs b/src/Sinch/Conversation/Messages/Messages.cs index 44678122..efbbe617 100644 --- a/src/Sinch/Conversation/Messages/Messages.cs +++ b/src/Sinch/Conversation/Messages/Messages.cs @@ -78,10 +78,10 @@ internal class Messages : ISinchConversationMessages { private readonly Uri _baseAddress; private readonly IHttp _http; - private readonly ILoggerAdapter _logger; + private readonly ILoggerAdapter _logger; private readonly string _projectId; - public Messages(string projectId, Uri baseAddress, ILoggerAdapter logger, IHttp http) + public Messages(string projectId, Uri baseAddress, ILoggerAdapter logger, IHttp http) { _projectId = projectId; _baseAddress = baseAddress; diff --git a/src/Sinch/Conversation/SinchConversationClient.cs b/src/Sinch/Conversation/SinchConversationClient.cs index 68ac711d..47e418be 100644 --- a/src/Sinch/Conversation/SinchConversationClient.cs +++ b/src/Sinch/Conversation/SinchConversationClient.cs @@ -3,6 +3,7 @@ using Sinch.Conversation.Contacts; using Sinch.Conversation.Conversations; using Sinch.Conversation.Messages; +using Sinch.Conversation.Webhooks; using Sinch.Core; using Sinch.Logger; @@ -27,6 +28,9 @@ public interface ISinchConversation /// ISinchConversationConversations Conversations { get; } + + /// + ISinchConversationWebhooks Webhooks { get; } } /// @@ -34,13 +38,16 @@ internal class SinchConversationClient : ISinchConversation { internal SinchConversationClient(string projectId, Uri baseAddress, LoggerFactory loggerFactory, IHttp http) { - Messages = new Messages.Messages(projectId, baseAddress, loggerFactory?.Create(), + 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); Conversations = new ConversationsClient(projectId, baseAddress, loggerFactory?.Create(), http); + Webhooks = new Webhooks.Webhooks(projectId, baseAddress, + loggerFactory?.Create(), http); } /// @@ -54,5 +61,7 @@ internal SinchConversationClient(string projectId, Uri baseAddress, LoggerFactor /// public ISinchConversationConversations Conversations { get; } + + public ISinchConversationWebhooks Webhooks { get; } } } diff --git a/src/Sinch/Conversation/Webhooks/ClientCredentials.cs b/src/Sinch/Conversation/Webhooks/ClientCredentials.cs new file mode 100644 index 00000000..392653ac --- /dev/null +++ b/src/Sinch/Conversation/Webhooks/ClientCredentials.cs @@ -0,0 +1,56 @@ +using System.Text; + +namespace Sinch.Conversation.Webhooks +{ + /// + /// Optional. Used for OAuth2 authentication. + /// + public sealed class ClientCredentials + { + /// + /// The Client ID that will be used in the OAuth2 Client Credentials flow. + /// +#if NET7_0_OR_GREATER + public required string ClientId { get; set; } +#else + public string ClientId { get; set; } +#endif + + + /// + /// The Client Secret that will be used in the OAuth2 Client Credentials flow. + /// +#if NET7_0_OR_GREATER + public required string ClientSecret { get; set; } +#else + public string ClientSecret { get; set; } +#endif + + + /// + /// The endpoint that will be used in the OAuth2 Client Credentials flow. Expected to return a JSON with an access + /// token and `expires_in` value (in seconds). The `expires_in` value, which must be a minimum of + /// 30 seconds and a maximum of 3600 seconds, is how long Sinch will save the access token before asking for a new one. + /// +#if NET7_0_OR_GREATER + public required string Endpoint { get; set; } +#else + public string Endpoint { get; set; } +#endif + + + /// + /// Returns the string presentation of the object + /// + /// String presentation of the object + public override string ToString() + { + var sb = new StringBuilder(); + sb.Append("class ClientCredentials {\n"); + sb.Append(" ClientId: ").Append(ClientId).Append("\n"); + sb.Append(" Endpoint: ").Append(Endpoint).Append("\n"); + sb.Append("}\n"); + return sb.ToString(); + } + } +} diff --git a/src/Sinch/Conversation/Webhooks/Webhook.cs b/src/Sinch/Conversation/Webhooks/Webhook.cs new file mode 100644 index 00000000..b57b7c06 --- /dev/null +++ b/src/Sinch/Conversation/Webhooks/Webhook.cs @@ -0,0 +1,145 @@ +using System.Collections.Generic; +using System.Text; +using Sinch.Core; + +namespace Sinch.Conversation.Webhooks +{ + /// + /// Represents a destination for receiving callbacks from the Conversation API. + /// + public class Webhook : PropertyMaskQuery + { + private WebhookTargetType _targetType; + private string _appId; + private ClientCredentials _clientCredentials; + private string _id; + private string _secret; + private string _target; + private List _triggers; + + /// + /// Gets or sets the target type. + /// + public WebhookTargetType TargetType + { + get => _targetType; + set + { + SetFields.Add(nameof(TargetType)); + _targetType = value; + } + } + + /// + /// The app that this webhook belongs to. + /// +#if NET7_0_OR_GREATER + public required string AppId +#else + public string AppId +#endif + { + get => _appId; + set + { + SetFields.Add(nameof(AppId)); + _appId = value; + } + } + + /// + /// Gets or sets the client credentials. + /// + public ClientCredentials ClientCredentials + { + get => _clientCredentials; + set + { + SetFields.Add(nameof(ClientCredentials)); + _clientCredentials = value; + } + } + + /// + /// Gets or sets the ID of the webhook. + /// + public string Id + { + get => _id; + set + { + SetFields.Add(nameof(Id)); + _id = value; + } + } + + /// + /// Optional secret to be used to sign contents of webhooks sent by the Conversation API. + /// You can then use the secret to verify the signature. + /// + public string Secret + { + get => _secret; + set + { + SetFields.Add(nameof(Secret)); + _secret = value; + } + } + + /// + /// Gets or sets the target URL where events should be sent to. + /// Maximum URL length is 742. The conversation-api.*.sinch.com subdomains are forbidden. + /// +#if NET7_0_OR_GREATER + public required string Target +#else + public string Target +#endif + { + get => _target; + set + { + SetFields.Add(nameof(Target)); + _target = value; + } + } + + /// + /// An array of triggers that should trigger the webhook and result in an event being sent to the target URL. + /// Refer to the list of [Webhook Triggers](https://developers.sinch.com/docs/conversation/callbacks#webhook-triggers) + /// for a complete list. + /// +#if NET7_0_OR_GREATER + public required List Triggers +#else + public List Triggers +#endif + { + get => _triggers; + set + { + SetFields.Add(nameof(Triggers)); + _triggers = value; + } + } + + /// + /// Returns the string presentation of the object. + /// + /// String presentation of the object. + public override string ToString() + { + var sb = new StringBuilder(); + sb.Append("class Webhook {\n"); + sb.Append(" AppId: ").Append(AppId).Append("\n"); + sb.Append(" ClientCredentials: ").Append(ClientCredentials).Append("\n"); + sb.Append(" Id: ").Append(Id).Append("\n"); + sb.Append(" Target: ").Append(Target).Append("\n"); + sb.Append(" TargetType: ").Append(TargetType).Append("\n"); + sb.Append(" Triggers: ").Append(Triggers).Append("\n"); + sb.Append("}\n"); + return sb.ToString(); + } + } +} diff --git a/src/Sinch/Conversation/Webhooks/WebhookTargetType.cs b/src/Sinch/Conversation/Webhooks/WebhookTargetType.cs new file mode 100644 index 00000000..4aa1abc9 --- /dev/null +++ b/src/Sinch/Conversation/Webhooks/WebhookTargetType.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; +using Sinch.Core; + +namespace Sinch.Conversation.Webhooks +{ + /// + /// Defines WebhookTargetType + /// + [JsonConverter(typeof(EnumRecordJsonConverter))] + public record WebhookTargetType(string Value) : EnumRecord(Value) + { + public static readonly WebhookTargetType Dismiss = new("DISMISS"); + public static readonly WebhookTargetType Http = new("HTTP"); + } +} diff --git a/src/Sinch/Conversation/Webhooks/WebhookTrigger.cs b/src/Sinch/Conversation/Webhooks/WebhookTrigger.cs new file mode 100644 index 00000000..2b116a4e --- /dev/null +++ b/src/Sinch/Conversation/Webhooks/WebhookTrigger.cs @@ -0,0 +1,55 @@ +using System.Text.Json.Serialization; +using Sinch.Core; + +namespace Sinch.Conversation.Webhooks +{ + /// + /// - `UNSPECIFIED_TRIGGER`: Using this value will cause errors. - `MESSAGE_DELIVERY`: Subscribe to + /// delivery receipts for a message sent. - `MESSAGE_SUBMIT`: Subscribe to message submission notifications. + /// - `EVENT_DELIVERY`: Subscribe to delivery receipts for a event sent. - `MESSAGE_INBOUND`: + /// Subscribe to inbound messages from end users on the underlying channels. - `SMART_CONVERSATION`: These + /// triggers allow you to subscribe to payloads that provide machine learning analyses of inbound messages from end + /// users on the underlying channels - `MESSAGE_INBOUND_SMART_CONVERSATION_REDACTION`: These triggers allow + /// you to subscribe to payloads that deliver redacted versions of inbound messages - `EVENT_INBOUND`: + /// Subscribe to inbound events from end users on the underlying channels. - `CONVERSATION_START`: Subscribe + /// to an event that is triggered when a new conversation has been started. - `CONVERSATION_STOP`: Subscribe + /// to an event that is triggered when an active conversation has been stopped. - `CONTACT_CREATE`: Subscribe + /// to an event that is triggered when a new contact has been created. - `CONTACT_DELETE`: Subscribe to an + /// event that is triggered when a contact has been deleted. - `CONTACT_MERGE`: Subscribe to an event that is + /// triggered when two contacts are merged. - `CONTACT_UPDATE`: Subscribe to an event that is triggered when + /// a contact is updated. - `UNSUPPORTED`: Subscribe to callbacks that are not natively supported by the + /// Conversation API. - `OPT_IN`: Subscribe to opt_ins. - `OPT_OUT`: Subscribe to opt_outs. - + /// `CAPABILITY`: Subscribe to see get capability results. - `CHANNEL_EVENT`: Subscribe to channel + /// event notifications. - `CONVERSATION_DELETE`: Subscribe to get an event when a conversation is deleted. - + /// `CONTACT_IDENTITIES_DUPLICATION`: Subscribe to get an event when contact identity duplications are found + /// during message or event processing. - `SMART_CONVERSATIONS`: Subscribe to smart conversations callback + /// + [JsonConverter(typeof(EnumRecordJsonConverter))] + public record WebhookTrigger(string Value) : EnumRecord(Value) + { + public static readonly WebhookTrigger UnspecifiedTrigger = new("UNSPECIFIED_TRIGGER"); + public static readonly WebhookTrigger MessageDelivery = new("MESSAGE_DELIVERY"); + public static readonly WebhookTrigger MessageSubmit = new("MESSAGE_SUBMIT"); + public static readonly WebhookTrigger EventDelivery = new("EVENT_DELIVERY"); + public static readonly WebhookTrigger MessageInbound = new("MESSAGE_INBOUND"); + public static readonly WebhookTrigger SmartConversation = new("SMART_CONVERSATION"); + + public static readonly WebhookTrigger MessageInboundSmartConversationRedaction = + new("MESSAGE_INBOUND_SMART_CONVERSATION_REDACTION"); + + public static readonly WebhookTrigger EventInbound = new("EVENT_INBOUND"); + public static readonly WebhookTrigger ConversationStart = new("CONVERSATION_START"); + public static readonly WebhookTrigger ConversationStop = new("CONVERSATION_STOP"); + public static readonly WebhookTrigger ContactCreate = new("CONTACT_CREATE"); + public static readonly WebhookTrigger ContactDelete = new("CONTACT_DELETE"); + public static readonly WebhookTrigger ContactMerge = new("CONTACT_MERGE"); + public static readonly WebhookTrigger ContactUpdate = new("CONTACT_UPDATE"); + public static readonly WebhookTrigger Unsupported = new("UNSUPPORTED"); + public static readonly WebhookTrigger OptIn = new("OPT_IN"); + public static readonly WebhookTrigger OptOut = new("OPT_OUT"); + public static readonly WebhookTrigger Capability = new("CAPABILITY"); + public static readonly WebhookTrigger ChannelEvent = new("CHANNEL_EVENT"); + public static readonly WebhookTrigger ConversationDelete = new("CONVERSATION_DELETE"); + public static readonly WebhookTrigger ContactIdentitiesDuplication = new("CONTACT_IDENTITIES_DUPLICATION"); + } +} diff --git a/src/Sinch/Conversation/Webhooks/Webhooks.cs b/src/Sinch/Conversation/Webhooks/Webhooks.cs new file mode 100644 index 00000000..cb2dac15 --- /dev/null +++ b/src/Sinch/Conversation/Webhooks/Webhooks.cs @@ -0,0 +1,158 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using System.Web; +using Sinch.Core; +using Sinch.Logger; + +namespace Sinch.Conversation.Webhooks +{ + /// + /// Manage your webhooks with this set of methods. + /// + public interface ISinchConversationWebhooks + { + /// + /// Creates a webhook for receiving callbacks on specific triggers. You can create up to 5 webhooks per app. + /// + /// + /// + /// + Task Create(Webhook request, CancellationToken cancellationToken = default); + + /// + /// Get a webhook as specified by the webhook ID. + /// + /// The unique ID of the webhook. + /// + /// + Task Get(string webhookId, CancellationToken cancellationToken = default); + + /// + /// List all webhooks for a given app as specified by the App ID. + /// + /// + /// The unique ID of the app. You can find this on the [Sinch + /// Dashboard](https://dashboard.sinch.com/convapi/apps). + /// + /// + /// + Task> List(string appId, CancellationToken cancellationToken = default); + + /// + /// Updates an existing webhook as specified by the webhook ID. + /// + /// Don't forget to provide the ID of the webhook in the object. + /// + /// + Task Update(Webhook webhook, CancellationToken cancellationToken = default); + + /// + /// Deletes a webhook as specified by the webhook ID. + /// + /// The unique ID of the webhook. + /// + /// + Task Delete(string webhookId, CancellationToken cancellationToken = default); + } + + /// + internal class Webhooks : ISinchConversationWebhooks + { + private readonly Uri _baseAddress; + private readonly IHttp _http; + private readonly ILoggerAdapter _logger; + private readonly string _projectId; + + public Webhooks(string projectId, Uri baseAddress, ILoggerAdapter logger, + IHttp http) + { + _projectId = projectId; + _baseAddress = baseAddress; + _logger = logger; + _http = http; + } + + /// + public Task Create(Webhook request, CancellationToken cancellationToken = default) + { + var uri = new Uri(_baseAddress, $"/v1/projects/{_projectId}/webhooks"); + _logger?.LogDebug("Creating a webhook..."); + return _http.Send(uri, HttpMethod.Post, request, + cancellationToken); + } + + /// + public Task Get(string webhookId, CancellationToken cancellationToken = default) + { + var uri = new Uri(_baseAddress, $"/v1/projects/{_projectId}/webhooks/{webhookId}"); + _logger?.LogDebug("Getting a webhook with {id}...", webhookId); + return _http.Send(uri, HttpMethod.Get, + cancellationToken); + } + + /// + public async Task> List(string appId, CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(appId)) + { + throw new ArgumentNullException(nameof(appId), "Should have a value"); + } + + var uri = new Uri(_baseAddress, $"/v1/projects/{_projectId}/apps/{appId}/webhooks"); + _logger?.LogDebug("Listing webhooks for an {appId}...", appId); + var response = await _http.Send(uri, HttpMethod.Get, + cancellationToken); + return response.Webhooks; + } + + /// + public Task Update(Webhook webhook, CancellationToken cancellationToken = default) + { + if (webhook is null) + { + throw new ArgumentNullException(nameof(webhook), "Should have a value"); + } + + if (string.IsNullOrEmpty(webhook.Id)) + { + throw new NullReferenceException($"{nameof(webhook)}.{nameof(webhook.Id)} shouldn't be null"); + } + + var uri = new Uri(_baseAddress, $"/v1/projects/{_projectId}/webhooks/{webhook.Id}"); + + var builder = new UriBuilder(uri); + var queryString = HttpUtility.ParseQueryString(string.Empty); + var propMask = webhook.GetPropertiesMask(); + if (!string.IsNullOrEmpty(propMask)) queryString.Add("update_mask", propMask); + builder.Query = queryString?.ToString()!; + + _logger?.LogDebug("Updating a webhook with {id}...", webhook.Id); + return _http.Send(builder.Uri, HttpMethod.Patch, webhook, + cancellationToken); + } + + + + /// + public Task Delete(string webhookId, CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(webhookId)) + { + throw new ArgumentNullException(nameof(webhookId), "Should have a value"); + } + var uri = new Uri(_baseAddress, $"/v1/projects/{_projectId}/webhooks/{webhookId}"); + _logger?.LogDebug("Deleting a webhook with {id}...", webhookId); + return _http.Send(uri, HttpMethod.Delete, + cancellationToken); + } + } + + internal class ListWebhooksResponse + { + // ReSharper disable once CollectionNeverUpdated.Global + public List Webhooks { get; set; } + } +} diff --git a/src/Sinch/SMS/DeliveryReports/Get/GetDeliveryReportResponse.cs b/src/Sinch/SMS/DeliveryReports/Get/GetDeliveryReportResponse.cs index c26f1465..ac7ae0cb 100644 --- a/src/Sinch/SMS/DeliveryReports/Get/GetDeliveryReportResponse.cs +++ b/src/Sinch/SMS/DeliveryReports/Get/GetDeliveryReportResponse.cs @@ -4,6 +4,8 @@ namespace Sinch.SMS.DeliveryReports.Get { public sealed class GetDeliveryReportResponse { +#pragma warning disable CS1570 + /// /// The type of webhook for the delivery report. /// Returns a either a full or summary delivery report depending on what was set in the batch. @@ -13,6 +15,7 @@ public sealed class GetDeliveryReportResponse /// the difference between the two. /// /// +#pragma warning restore CA2200 public DeliveryReportType Type { get; set; } /// diff --git a/tests/Sinch.Tests/e2e/Conversation/WebhooksTests.cs b/tests/Sinch.Tests/e2e/Conversation/WebhooksTests.cs new file mode 100644 index 00000000..c3f67b4d --- /dev/null +++ b/tests/Sinch.Tests/e2e/Conversation/WebhooksTests.cs @@ -0,0 +1,103 @@ +using System.Collections.Generic; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using Sinch.Conversation.Webhooks; +using Xunit; + +namespace Sinch.Tests.e2e.Conversation +{ + public class WebhooksTests : TestBase + { + private const string SomeStringValue = "some_string_value"; + + private readonly Webhook _webhookResponse = new Webhook() + { + Id = SomeStringValue, + AppId = SomeStringValue, + Secret = SomeStringValue, + TargetType = WebhookTargetType.Http, + Target = SomeStringValue, + Triggers = new List() + { + WebhookTrigger.MessageDelivery + }, + ClientCredentials = new ClientCredentials() + { + Endpoint = SomeStringValue, + ClientId = SomeStringValue, + ClientSecret = SomeStringValue + } + }; + + [Fact] + public async Task Create() + { + var response = await SinchClientMockServer.Conversation.Webhooks.Create(new Webhook() + { + AppId = "APPID", + Secret = "secret", + Target = "http://localhost:8080", + TargetType = WebhookTargetType.Http, + Triggers = new List() + { + WebhookTrigger.Capability + }, + ClientCredentials = new ClientCredentials() + { + Endpoint = "a", + ClientId = "b", + ClientSecret = "c" + }, + }); + response.Should().BeEquivalentTo(_webhookResponse); + } + + [Fact] + public async Task CreateInvalidValue() + { + var op = () => SinchClientMockServer.Conversation.Webhooks.Create(new Webhook() + { + AppId = "APPID", + Target = "http://localhost:8080", + TargetType = new WebhookTargetType("CUSTOM_UNKNOWN"), + Triggers = new List() + { + WebhookTrigger.Capability + }, + }); + // mock server should not match this request, ideally it should throw the exception with detailed message + // - for this case - target_type enum have an invalid value + // but I'm testing matching request from open-api-spec file + await op.Should().ThrowAsync().Where(x => x.StatusCode == HttpStatusCode.NotFound); + } + + [Fact] + public async Task Get() + { + var response = await SinchClientMockServer.Conversation.Webhooks.Get("123"); + response.Should().BeEquivalentTo(_webhookResponse); + } + + [Fact] + public async Task Delete() + { + var op = () => SinchClientMockServer.Conversation.Webhooks.Delete("123"); + await op.Should().NotThrowAsync(); + } + + [Fact] + public async Task List() + { + var response = await SinchClientMockServer.Conversation.Webhooks.List("appid"); + response.Should().HaveCount(1); + } + + [Fact] + public async Task Update() + { + var response = await SinchClientMockServer.Conversation.Webhooks.Update(_webhookResponse); + response.Should().BeEquivalentTo(_webhookResponse); + } + } +}