diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ad286d..20a1d51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `Validation` utility class for specifications as MSISDN - Docs rendering using DocFX +- `Personal` as enum value for `ContentCategory` +- WhatsApp support for + - `AudioMessageContent` + - `DocumentMessageContent` + - `ImageMessageContent` + - `TextMessageContent` + - `VideoMessageContent` ### Changed diff --git a/src/LinkMobility/Enums/ContentCategory.cs b/src/LinkMobility/Enums/ContentCategory.cs index b6ea67b..a3c69f3 100644 --- a/src/LinkMobility/Enums/ContentCategory.cs +++ b/src/LinkMobility/Enums/ContentCategory.cs @@ -20,5 +20,11 @@ namespace AMWD.Net.Api.LinkMobility /// [EnumMember(Value = "advertisement")] Advertisement = 2, + + /// + /// Represents content that is classified as a personal message. + /// + [EnumMember(Value = "personal")] + Personal = 3, } } diff --git a/src/LinkMobility/README.md b/src/LinkMobility/README.md index 1a0bd52..8b949c6 100644 --- a/src/LinkMobility/README.md +++ b/src/LinkMobility/README.md @@ -9,6 +9,7 @@ LINK Mobility is a provider for communication with customers via SMS, RCS or Wha In this project the REST API of LINK Mobility will be implemented. - [SMS API](https://developer.linkmobility.eu/sms-api/rest-api) +- [WhatsApp API](https://developer.linkmobility.eu/whatsapp-api/rest-api) (partial, see Changelog) --- diff --git a/src/LinkMobility/Utils/UnixTimestampJsonConverter.cs b/src/LinkMobility/Utils/UnixTimestampJsonConverter.cs new file mode 100644 index 0000000..16bfbf1 --- /dev/null +++ b/src/LinkMobility/Utils/UnixTimestampJsonConverter.cs @@ -0,0 +1,40 @@ +namespace AMWD.Net.Api.LinkMobility.Utils +{ + internal class UnixTimestampJsonConverter : JsonConverter + { + private static readonly DateTime _unixEpoch = new(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); + + public override bool CanConvert(Type objectType) + { + return typeof(DateTime?).IsAssignableFrom(objectType); + } + + public override object? ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer) + { + long? ts = serializer.Deserialize(reader); + + if (ts.HasValue) + return _unixEpoch.AddSeconds(ts.Value); + + return null; + } + + public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) + { + if (value == null) + { + writer.WriteNull(); + return; + } + + if (value is DateTime dt) + { + long unixTimestamp = (long)(dt.ToUniversalTime() - _unixEpoch).TotalSeconds; + writer.WriteValue(unixTimestamp); + return; + } + + throw new JsonSerializationException("Expected date object value."); + } + } +} diff --git a/src/LinkMobility/Webhook/WhatsApp/Contact.cs b/src/LinkMobility/Webhook/WhatsApp/Contact.cs new file mode 100644 index 0000000..a64c78e --- /dev/null +++ b/src/LinkMobility/Webhook/WhatsApp/Contact.cs @@ -0,0 +1,32 @@ +namespace AMWD.Net.Api.LinkMobility.Webhook.WhatsApp +{ + /// + /// Represents a WhatsApp contact. + /// + public class Contact + { + /// + /// The user profile information. + /// + [JsonProperty("profile")] + public Profile? Profile { get; set; } + + /// + /// WhatsApp user ID. + /// + /// + /// Note that a WhatsApp user's ID and phone number may not always match. + /// + [JsonProperty("wa_id")] + public string? WhatsAppId { get; set; } + + /// + /// Identity key hash. + /// + /// + /// Only included if you have enabled the identity change check feature. + /// + [JsonProperty("identity_key_hash")] + public string? IdentityKeyHash { get; set; } + } +} diff --git a/src/LinkMobility/Webhook/WhatsApp/Contents/AudioContent.cs b/src/LinkMobility/Webhook/WhatsApp/Contents/AudioContent.cs new file mode 100644 index 0000000..258100a --- /dev/null +++ b/src/LinkMobility/Webhook/WhatsApp/Contents/AudioContent.cs @@ -0,0 +1,45 @@ +namespace AMWD.Net.Api.LinkMobility.Webhook.WhatsApp +{ + /// + /// Represents a received audio file. + /// + public class AudioContent + { + /// + /// Media asset MIME type. + /// + [JsonProperty("mime_type")] + public string? MimeType { get; set; } + + /// + /// Media asset SHA-256 hash. + /// + [JsonProperty("sha256")] + public string? Sha256 { get; set; } + + /// + /// Media asset ID. + /// + /// + /// You can perform a GET on this ID to get the asset URL, + /// then perform a GET on the returned URL (using your access token) to get the underlying asset. + /// + [JsonProperty("id")] + public string? Id { get; set; } + + /// + /// Media URL. + /// + /// + /// You can query this URL directly with your access token to download the media asset. + /// + [JsonProperty("url")] + public string? Url { get; set; } + + /// + /// indicating if audio is a recording made with the WhatsApp client voice recording feature. + /// + [JsonProperty("voice")] + public bool? IsVoiceRecord { get; set; } + } +} diff --git a/src/LinkMobility/Webhook/WhatsApp/Contents/DocumentContent.cs b/src/LinkMobility/Webhook/WhatsApp/Contents/DocumentContent.cs new file mode 100644 index 0000000..dd874b8 --- /dev/null +++ b/src/LinkMobility/Webhook/WhatsApp/Contents/DocumentContent.cs @@ -0,0 +1,51 @@ +namespace AMWD.Net.Api.LinkMobility.Webhook.WhatsApp +{ + /// + /// Represents a received document file. + /// + public class DocumentContent + { + /// + /// Media asset caption text. + /// + [JsonProperty("caption")] + public string? Caption { get; set; } + + /// + /// Media asset filename. + /// + [JsonProperty("filename")] + public string? Filename { get; set; } + + /// + /// Media asset MIME type. + /// + [JsonProperty("mime_type")] + public string? MimeType { get; set; } + + /// + /// Media asset SHA-256 hash. + /// + [JsonProperty("sha256")] + public string? Sha256 { get; set; } + + /// + /// Media asset ID. + /// + /// + /// You can perform a GET on this ID to get the asset URL, + /// then perform a GET on the returned URL (using your access token) to get the underlying asset. + /// + [JsonProperty("id")] + public string? Id { get; set; } + + /// + /// Media URL. + /// + /// + /// You can query this URL directly with your access token to download the media asset. + /// + [JsonProperty("url")] + public string? Url { get; set; } + } +} diff --git a/src/LinkMobility/Webhook/WhatsApp/Contents/ImageContent.cs b/src/LinkMobility/Webhook/WhatsApp/Contents/ImageContent.cs new file mode 100644 index 0000000..020d258 --- /dev/null +++ b/src/LinkMobility/Webhook/WhatsApp/Contents/ImageContent.cs @@ -0,0 +1,45 @@ +namespace AMWD.Net.Api.LinkMobility.Webhook.WhatsApp +{ + /// + /// Represents a received image file. + /// + public class ImageContent + { + /// + /// Media asset caption text. + /// + [JsonProperty("caption")] + public string? Caption { get; set; } + + /// + /// Media asset MIME type. + /// + [JsonProperty("mime_type")] + public string? MimeType { get; set; } + + /// + /// Media asset SHA-256 hash. + /// + [JsonProperty("sha256")] + public string? Sha256 { get; set; } + + /// + /// Media asset ID. + /// + /// + /// You can perform a GET on this ID to get the asset URL, + /// then perform a GET on the returned URL (using your access token) to get the underlying asset. + /// + [JsonProperty("id")] + public string? Id { get; set; } + + /// + /// Media URL. + /// + /// + /// You can query this URL directly with your access token to download the media asset. + /// + [JsonProperty("url")] + public string? Url { get; set; } + } +} diff --git a/src/LinkMobility/Webhook/WhatsApp/Contents/TextContent.cs b/src/LinkMobility/Webhook/WhatsApp/Contents/TextContent.cs new file mode 100644 index 0000000..4d94e99 --- /dev/null +++ b/src/LinkMobility/Webhook/WhatsApp/Contents/TextContent.cs @@ -0,0 +1,14 @@ +namespace AMWD.Net.Api.LinkMobility.Webhook.WhatsApp +{ + /// + /// Represents a received text. + /// + public class TextContent + { + /// + /// The text content of the message. + /// + [JsonProperty("body")] + public string? Body { get; set; } + } +} diff --git a/src/LinkMobility/Webhook/WhatsApp/Contents/VideoContent.cs b/src/LinkMobility/Webhook/WhatsApp/Contents/VideoContent.cs new file mode 100644 index 0000000..74629e5 --- /dev/null +++ b/src/LinkMobility/Webhook/WhatsApp/Contents/VideoContent.cs @@ -0,0 +1,45 @@ +namespace AMWD.Net.Api.LinkMobility.Webhook.WhatsApp +{ + /// + /// Represents a received video file. + /// + public class VideoContent + { + /// + /// Media asset caption text. + /// + [JsonProperty("caption")] + public string? Caption { get; set; } + + /// + /// Media asset MIME type. + /// + [JsonProperty("mime_type")] + public string? MimeType { get; set; } + + /// + /// Media asset SHA-256 hash. + /// + [JsonProperty("sha256")] + public string? Sha256 { get; set; } + + /// + /// Media asset ID. + /// + /// + /// You can perform a GET on this ID to get the asset URL, + /// then perform a GET on the returned URL (using your access token) to get the underlying asset. + /// + [JsonProperty("id")] + public string? Id { get; set; } + + /// + /// Media URL. + /// + /// + /// You can query this URL directly with your access token to download the media asset. + /// + [JsonProperty("url")] + public string? Url { get; set; } + } +} diff --git a/src/LinkMobility/Webhook/WhatsApp/Conversation.cs b/src/LinkMobility/Webhook/WhatsApp/Conversation.cs new file mode 100644 index 0000000..391aa3c --- /dev/null +++ b/src/LinkMobility/Webhook/WhatsApp/Conversation.cs @@ -0,0 +1,66 @@ +using AMWD.Net.Api.LinkMobility.Utils; + +namespace AMWD.Net.Api.LinkMobility.Webhook.WhatsApp +{ + /// + /// The conversation information. + /// + /// + /// + /// Only included with sent status, and one of either delivered or read status. + /// Omitted entirely for v24.0+ unless webhook is for a free entry point conversation. + /// + /// + public class Conversation + { + /// + /// Unique identifier for the conversation. + /// + /// + /// + /// Version 24.0 and higher: + ///
+ /// The object will be omitted entirely, + /// unless the webhook is for a message sent within an open free entry point window, + /// in which case the value will be unique per window. + ///
+ /// + /// Version 23.0 and lower: + ///
+ /// Value will now be set to a unique ID per-message, + /// unless the webhook is for a message sent with an open free entry point window, + /// in which case the value will be unique per window. + ///
+ ///
+ [JsonProperty("id")] + public string? Id { get; set; } + + /// + /// Timestamp indicating when the conversation will expire. + /// + /// + /// The expiration_timestamp property is only included for status. + /// + [JsonProperty("expiration_timestamp")] + [JsonConverter(typeof(UnixTimestampJsonConverter))] + public DateTime? ExpirationTimestamp { get; set; } + + /// + /// The conversation origin. + /// + [JsonProperty("origin")] + public ConversationOrigin? Origin { get; set; } + } + + /// + /// The conversation origin. + /// + public class ConversationOrigin + { + /// + /// The conversation category. + /// + [JsonProperty("type")] + public BillingCategory? Type { get; set; } + } +} diff --git a/src/LinkMobility/Webhook/WhatsApp/Enums/BillingCategory.cs b/src/LinkMobility/Webhook/WhatsApp/Enums/BillingCategory.cs new file mode 100644 index 0000000..18f7383 --- /dev/null +++ b/src/LinkMobility/Webhook/WhatsApp/Enums/BillingCategory.cs @@ -0,0 +1,54 @@ +using System.Runtime.Serialization; +using Newtonsoft.Json.Converters; + +namespace AMWD.Net.Api.LinkMobility.Webhook.WhatsApp +{ + /// + /// Defines the available pricing category (rates). + /// + [JsonConverter(typeof(StringEnumConverter))] + public enum BillingCategory + { + /// + /// Indicates an authentication conversation. + /// + [EnumMember(Value = "authentication")] + Authentication = 1, + + /// + /// Indicates an authentication-international conversation. + /// + [EnumMember(Value = "authentication_international")] + AuthenticationInternational = 2, + + /// + /// Indicates a marketing conversation. + /// + [EnumMember(Value = "marketing")] + Marketing = 3, + + /// + /// Indicates a Marketing Messages Lite API conversation. + /// + [EnumMember(Value = "marketing_lite")] + MarketingLite = 4, + + /// + /// Indicates a free entry point conversation. + /// + [EnumMember(Value = "referral_conversion")] + ReferralConversion = 5, + + /// + /// Indicates a service conversation. + /// + [EnumMember(Value = "service")] + Service = 6, + + /// + /// Indicates a utility conversation. + /// + [EnumMember(Value = "utility")] + Utility = 7, + } +} diff --git a/src/LinkMobility/Webhook/WhatsApp/Enums/BillingType.cs b/src/LinkMobility/Webhook/WhatsApp/Enums/BillingType.cs new file mode 100644 index 0000000..d279e25 --- /dev/null +++ b/src/LinkMobility/Webhook/WhatsApp/Enums/BillingType.cs @@ -0,0 +1,33 @@ +using System.Runtime.Serialization; +using Newtonsoft.Json.Converters; + +namespace AMWD.Net.Api.LinkMobility.Webhook.WhatsApp +{ + /// + /// Defines the billing/pricing type. + /// + [JsonConverter(typeof(StringEnumConverter))] + public enum BillingType + { + /// + /// Indicates the message is billable. + /// + [EnumMember(Value = "regular")] + Regular = 1, + + /// + /// Indicates the message is free because it was + /// either a utility template message + /// or non-template message sent within a customer service window. + /// + [EnumMember(Value = "free_customer_service")] + FreeCustomerService = 2, + + /// + /// Indicates the message is free because + /// it was sent within an open free entry point window. + /// + [EnumMember(Value = "free_entry_point")] + FreeEntryPoint = 3, + } +} diff --git a/src/LinkMobility/Webhook/WhatsApp/Enums/DeliveryStatus.cs b/src/LinkMobility/Webhook/WhatsApp/Enums/DeliveryStatus.cs new file mode 100644 index 0000000..391f0e8 --- /dev/null +++ b/src/LinkMobility/Webhook/WhatsApp/Enums/DeliveryStatus.cs @@ -0,0 +1,52 @@ +using System.Runtime.Serialization; +using Newtonsoft.Json.Converters; + +namespace AMWD.Net.Api.LinkMobility.Webhook.WhatsApp +{ + /// + /// WhatsApp message status. + /// + [JsonConverter(typeof(StringEnumConverter))] + public enum DeliveryStatus + { + /// + /// Indicates the message was successfully sent from our servers. + ///
+ /// WhatsApp UI equivalent: One checkmark. + ///
+ [EnumMember(Value = "sent")] + Sent = 1, + + /// + /// Indicates message was successfully delivered to the WhatsApp user's device. + ///
+ /// WhatsApp UI equivalent: Two checkmarks. + ///
+ [EnumMember(Value = "delivered")] + Delivered = 2, + + /// + /// Indicates failure to send or deliver the message to the WhatsApp user's device. + ///
+ /// WhatsApp UI equivalent: Red error triangle. + ///
+ [EnumMember(Value = "failed")] + Failed = 3, + + /// + /// Indicates the message was displayed in an open chat thread in the WhatsApp user's device. + ///
+ /// WhatsApp UI equivalent: Two blue checkmarks. + ///
+ [EnumMember(Value = "read")] + Read = 4, + + /// + /// Indicates the first time a voice message is played by the WhatsApp user's device. + ///
+ /// WhatsApp UI equivalent: Blue microphone. + ///
+ [EnumMember(Value = "played")] + Played = 5, + } +} diff --git a/src/LinkMobility/Webhook/WhatsApp/Message.cs b/src/LinkMobility/Webhook/WhatsApp/Message.cs new file mode 100644 index 0000000..353e124 --- /dev/null +++ b/src/LinkMobility/Webhook/WhatsApp/Message.cs @@ -0,0 +1,74 @@ +using AMWD.Net.Api.LinkMobility.Utils; +using AMWD.Net.Api.LinkMobility.WhatsApp; + +namespace AMWD.Net.Api.LinkMobility.Webhook.WhatsApp +{ + /// + /// Represents a received WhatsApp message. + /// + public class Message + { + /// + /// WhatsApp user phone number. + /// + /// + /// This is the same value returned by the API as the input value when sending a message to a WhatsApp user. + /// Note that a WhatsApp user's phone number and ID may not always match. + /// + [JsonProperty("from")] + public string? From { get; set; } + + /// + /// WhatsApp message ID. + /// + [JsonProperty("id")] + public string? Id { get; set; } + + /// + /// Unix timestamp indicating when the webhook was triggered. + /// + [JsonProperty("timestamp")] + [JsonConverter(typeof(UnixTimestampJsonConverter))] + public DateTime? Timestamp { get; set; } + + /// + /// The type of message received. + /// + [JsonProperty("type")] + public MessageType? Type { get; set; } + + #region Content depending on the message type + + /// + /// Audio file content. + /// + [JsonProperty("audio")] + public AudioContent? Audio { get; set; } + + /// + /// Document file content. + /// + [JsonProperty("document")] + public DocumentContent? Document { get; set; } + + /// + /// Image file content. + /// + [JsonProperty("image")] + public ImageContent? Image { get; set; } + + /// + /// Content of a text message. + /// + [JsonProperty("text")] + public TextContent? Text { get; set; } + + /// + /// Video file content. + /// + [JsonProperty("video")] + public VideoContent? Video { get; set; } + + #endregion Content depending on the message type + } +} diff --git a/src/LinkMobility/Webhook/WhatsApp/Notification.cs b/src/LinkMobility/Webhook/WhatsApp/Notification.cs new file mode 100644 index 0000000..3d19287 --- /dev/null +++ b/src/LinkMobility/Webhook/WhatsApp/Notification.cs @@ -0,0 +1,21 @@ +namespace AMWD.Net.Api.LinkMobility.Webhook.WhatsApp +{ + /// + /// Represents a Meta WhatsApp Notification payload. + /// + public class Notification + { + /// + /// The object. + /// In this case, it is specified as whatsapp_business_account. + /// + [JsonProperty("object")] + public string? Object { get; set; } + + /// + /// Entries of the notification object. + /// + [JsonProperty("entry")] + public IReadOnlyCollection? Entries { get; set; } + } +} diff --git a/src/LinkMobility/Webhook/WhatsApp/NotificationChange.cs b/src/LinkMobility/Webhook/WhatsApp/NotificationChange.cs new file mode 100644 index 0000000..a56f5b7 --- /dev/null +++ b/src/LinkMobility/Webhook/WhatsApp/NotificationChange.cs @@ -0,0 +1,20 @@ +namespace AMWD.Net.Api.LinkMobility.Webhook.WhatsApp +{ + /// + /// A change of a WhatsApp notification entry. + /// + public class NotificationChange + { + /// + /// The field category. + /// + [JsonProperty("field")] + public string? Field { get; set; } + + /// + /// The change value. + /// + [JsonProperty("value")] + public NotificationValue? Value { get; set; } + } +} diff --git a/src/LinkMobility/Webhook/WhatsApp/NotificationEntry.cs b/src/LinkMobility/Webhook/WhatsApp/NotificationEntry.cs new file mode 100644 index 0000000..ed4c642 --- /dev/null +++ b/src/LinkMobility/Webhook/WhatsApp/NotificationEntry.cs @@ -0,0 +1,20 @@ +namespace AMWD.Net.Api.LinkMobility.Webhook.WhatsApp +{ + /// + /// A WhatsApp notification entry. + /// + public class NotificationEntry + { + /// + /// The WhatsApp business account ID. + /// + [JsonProperty("id")] + public string? Id { get; set; } + + /// + /// Changes of that entry. + /// + [JsonProperty("changes")] + public IReadOnlyCollection? Changes { get; set; } + } +} diff --git a/src/LinkMobility/Webhook/WhatsApp/NotificationMetadata.cs b/src/LinkMobility/Webhook/WhatsApp/NotificationMetadata.cs new file mode 100644 index 0000000..ee9e1f1 --- /dev/null +++ b/src/LinkMobility/Webhook/WhatsApp/NotificationMetadata.cs @@ -0,0 +1,20 @@ +namespace AMWD.Net.Api.LinkMobility.Webhook.WhatsApp +{ + /// + /// Represents metadata for a notification. + /// + public class NotificationMetadata + { + /// + /// Business display phone number. + /// + [JsonProperty("display_phone_number")] + public string? DisplayPhoneNumber { get; set; } + + /// + /// Business phone number ID. + /// + [JsonProperty("phone_number_id")] + public string? PhoneNumberId { get; set; } + } +} diff --git a/src/LinkMobility/Webhook/WhatsApp/NotificationValue.cs b/src/LinkMobility/Webhook/WhatsApp/NotificationValue.cs new file mode 100644 index 0000000..1d9f064 --- /dev/null +++ b/src/LinkMobility/Webhook/WhatsApp/NotificationValue.cs @@ -0,0 +1,39 @@ +namespace AMWD.Net.Api.LinkMobility.Webhook.WhatsApp +{ + /// + /// A value of the notification change. + /// + public class NotificationValue + { + /// + /// The type of messaging product that triggered the webhook. + /// Will be whatsapp. + /// + [JsonProperty("messaging_product")] + public string? MessagingProduct { get; set; } + + /// + /// Metadata about the notification change. + /// + [JsonProperty("metadata")] + public NotificationMetadata? Metadata { get; set; } + + /// + /// Contacts of the WhatsApp users involved in the notification change. + /// + [JsonProperty("contacts")] + public IReadOnlyCollection? Contacts { get; set; } + + /// + /// The messages involved in the notification change. + /// + [JsonProperty("messages")] + public IReadOnlyCollection? Messages { get; set; } + + /// + /// Status changes of the messages involved in the notification change. + /// + [JsonProperty("statuses")] + public IReadOnlyCollection? Statuses { get; set; } + } +} diff --git a/src/LinkMobility/Webhook/WhatsApp/Pricing.cs b/src/LinkMobility/Webhook/WhatsApp/Pricing.cs new file mode 100644 index 0000000..4dfea0c --- /dev/null +++ b/src/LinkMobility/Webhook/WhatsApp/Pricing.cs @@ -0,0 +1,50 @@ +namespace AMWD.Net.Api.LinkMobility.Webhook.WhatsApp +{ + /// + /// The pricing information for a WhatsApp message. + /// + public class Pricing + { + /// + /// Indicates if the message is billable () or not (). + /// + /// + /// Note that the property will be deprecated in a future versioned release, + /// so we recommend that you start using and together to determine if a message is billable, + /// and if so, its billing rate. + /// + [JsonProperty("billable")] + public bool? Billable { get; set; } + + /// + /// Pricing model. + /// + /// + /// + /// + /// CBP: + /// Indicates conversation-based pricing applies. + /// Will only be set to this value if the webhook was sent before 2025-07-01. + /// + /// + /// PMP: + /// Indicates per-message pricing applies. + /// + /// + /// + [JsonProperty("pricing_model")] + public string? PricingModel { get; set; } + + /// + /// Pricing type. + /// + [JsonProperty("type")] + public BillingType? Type { get; set; } + + /// + /// Pricing category (rate) applied if billable. + /// + [JsonProperty("category")] + public BillingCategory? Category { get; set; } + } +} diff --git a/src/LinkMobility/Webhook/WhatsApp/Profile.cs b/src/LinkMobility/Webhook/WhatsApp/Profile.cs new file mode 100644 index 0000000..9bf0b72 --- /dev/null +++ b/src/LinkMobility/Webhook/WhatsApp/Profile.cs @@ -0,0 +1,26 @@ +namespace AMWD.Net.Api.LinkMobility.Webhook.WhatsApp +{ + /// + /// Represents a WhatsApp user profile. + /// + public class Profile + { + /// + /// WhatsApp user's name as it appears in their profile in the WhatsApp client. + /// + [JsonProperty("name")] + public string? Name { get; set; } + + /// + /// The username. + /// + [JsonProperty("username")] + public string? Username { get; set; } + + /// + /// The country code. + /// + [JsonProperty("country_code")] + public string? CountryCode { get; set; } + } +} diff --git a/src/LinkMobility/Webhook/WhatsApp/Status.cs b/src/LinkMobility/Webhook/WhatsApp/Status.cs new file mode 100644 index 0000000..5f9a840 --- /dev/null +++ b/src/LinkMobility/Webhook/WhatsApp/Status.cs @@ -0,0 +1,66 @@ +using AMWD.Net.Api.LinkMobility.Utils; + +namespace AMWD.Net.Api.LinkMobility.Webhook.WhatsApp +{ + /// + /// Represents a status change of a WhatsApp message. + /// + public class Status + { + /// + /// WhatsApp message ID. + /// + [JsonProperty("id")] + public string? Id { get; set; } + + /// + /// Message status. + /// + [JsonProperty("status")] + public DeliveryStatus? DeliveryStatus { get; set; } + + /// + /// Timestamp indicating when the webhook was triggered. + /// + [JsonProperty("timestamp")] + [JsonConverter(typeof(UnixTimestampJsonConverter))] + public DateTime? Timestamp { get; set; } + + /// + /// WhatsApp user phone number or group ID. + /// + /// + /// Value set to the WhatsApp user's phone number if the message was sent to their phone number, or set to a group ID if sent to a group ID. + /// If sent to a group ID, the WhatsApp user's phone number is instead assigned to the property. + /// + [JsonProperty("recipient_id")] + public string? RecipientId { get; set; } + + /// + /// WhatsApp user phone number. Property only included if message was sent to a group. + /// + [JsonProperty("recipient_participant_id")] + public string? RecipientParticipantId { get; set; } + + /// + /// The conversation information. + /// + /// + /// + /// Only included with sent status, and one of either delivered or read status. + /// Omitted entirely for v24.0+ unless webhook is for a free entry point conversation. + /// + /// + [JsonProperty("conversation")] + public Conversation? Conversation { get; set; } + + /// + /// The pricing information. + /// + /// + /// Only included with status, and one of either or status. + /// + [JsonProperty("pricing")] + public Pricing? Pricing { get; set; } + } +} diff --git a/src/LinkMobility/Webhook/WhatsApp/WhatsAppNotification.cs b/src/LinkMobility/Webhook/WhatsApp/WhatsAppNotification.cs new file mode 100644 index 0000000..3a692d6 --- /dev/null +++ b/src/LinkMobility/Webhook/WhatsApp/WhatsAppNotification.cs @@ -0,0 +1,45 @@ +namespace AMWD.Net.Api.LinkMobility.Webhook.WhatsApp +{ + /// + /// Represents a notification for an incoming WhatsApp message or delivery report. + /// (API) + /// + public class WhatsAppNotification + { + /// + /// A unique identifier for the customer channel. It is typically in UUID format. + /// + [JsonProperty("customerChannelUuid")] + public Guid CustomerChannelUuid { get; set; } + + /// + /// The sender's information in E164 formatted MSISDN (see Wikipedia MSISDN). + /// In this case is the customer phone number. + /// + [JsonProperty("sender")] + public string? Sender { get; set; } + + /// + /// The recipient's information in E164 formatted MSISDN (see Wikipedia MSISDN). + /// In this case is the Customer Channel identifier. + /// + [JsonProperty("recipient")] + public string? Recipient { get; set; } + + /// + /// The type of the communication channel. + /// In this case, it is specified as whatsapp. + /// + [JsonProperty("type")] + public string? Type { get; set; } + + /// + /// Meta WhatsApp Notification payload. + /// + /// + /// See specification on Meta documentation. + /// + [JsonProperty("whatsappNotification")] + public Notification? Body { get; set; } + } +} diff --git a/src/LinkMobility/WhatsApp/Contents/AudioMessageContent.cs b/src/LinkMobility/WhatsApp/Contents/AudioMessageContent.cs new file mode 100644 index 0000000..912f8dc --- /dev/null +++ b/src/LinkMobility/WhatsApp/Contents/AudioMessageContent.cs @@ -0,0 +1,52 @@ +namespace AMWD.Net.Api.LinkMobility.WhatsApp +{ + /// + /// A WhatsApp audio message content. + /// + public class AudioMessageContent : IMessageContent + { + /// + /// Initializes a new instance of the class with the provided audio link. + /// + /// The link to an audio file (http/https only). + public AudioMessageContent(string mediaLink) + { + Body = new Content { Link = mediaLink }; + } + + /// + [JsonProperty("type")] + public MessageType Type => MessageType.Audio; + + /// + /// The content container. + /// + [JsonProperty("audio")] + public Content Body { get; set; } + + /// + public bool IsValid() + { + return !string.IsNullOrWhiteSpace(Body?.Link) + && (Body!.Link!.StartsWith("http://") || Body!.Link!.StartsWith("https://")); + } + + /// + /// Container for the audio message content. + /// + public class Content + { + /// + /// The media link. + /// + [JsonProperty("link")] + public string? Link { get; set; } + + /// + /// A caption for the audio. + /// + [JsonProperty("caption")] + public string? Caption { get; set; } + } + } +} diff --git a/src/LinkMobility/WhatsApp/Contents/DocumentMessageContent.cs b/src/LinkMobility/WhatsApp/Contents/DocumentMessageContent.cs new file mode 100644 index 0000000..7af5dce --- /dev/null +++ b/src/LinkMobility/WhatsApp/Contents/DocumentMessageContent.cs @@ -0,0 +1,58 @@ +namespace AMWD.Net.Api.LinkMobility.WhatsApp +{ + /// + /// A WhatsApp document message content. + /// + public class DocumentMessageContent : IMessageContent + { + /// + /// Initializes a new instance of the class with the provided document link. + /// + /// The link to a document (http/https only). + public DocumentMessageContent(string mediaLink) + { + Body = new Content { Link = mediaLink }; + } + + /// + [JsonProperty("type")] + public MessageType Type => MessageType.Document; + + /// + /// The content container. + /// + [JsonProperty("document")] + public Content Body { get; set; } + + /// + public bool IsValid() + { + return !string.IsNullOrWhiteSpace(Body?.Link) + && (Body!.Link!.StartsWith("http://") || Body!.Link!.StartsWith("https://")); + } + + /// + /// Container for the document message content. + /// + public class Content + { + /// + /// The media link. + /// + [JsonProperty("link")] + public string? Link { get; set; } + + /// + /// A caption for the document. + /// + [JsonProperty("caption")] + public string? Caption { get; set; } + + /// + /// A filename for the document (e.g. "file.pdf"). + /// + [JsonProperty("filename")] + public string? Filename { get; set; } + } + } +} diff --git a/src/LinkMobility/WhatsApp/Contents/ImageMessageContent.cs b/src/LinkMobility/WhatsApp/Contents/ImageMessageContent.cs new file mode 100644 index 0000000..3335dca --- /dev/null +++ b/src/LinkMobility/WhatsApp/Contents/ImageMessageContent.cs @@ -0,0 +1,52 @@ +namespace AMWD.Net.Api.LinkMobility.WhatsApp +{ + /// + /// A WhatsApp image message content. + /// + public class ImageMessageContent : IMessageContent + { + /// + /// Initializes a new instance of the class with the provided message text. + /// + /// The link to an image (http/https only). + public ImageMessageContent(string mediaLink) + { + Body = new Content { Link = mediaLink }; + } + + /// + [JsonProperty("type")] + public MessageType Type => MessageType.Image; + + /// + /// The content container. + /// + [JsonProperty("image")] + public Content Body { get; set; } + + /// + public bool IsValid() + { + return !string.IsNullOrWhiteSpace(Body?.Link) + && (Body!.Link!.StartsWith("http://") || Body!.Link!.StartsWith("https://")); + } + + /// + /// Container for the text message content. + /// + public class Content + { + /// + /// The message text. + /// + [JsonProperty("link")] + public string? Link { get; set; } + + /// + /// A caption for the image. + /// + [JsonProperty("caption")] + public string? Caption { get; set; } + } + } +} diff --git a/src/LinkMobility/WhatsApp/Contents/TextMessageContent.cs b/src/LinkMobility/WhatsApp/Contents/TextMessageContent.cs new file mode 100644 index 0000000..2145f3b --- /dev/null +++ b/src/LinkMobility/WhatsApp/Contents/TextMessageContent.cs @@ -0,0 +1,51 @@ +namespace AMWD.Net.Api.LinkMobility.WhatsApp +{ + /// + /// A WhatsApp text message content. + /// + public class TextMessageContent : IMessageContent + { + /// + /// Initializes a new instance of the class with the provided message text. + /// + /// The message text. + public TextMessageContent(string message) + { + Body = new Content { Text = message }; + } + + /// + [JsonProperty("type")] + public MessageType Type => MessageType.Text; + + /// + /// The content container. + /// + [JsonProperty("text")] + public Content Body { get; set; } + + /// + public bool IsValid() + { + return !string.IsNullOrWhiteSpace(Body?.Text); + } + + /// + /// Container for the text message content. + /// + public class Content + { + /// + /// The message text. + /// + [JsonProperty("body")] + public string? Text { get; set; } + + /// + /// Indicates whether urls should try to be previewed. + /// + [JsonProperty("preview_url")] + public bool PreviewUrl { get; set; } = false; + } + } +} diff --git a/src/LinkMobility/WhatsApp/Contents/VideoMessageContent.cs b/src/LinkMobility/WhatsApp/Contents/VideoMessageContent.cs new file mode 100644 index 0000000..30ca17d --- /dev/null +++ b/src/LinkMobility/WhatsApp/Contents/VideoMessageContent.cs @@ -0,0 +1,52 @@ +namespace AMWD.Net.Api.LinkMobility.WhatsApp +{ + /// + /// A WhatsApp video message content. + /// + public class VideoMessageContent : IMessageContent + { + /// + /// Initializes a new instance of the class with the provided video link. + /// + /// The link to a video (http/https only). + public VideoMessageContent(string mediaLink) + { + Body = new Content { Link = mediaLink }; + } + + /// + [JsonProperty("type")] + public MessageType Type => MessageType.Video; + + /// + /// The content container. + /// + [JsonProperty("video")] + public Content Body { get; set; } + + /// + public bool IsValid() + { + return !string.IsNullOrWhiteSpace(Body?.Link) + && (Body!.Link!.StartsWith("http://") || Body!.Link!.StartsWith("https://")); + } + + /// + /// Container for the text message content. + /// + public class Content + { + /// + /// The message text. + /// + [JsonProperty("link")] + public string? Link { get; set; } + + /// + /// A caption for the image. + /// + [JsonProperty("caption")] + public string? Caption { get; set; } + } + } +} diff --git a/src/LinkMobility/WhatsApp/IMessageContent.cs b/src/LinkMobility/WhatsApp/IMessageContent.cs new file mode 100644 index 0000000..ae306f5 --- /dev/null +++ b/src/LinkMobility/WhatsApp/IMessageContent.cs @@ -0,0 +1,19 @@ +namespace AMWD.Net.Api.LinkMobility.WhatsApp +{ + /// + /// The message content of a WhatsApp message. + /// + public interface IMessageContent + { + /// + /// The type of the message content. + /// + [JsonProperty("type")] + MessageType Type { get; } + + /// + /// Determines whether the content message is valid. + /// + bool IsValid(); + } +} diff --git a/src/LinkMobility/WhatsApp/MessageType.cs b/src/LinkMobility/WhatsApp/MessageType.cs new file mode 100644 index 0000000..9ff2d1e --- /dev/null +++ b/src/LinkMobility/WhatsApp/MessageType.cs @@ -0,0 +1,42 @@ +using System.Runtime.Serialization; +using Newtonsoft.Json.Converters; + +namespace AMWD.Net.Api.LinkMobility.WhatsApp +{ + /// + /// Represents the list of supported message types for WhatsApp messages. + /// + [JsonConverter(typeof(StringEnumConverter))] + public enum MessageType + { + /// + /// Send a simple text message. + /// + [EnumMember(Value = "text")] + Text = 1, + + /// + /// Sends a media message, which contains the link to an image. + /// + [EnumMember(Value = "image")] + Image = 2, + + /// + /// Sends a media message, which contains the link to a video. + /// + [EnumMember(Value = "video")] + Video = 3, + + /// + /// Sends a media message, which contains the link to an audio file. + /// + [EnumMember(Value = "audio")] + Audio = 4, + + /// + /// Sends a media message, which contains the link to a document (e.g. PDF). + /// + [EnumMember(Value = "document")] + Document = 5, + } +} diff --git a/src/LinkMobility/WhatsApp/SendWhatsAppMessageRequest.cs b/src/LinkMobility/WhatsApp/SendWhatsAppMessageRequest.cs new file mode 100644 index 0000000..b43ef5e --- /dev/null +++ b/src/LinkMobility/WhatsApp/SendWhatsAppMessageRequest.cs @@ -0,0 +1,83 @@ +namespace AMWD.Net.Api.LinkMobility.WhatsApp +{ + /// + /// Request to send a WhatsApp message to a list of recipients. + /// + public class SendWhatsAppMessageRequest + { + /// + /// Initializes a new instance of the class. + /// + /// The content of a WhatsApp message. + /// A list of recipient numbers. + public SendWhatsAppMessageRequest(IMessageContent messageContent, IReadOnlyCollection recipientAddressList) + { + MessageContent = messageContent; + RecipientAddressList = recipientAddressList; + } + + /// + /// Optional. + /// May contain a freely definable message id. + /// + [JsonProperty("clientMessageId")] + public string? ClientMessageId { get; set; } + + /// + /// Optional. + /// The content category that is used to categorize the message (used for blacklisting). + /// + /// + /// The following content categories are supported: , or . + /// If no content category is provided, the default setting is used (may be changed inside the web interface). + /// + [JsonProperty("contentCategory")] + public ContentCategory? ContentCategory { get; set; } + + /// + /// UTF-8 encoded message content. + /// + [JsonProperty("messageContent")] + public IMessageContent MessageContent { get; set; } + + /// + /// Optional. + /// Priority of the message. + /// + /// + /// Must not exceed the value configured for the channel used to send the message. + /// + [JsonProperty("priority")] + public int? Priority { get; set; } + + /// + /// List of recipients (E.164 formatted MSISDNs) + /// to whom the message should be sent. + ///
+ /// The list of recipients may contain a maximum of 1000 entries. + ///
+ [JsonProperty("recipientAddressList")] + public IReadOnlyCollection RecipientAddressList { get; set; } + + /// + /// Optional. + ///
+ /// : The transmission is only simulated, no whatsapp message is sent. + /// Depending on the number of recipients the status code or is returned. + ///
+ /// : No simulation is done. The whatsapp message is sent. (default) + ///
+ [JsonProperty("test")] + public bool? Test { get; set; } + + /// + /// Optional. + /// Specifies the validity periode (in seconds) in which the message is tried to be delivered to the recipient. + /// + /// + /// A minimum of 1 minute (60 seconds) and a maximum of 3 days (259200 seconds) are allowed. + /// + [JsonProperty("validityPeriode")] + public int? ValidityPeriode { get; set; } + } +} diff --git a/src/LinkMobility/WhatsApp/WhatsAppExtensions.cs b/src/LinkMobility/WhatsApp/WhatsAppExtensions.cs new file mode 100644 index 0000000..3db7c36 --- /dev/null +++ b/src/LinkMobility/WhatsApp/WhatsAppExtensions.cs @@ -0,0 +1,42 @@ +using System.Threading; +using System.Threading.Tasks; +using AMWD.Net.Api.LinkMobility.Utils; + +namespace AMWD.Net.Api.LinkMobility.WhatsApp +{ + /// + /// Implementation of WhatsApp messaging. API + /// + public static class WhatsAppExtensions + { + /// + /// Sends a WhatsApp message to a list of recipients. + /// + /// The instance. + /// The unique identifier of the WhatsApp channel. + /// The request data. + /// A cancellation token to propagate notification that operations should be canceled. + public static Task SendWhatsAppMessage(this LinkMobilityClient client, Guid uuid, SendWhatsAppMessageRequest request, CancellationToken cancellationToken = default) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + + if (request.MessageContent?.IsValid() != true) + throw new ArgumentException("A valid message must be provided.", nameof(request.MessageContent)); + + if (request.ContentCategory.HasValue && request.ContentCategory.Value != ContentCategory.Informational && request.ContentCategory.Value != ContentCategory.Advertisement && request.ContentCategory.Value != ContentCategory.Personal) + throw new ArgumentException($"Content category '{request.ContentCategory.Value}' is not valid.", nameof(request.ContentCategory)); + + if (request.RecipientAddressList == null || request.RecipientAddressList.Count == 0) + throw new ArgumentException("At least one recipient must be provided.", nameof(request.RecipientAddressList)); + + foreach (string recipient in request.RecipientAddressList) + { + if (!Validation.IsValidMSISDN(recipient)) + throw new ArgumentException($"Recipient address '{recipient}' is not a valid MSISDN format.", nameof(request.RecipientAddressList)); + } + + return client.PostAsync($"/channels/{uuid}/send/whatsapp", request, cancellationToken: cancellationToken); + } + } +} diff --git a/test/LinkMobility.Tests/Webhook/WhatsApp/WhatsAppNotificationTest.cs b/test/LinkMobility.Tests/Webhook/WhatsApp/WhatsAppNotificationTest.cs new file mode 100644 index 0000000..d67e61c --- /dev/null +++ b/test/LinkMobility.Tests/Webhook/WhatsApp/WhatsAppNotificationTest.cs @@ -0,0 +1,155 @@ +using System.Linq; +using AMWD.Net.Api.LinkMobility.Webhook.WhatsApp; +using AMWD.Net.Api.LinkMobility.WhatsApp; + +namespace LinkMobility.Tests.Webhook.WhatsApp +{ + [TestClass] + public class WhatsAppNotificationTest + { + [TestMethod] + public void ShouldDeserializeWhatsAppNotificationWithMessageAndStatus() + { + // Arrange + string json = @"{ + ""customerChannelUuid"": ""11111111-2222-3333-4444-555555555555"", + ""sender"": ""46701234567"", + ""recipient"": ""123e4567-e89b-12d3-a456-426614174000"", + ""type"": ""whatsapp"", + ""whatsappNotification"": { + ""object"": ""whatsapp_business_account"", + ""entry"": [ + { + ""id"": ""123456789"", + ""changes"": [ + { + ""field"": ""messages"", + ""value"": { + ""messaging_product"": ""whatsapp"", + ""metadata"": { + ""display_phone_number"": ""+46701234567"", + ""phone_number_id"": ""111222333"" + }, + ""contacts"": [ + { + ""profile"": { + ""name"": ""John Doe"" + }, + ""wa_id"": ""46701234567"" + } + ], + ""messages"": [ + { + ""from"": ""46701234567"", + ""id"": ""wamid.123"", + ""timestamp"": 1672531200, + ""type"": ""text"", + ""text"": { + ""body"": ""Hello world"" + } + } + ], + ""statuses"": [ + { + ""id"": ""wamid.123"", + ""status"": ""delivered"", + ""timestamp"": 1672531200, + ""recipient_id"": ""16505551234"", + ""recipient_participant_id"": ""16505550000"", + ""conversation"": { + ""id"": ""conv-1"", + ""expiration_timestamp"": 1672617600, + ""origin"": { + ""type"": ""service"" + } + }, + ""pricing"": { + ""billable"": true, + ""pricing_model"": ""PMP"", + ""type"": ""regular"", + ""category"": ""service"" + } + } + ] + } + } + ] + } + ] + } + }"; + + // Act + var notification = JsonConvert.DeserializeObject(json); + + // Assert + Assert.IsNotNull(notification); + Assert.AreEqual(Guid.Parse("11111111-2222-3333-4444-555555555555"), notification.CustomerChannelUuid); + Assert.AreEqual("46701234567", notification.Sender); + Assert.AreEqual("123e4567-e89b-12d3-a456-426614174000", notification.Recipient); + Assert.AreEqual("whatsapp", notification.Type); + + Assert.IsNotNull(notification.Body); + Assert.AreEqual("whatsapp_business_account", notification.Body.Object); + Assert.IsNotNull(notification.Body.Entries); + Assert.HasCount(1, notification.Body.Entries); + + var entry = notification.Body.Entries.First(); + Assert.AreEqual("123456789", entry.Id); + Assert.IsNotNull(entry.Changes); + Assert.HasCount(1, entry.Changes); + + var change = entry.Changes.First(); + Assert.AreEqual("messages", change.Field); + Assert.IsNotNull(change.Value); + Assert.AreEqual("whatsapp", change.Value.MessagingProduct); + + Assert.IsNotNull(change.Value.Metadata); + Assert.AreEqual("+46701234567", change.Value.Metadata.DisplayPhoneNumber); + Assert.AreEqual("111222333", change.Value.Metadata.PhoneNumberId); + + Assert.IsNotNull(change.Value.Contacts); + Assert.HasCount(1, change.Value.Contacts); + + var contact = change.Value.Contacts.First(); + Assert.IsNotNull(contact.Profile); + Assert.AreEqual("John Doe", contact.Profile.Name); + Assert.AreEqual("46701234567", contact.WhatsAppId); + + Assert.IsNotNull(change.Value.Messages); + Assert.HasCount(1, change.Value.Messages); + + var message = change.Value.Messages.First(); + Assert.AreEqual("46701234567", message.From); + Assert.AreEqual("wamid.123", message.Id); + Assert.IsNotNull(message.Timestamp); + + // 1672531200 -> 2023-01-01T00:00:00Z + var expected = new DateTime(2023, 1, 1, 0, 0, 0, DateTimeKind.Utc); + Assert.AreEqual(expected, message.Timestamp.Value.ToUniversalTime()); + Assert.IsTrue(message.Type.HasValue); + Assert.AreEqual(MessageType.Text, message.Type.Value); + Assert.IsNotNull(message.Text); + Assert.IsNotNull(message.Text.Body); + Assert.AreEqual("Hello world", message.Text.Body); + + Assert.IsNotNull(change.Value.Statuses); + Assert.HasCount(1, change.Value.Statuses); + + var status = change.Value.Statuses.First(); + Assert.AreEqual("wamid.123", status.Id); + Assert.IsTrue(status.DeliveryStatus.HasValue); + Assert.AreEqual(DeliveryStatus.Delivered, status.DeliveryStatus.Value); + } + + [TestMethod] + public void DeserializeShouldThrowOnInvalidJson() + { + // Arrange + string invalid = "this is not json"; + + // Act & Assert + Assert.ThrowsExactly(() => JsonConvert.DeserializeObject(invalid)); + } + } +} diff --git a/test/LinkMobility.Tests/WhatsApp/Contents/AudioMessageContentTest.cs b/test/LinkMobility.Tests/WhatsApp/Contents/AudioMessageContentTest.cs new file mode 100644 index 0000000..1dc7435 --- /dev/null +++ b/test/LinkMobility.Tests/WhatsApp/Contents/AudioMessageContentTest.cs @@ -0,0 +1,43 @@ +using AMWD.Net.Api.LinkMobility.WhatsApp; + +namespace LinkMobility.Tests.WhatsApp.Contents +{ + [TestClass] + public class AudioMessageContentTest + { + [TestMethod] + [DataRow(null)] + [DataRow("")] + [DataRow("Caption")] + public void ShouldValidateSuccessful(string caption) + { + // Arrange + var content = new AudioMessageContent("https://example.com/audio.mp3"); + content.Body.Caption = caption; + + // Act + bool isValid = content.IsValid(); + + // Assert + Assert.IsTrue(isValid); + } + + [TestMethod] + [DataRow(null)] + [DataRow("")] + [DataRow(" ")] + [DataRow("ftp://example.com/audio.mp3")] + [DataRow("www.example.org/audio.mp3")] + public void ShouldValidateNotSuccessful(string url) + { + // Arrange + var content = new AudioMessageContent(url); + + // Act + bool isValid = content.IsValid(); + + // Assert + Assert.IsFalse(isValid); + } + } +} diff --git a/test/LinkMobility.Tests/WhatsApp/Contents/DocumentMessageContentTest.cs b/test/LinkMobility.Tests/WhatsApp/Contents/DocumentMessageContentTest.cs new file mode 100644 index 0000000..9315e39 --- /dev/null +++ b/test/LinkMobility.Tests/WhatsApp/Contents/DocumentMessageContentTest.cs @@ -0,0 +1,43 @@ +using AMWD.Net.Api.LinkMobility.WhatsApp; + +namespace LinkMobility.Tests.WhatsApp.Contents +{ + [TestClass] + public class DocumentMessageContentTest + { + [TestMethod] + [DataRow(null)] + [DataRow("")] + [DataRow("Caption")] + public void ShouldValidateSuccessful(string caption) + { + // Arrange + var content = new DocumentMessageContent("https://example.com/doc.pdf"); + content.Body.Caption = caption; + + // Act + bool isValid = content.IsValid(); + + // Assert + Assert.IsTrue(isValid); + } + + [TestMethod] + [DataRow(null)] + [DataRow("")] + [DataRow(" ")] + [DataRow("ftp://example.com/doc.pdf")] + [DataRow("www.example.org/doc.pdf")] + public void ShouldValidateNotSuccessful(string url) + { + // Arrange + var content = new DocumentMessageContent(url); + + // Act + bool isValid = content.IsValid(); + + // Assert + Assert.IsFalse(isValid); + } + } +} diff --git a/test/LinkMobility.Tests/WhatsApp/Contents/ImageMessageContentTest.cs b/test/LinkMobility.Tests/WhatsApp/Contents/ImageMessageContentTest.cs new file mode 100644 index 0000000..a18b57b --- /dev/null +++ b/test/LinkMobility.Tests/WhatsApp/Contents/ImageMessageContentTest.cs @@ -0,0 +1,43 @@ +using AMWD.Net.Api.LinkMobility.WhatsApp; + +namespace LinkMobility.Tests.WhatsApp.Contents +{ + [TestClass] + public class ImageMessageContentTest + { + [TestMethod] + [DataRow(null)] + [DataRow("")] + [DataRow("Caption")] + public void ShouldValidateSuccessful(string caption) + { + // Arrange + var content = new ImageMessageContent("https://example.com/image.jpg"); + content.Body.Caption = caption; + + // Act + bool isValid = content.IsValid(); + + // Assert + Assert.IsTrue(isValid); + } + + [TestMethod] + [DataRow(null)] + [DataRow("")] + [DataRow(" ")] + [DataRow("ftp://example.com/image.jpg")] + [DataRow("www.example.org/image.jpg")] + public void ShouldValidateNotSuccessful(string url) + { + // Arrange + var content = new ImageMessageContent(url); + + // Act + bool isValid = content.IsValid(); + + // Assert + Assert.IsFalse(isValid); + } + } +} diff --git a/test/LinkMobility.Tests/WhatsApp/Contents/TextMessageContentTest.cs b/test/LinkMobility.Tests/WhatsApp/Contents/TextMessageContentTest.cs new file mode 100644 index 0000000..0926033 --- /dev/null +++ b/test/LinkMobility.Tests/WhatsApp/Contents/TextMessageContentTest.cs @@ -0,0 +1,40 @@ +using AMWD.Net.Api.LinkMobility.WhatsApp; + +namespace LinkMobility.Tests.WhatsApp.Contents +{ + [TestClass] + public class TextMessageContentTest + { + [TestMethod] + [DataRow(true)] + [DataRow(false)] + public void ShouldValidateSuccessful(bool previewUrl) + { + // Arrange + var content = new TextMessageContent("Hello, World!"); + content.Body.PreviewUrl = previewUrl; + + // Act + bool isValid = content.IsValid(); + + // Assert + Assert.IsTrue(isValid); + } + + [TestMethod] + [DataRow(null)] + [DataRow("")] + [DataRow(" ")] + public void ShouldValidateNotSuccessful(string text) + { + // Arrange + var content = new TextMessageContent(text); + + // Act + bool isValid = content.IsValid(); + + // Assert + Assert.IsFalse(isValid); + } + } +} diff --git a/test/LinkMobility.Tests/WhatsApp/Contents/VideoMessageContentTest.cs b/test/LinkMobility.Tests/WhatsApp/Contents/VideoMessageContentTest.cs new file mode 100644 index 0000000..83eb862 --- /dev/null +++ b/test/LinkMobility.Tests/WhatsApp/Contents/VideoMessageContentTest.cs @@ -0,0 +1,43 @@ +using AMWD.Net.Api.LinkMobility.WhatsApp; + +namespace LinkMobility.Tests.WhatsApp.Contents +{ + [TestClass] + public class VideoMessageContentTest + { + [TestMethod] + [DataRow(null)] + [DataRow("")] + [DataRow("Caption")] + public void ShouldValidateSuccessful(string caption) + { + // Arrange + var content = new VideoMessageContent("https://example.com/video.mp4"); + content.Body.Caption = caption; + + // Act + bool isValid = content.IsValid(); + + // Assert + Assert.IsTrue(isValid); + } + + [TestMethod] + [DataRow(null)] + [DataRow("")] + [DataRow(" ")] + [DataRow("ftp://example.com/video.mp4")] + [DataRow("www.example.org/video.mp4")] + public void ShouldValidateNotSuccessful(string url) + { + // Arrange + var content = new VideoMessageContent(url); + + // Act + bool isValid = content.IsValid(); + + // Assert + Assert.IsFalse(isValid); + } + } +} diff --git a/test/LinkMobility.Tests/WhatsApp/SendWhatsAppMessageTest.cs b/test/LinkMobility.Tests/WhatsApp/SendWhatsAppMessageTest.cs new file mode 100644 index 0000000..64fe7a6 --- /dev/null +++ b/test/LinkMobility.Tests/WhatsApp/SendWhatsAppMessageTest.cs @@ -0,0 +1,202 @@ +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using AMWD.Net.Api.LinkMobility; +using AMWD.Net.Api.LinkMobility.WhatsApp; +using LinkMobility.Tests.Helpers; +using Moq.Protected; + +namespace LinkMobility.Tests.WhatsApp +{ + [TestClass] + public class SendWhatsAppMessageTest + { + public TestContext TestContext { get; set; } + + private const string BASE_URL = "https://localhost/rest/"; + + private Mock _authenticationMock; + private Mock _clientOptionsMock; + private HttpMessageHandlerMock _httpMessageHandlerMock; + + private Guid _uuid; + private SendWhatsAppMessageRequest _request; + + [TestInitialize] + public void Initialize() + { + _authenticationMock = new Mock(); + _clientOptionsMock = new Mock(); + _httpMessageHandlerMock = new HttpMessageHandlerMock(); + + _authenticationMock + .Setup(a => a.AddHeader(It.IsAny())) + .Callback(c => c.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Scheme", "Parameter")); + + _clientOptionsMock.Setup(c => c.BaseUrl).Returns(BASE_URL); + _clientOptionsMock.Setup(c => c.Timeout).Returns(TimeSpan.FromSeconds(30)); + _clientOptionsMock.Setup(c => c.DefaultHeaders).Returns(new Dictionary()); + _clientOptionsMock.Setup(c => c.DefaultQueryParams).Returns(new Dictionary()); + _clientOptionsMock.Setup(c => c.AllowRedirects).Returns(true); + _clientOptionsMock.Setup(c => c.UseProxy).Returns(false); + + _uuid = Guid.NewGuid(); + + var image = new ImageMessageContent("https://example.com/image.jpg"); + image.Body.Caption = "Hello World :)"; + _request = new SendWhatsAppMessageRequest(image, ["436991234567"]); + } + + [TestMethod] + public async Task ShouldSendWhatsAppMessage() + { + // Arrange + _httpMessageHandlerMock.Responses.Enqueue(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent(@"{ ""clientMessageId"": ""myUniqueId"", ""statusCode"": 2000, ""statusMessage"": ""OK"", ""transferId"": ""0059d0b20100a0a8b803"" }", Encoding.UTF8, "application/json"), + }); + + var client = GetClient(); + + // Act + var response = await client.SendWhatsAppMessage(_uuid, _request, TestContext.CancellationToken); + + // Assert + Assert.IsNotNull(response); + + Assert.AreEqual("myUniqueId", response.ClientMessageId); + Assert.AreEqual(StatusCodes.Ok, response.StatusCode); + Assert.AreEqual("OK", response.StatusMessage); + Assert.AreEqual("0059d0b20100a0a8b803", response.TransferId); + + Assert.HasCount(1, _httpMessageHandlerMock.RequestCallbacks); + + var callback = _httpMessageHandlerMock.RequestCallbacks.First(); + Assert.AreEqual(HttpMethod.Post, callback.HttpMethod); + Assert.AreEqual($"https://localhost/rest/channels/{_uuid}/send/whatsapp", callback.Url); + Assert.AreEqual(@"{""messageContent"":{""type"":""image"",""image"":{""link"":""https://example.com/image.jpg"",""caption"":""Hello World :)""}},""recipientAddressList"":[""436991234567""]}", callback.Content); + + Assert.HasCount(3, callback.Headers); + Assert.IsTrue(callback.Headers.ContainsKey("Accept")); + Assert.IsTrue(callback.Headers.ContainsKey("Authorization")); + Assert.IsTrue(callback.Headers.ContainsKey("User-Agent")); + + Assert.AreEqual("application/json", callback.Headers["Accept"]); + Assert.AreEqual("Scheme Parameter", callback.Headers["Authorization"]); + Assert.AreEqual("LinkMobilityClient/1.0.0", callback.Headers["User-Agent"]); + + _httpMessageHandlerMock.Protected.Verify("SendAsync", Times.Once(), ItExpr.IsAny(), ItExpr.IsAny()); + + _clientOptionsMock.VerifyGet(o => o.DefaultQueryParams, Times.Once); + VerifyNoOtherCalls(); + } + + [TestMethod] + public void ShouldThrowOnNullRequest() + { + // Arrange + var client = GetClient(); + + // Act & Assert + var ex = Assert.ThrowsExactly(() => client.SendWhatsAppMessage(_uuid, null, TestContext.CancellationToken)); + Assert.AreEqual("request", ex.ParamName); + + VerifyNoOtherCalls(); + } + + [TestMethod] + public void ShouldThrowOnMissingMessage() + { + // Arrange + var req = new SendWhatsAppMessageRequest(null, ["436991234567"]); + var client = GetClient(); + + // Act & Assert + var ex = Assert.ThrowsExactly(() => client.SendWhatsAppMessage(_uuid, req, TestContext.CancellationToken)); + Assert.AreEqual("MessageContent", ex.ParamName); + + VerifyNoOtherCalls(); + } + + [TestMethod] + public void ShouldThrowOnNoRecipients() + { + // Arrange + var req = new SendWhatsAppMessageRequest(new TextMessageContent("Hello"), []); + var client = GetClient(); + + // Act & Assert + var ex = Assert.ThrowsExactly(() => client.SendWhatsAppMessage(_uuid, req, TestContext.CancellationToken)); + Assert.AreEqual("RecipientAddressList", ex.ParamName); + + VerifyNoOtherCalls(); + } + + [TestMethod] + public void ShouldThrowOnInvalidRecipient() + { + // Arrange + var client = GetClient(); + var req = new SendWhatsAppMessageRequest(new TextMessageContent("Hello"), ["4791234567", "invalid-recipient"]); + + // Act & Assert + var ex = Assert.ThrowsExactly(() => client.SendWhatsAppMessage(_uuid, req, TestContext.CancellationToken)); + + Assert.AreEqual("RecipientAddressList", ex.ParamName); + Assert.StartsWith($"Recipient address 'invalid-recipient' is not a valid MSISDN format.", ex.Message); + + VerifyNoOtherCalls(); + } + + [TestMethod] + public void ShouldThrowOnInvalidContentCategory() + { + // Arrange + _request.ContentCategory = 0; + var client = GetClient(); + + // Act & Assert + var ex = Assert.ThrowsExactly(() => client.SendWhatsAppMessage(_uuid, _request, TestContext.CancellationToken)); + Assert.AreEqual("ContentCategory", ex.ParamName); + Assert.StartsWith("Content category '0' is not valid.", ex.Message); + + VerifyNoOtherCalls(); + } + + private void VerifyNoOtherCalls() + { + _authenticationMock.VerifyNoOtherCalls(); + _clientOptionsMock.VerifyNoOtherCalls(); + _httpMessageHandlerMock.Mock.VerifyNoOtherCalls(); + } + + private LinkMobilityClient GetClient() + { + var client = new LinkMobilityClient(_authenticationMock.Object, _clientOptionsMock.Object); + + var httpClient = new HttpClient(_httpMessageHandlerMock.Mock.Object) + { + Timeout = _clientOptionsMock.Object.Timeout, + BaseAddress = new Uri(_clientOptionsMock.Object.BaseUrl) + }; + + httpClient.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("LinkMobilityClient", "1.0.0")); + httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + + _authenticationMock.Object.AddHeader(httpClient); + + _authenticationMock.Invocations.Clear(); + _clientOptionsMock.Invocations.Clear(); + + ReflectionHelper.GetPrivateField(client, "_httpClient")?.Dispose(); + ReflectionHelper.SetPrivateField(client, "_httpClient", httpClient); + + return client; + } + } +}