1
0

Compare commits

...

2 Commits

Author SHA1 Message Date
6b5581c247 Added basic WhatsApp implementation
All checks were successful
Branch Build / build-test-deploy (push) Successful in 1m23s
2026-03-24 20:06:55 +01:00
314e5da9cc Updated docs 2026-03-24 18:33:10 +01:00
47 changed files with 1917 additions and 11 deletions

View File

@@ -30,10 +30,9 @@ jobs:
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Restore tools - name: Prepare environment
run: | run: |
set -ex set -ex
dotnet tool restore -v q
dotnet tool install dotnet-reportgenerator-globaltool --tool-path /dotnet-tools dotnet tool install dotnet-reportgenerator-globaltool --tool-path /dotnet-tools
echo "CI_SERVER_HOST=${GITEA_SERVER_URL#https://}" >> "$GITEA_ENV" echo "CI_SERVER_HOST=${GITEA_SERVER_URL#https://}" >> "$GITEA_ENV"

View File

@@ -30,10 +30,9 @@ jobs:
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Restore tools - name: Prepare environment
run: | run: |
set -ex set -ex
dotnet tool restore -v q
dotnet tool install dotnet-reportgenerator-globaltool --tool-path /dotnet-tools dotnet tool install dotnet-reportgenerator-globaltool --tool-path /dotnet-tools
dotnet tool install docfx --tool-path /dotnet-tools dotnet tool install docfx --tool-path /dotnet-tools
echo "CI_SERVER_HOST=${GITEA_SERVER_URL#https://}" >> "$GITEA_ENV" echo "CI_SERVER_HOST=${GITEA_SERVER_URL#https://}" >> "$GITEA_ENV"

View File

@@ -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 - `Validation` utility class for specifications as MSISDN
- Docs rendering using DocFX - Docs rendering using DocFX
- `Personal` as enum value for `ContentCategory`
- WhatsApp support for
- `AudioMessageContent`
- `DocumentMessageContent`
- `ImageMessageContent`
- `TextMessageContent`
- `VideoMessageContent`
### Changed ### Changed

View File

@@ -8,6 +8,8 @@ _layout: landing
The available channels are SMS, RCS and WhatsApp Business. The available channels are SMS, RCS and WhatsApp Business.
Here you can find the API documentation: https://developer.linkmobility.eu/
## NuGet packages ## NuGet packages
Here is an overview of the latest package. Here is an overview of the latest package.

View File

@@ -8,13 +8,19 @@ namespace AMWD.Net.Api.LinkMobility
public class ClientOptions public class ClientOptions
{ {
/// <summary> /// <summary>
/// Gets or sets the default base url for the API. /// Gets or sets the base url for the API.
/// </summary> /// </summary>
/// <remarks>
/// The default base url is <c>https://api.linkmobility.eu/rest/</c>.
/// </remarks>
public virtual string BaseUrl { get; set; } = "https://api.linkmobility.eu/rest/"; public virtual string BaseUrl { get; set; } = "https://api.linkmobility.eu/rest/";
/// <summary> /// <summary>
/// Gets or sets the default timeout for the API. /// Gets or sets the default timeout for the API.
/// </summary> /// </summary>
/// <remarks>
/// The default timeout is <c>100</c> seconds.
/// </remarks>
public virtual TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(100); public virtual TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(100);
/// <summary> /// <summary>
@@ -28,7 +34,7 @@ namespace AMWD.Net.Api.LinkMobility
public virtual IDictionary<string, string> DefaultQueryParams { get; set; } = new Dictionary<string, string>(); public virtual IDictionary<string, string> DefaultQueryParams { get; set; } = new Dictionary<string, string>();
/// <summary> /// <summary>
/// Gets or sets a value indicating whether to allow redirects. /// Gets or sets a value indicating whether to follow redirects from the server.
/// </summary> /// </summary>
public virtual bool AllowRedirects { get; set; } public virtual bool AllowRedirects { get; set; }

View File

@@ -20,5 +20,11 @@ namespace AMWD.Net.Api.LinkMobility
/// </summary> /// </summary>
[EnumMember(Value = "advertisement")] [EnumMember(Value = "advertisement")]
Advertisement = 2, Advertisement = 2,
/// <summary>
/// Represents content that is classified as a personal message.
/// </summary>
[EnumMember(Value = "personal")]
Personal = 3,
} }
} }

View File

@@ -9,7 +9,7 @@ namespace AMWD.Net.Api.LinkMobility
public interface ILinkMobilityClient public interface ILinkMobilityClient
{ {
/// <summary> /// <summary>
/// Performs a POST request to the LINK mobility API. /// Performs a POST request to the LINK Mobility API.
/// </summary> /// </summary>
/// <typeparam name="TResponse">The type of the response.</typeparam> /// <typeparam name="TResponse">The type of the response.</typeparam>
/// <typeparam name="TRequest">The type of the request.</typeparam> /// <typeparam name="TRequest">The type of the request.</typeparam>

View File

@@ -27,7 +27,7 @@ namespace AMWD.Net.Api.LinkMobility
/// <param name="username">The username used for basic authentication.</param> /// <param name="username">The username used for basic authentication.</param>
/// <param name="password">The password used for basic authentication.</param> /// <param name="password">The password used for basic authentication.</param>
/// <param name="clientOptions">Optional configuration settings for the client.</param> /// <param name="clientOptions">Optional configuration settings for the client.</param>
/// <param name="httpClient">Optional <see cref="HttpClient"/> instance if you want a custom <see cref="HttpMessageHandler"/> implemented.</param> /// <param name="httpClient">Optional <see cref="HttpClient"/> instance if you want a custom implementation.</param>
public LinkMobilityClient(string username, string password, ClientOptions? clientOptions = null, HttpClient? httpClient = null) public LinkMobilityClient(string username, string password, ClientOptions? clientOptions = null, HttpClient? httpClient = null)
: this(new BasicAuthentication(username, password), clientOptions, httpClient) : this(new BasicAuthentication(username, password), clientOptions, httpClient)
{ {
@@ -38,7 +38,7 @@ namespace AMWD.Net.Api.LinkMobility
/// </summary> /// </summary>
/// <param name="token">The bearer token used for authentication.</param> /// <param name="token">The bearer token used for authentication.</param>
/// <param name="clientOptions">Optional configuration settings for the client.</param> /// <param name="clientOptions">Optional configuration settings for the client.</param>
/// <param name="httpClient">Optional <see cref="HttpClient"/> instance if you want a custom <see cref="HttpMessageHandler"/> implemented.</param> /// <param name="httpClient">Optional <see cref="HttpClient"/> instance if you want a custom implementation.</param>
public LinkMobilityClient(string token, ClientOptions? clientOptions = null, HttpClient? httpClient = null) public LinkMobilityClient(string token, ClientOptions? clientOptions = null, HttpClient? httpClient = null)
: this(new AccessTokenAuthentication(token), clientOptions, httpClient) : this(new AccessTokenAuthentication(token), clientOptions, httpClient)
{ {
@@ -50,7 +50,7 @@ namespace AMWD.Net.Api.LinkMobility
/// </summary> /// </summary>
/// <param name="authentication">The authentication mechanism used to authorize requests.</param> /// <param name="authentication">The authentication mechanism used to authorize requests.</param>
/// <param name="clientOptions">Optional client configuration settings.</param> /// <param name="clientOptions">Optional client configuration settings.</param>
/// <param name="httpClient">Optional <see cref="HttpClient"/> instance if you want a custom <see cref="HttpMessageHandler"/> implemented.</param> /// <param name="httpClient">Optional <see cref="HttpClient"/> instance if you want a custom implementation.</param>
public LinkMobilityClient(IAuthentication authentication, ClientOptions? clientOptions = null, HttpClient? httpClient = null) public LinkMobilityClient(IAuthentication authentication, ClientOptions? clientOptions = null, HttpClient? httpClient = null)
{ {
if (authentication == null) if (authentication == null)
@@ -66,7 +66,8 @@ namespace AMWD.Net.Api.LinkMobility
} }
/// <summary> /// <summary>
/// Disposes of the resources used by the <see cref="LinkMobilityClient"/> object. /// Disposes all resources used by the <see cref="LinkMobilityClient"/> object.
/// This includes the <see cref="HttpClient"/> whether it was injected or created internally.
/// </summary> /// </summary>
public void Dispose() public void Dispose()
{ {

View File

@@ -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. In this project the REST API of LINK Mobility will be implemented.
- [SMS API](https://developer.linkmobility.eu/sms-api/rest-api) - [SMS API](https://developer.linkmobility.eu/sms-api/rest-api)
- [WhatsApp API](https://developer.linkmobility.eu/whatsapp-api/rest-api) (partial, see Changelog)
--- ---

View File

@@ -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<long?>(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.");
}
}
}

View File

@@ -12,6 +12,13 @@ namespace AMWD.Net.Api.LinkMobility.Utils
/// <br/> /// <br/>
/// See <see href="https://en.wikipedia.org/wiki/MSISDN">Wikipedia: MSISDN</see> for more information. /// See <see href="https://en.wikipedia.org/wiki/MSISDN">Wikipedia: MSISDN</see> for more information.
/// </summary> /// </summary>
/// <remarks>
/// It comes down to a string of digits with a length between 8 and 15, starting with a non-zero digit.
/// This is a common format for international phone numbers, where the first few digits represent the country code, followed by the national number.
/// A leading <c>+</c> is has to be removed (not part of the <see href="https://en.wikipedia.org/wiki/E.164">E.164</see>).
/// <br/>
/// Regex (inside): <c>^[1-9][0-9]{7,14}$</c>
/// </remarks>
/// <param name="msisdn">The string to validate.</param> /// <param name="msisdn">The string to validate.</param>
/// <returns><see langword="true"/> for a valid MSISDN number, <see langword="false"/> otherwise.</returns> /// <returns><see langword="true"/> for a valid MSISDN number, <see langword="false"/> otherwise.</returns>
public static bool IsValidMSISDN(string msisdn) public static bool IsValidMSISDN(string msisdn)

View File

@@ -0,0 +1,32 @@
namespace AMWD.Net.Api.LinkMobility.Webhook.WhatsApp
{
/// <summary>
/// Represents a WhatsApp contact.
/// </summary>
public class Contact
{
/// <summary>
/// The user profile information.
/// </summary>
[JsonProperty("profile")]
public Profile? Profile { get; set; }
/// <summary>
/// WhatsApp user ID.
/// </summary>
/// <remarks>
/// Note that a WhatsApp user's ID and phone number may not always match.
/// </remarks>
[JsonProperty("wa_id")]
public string? WhatsAppId { get; set; }
/// <summary>
/// Identity key hash.
/// </summary>
/// <remarks>
/// Only included if you have enabled the <see href="https://developers.facebook.com/documentation/business-messaging/whatsapp/business-phone-numbers/phone-numbers">identity change check</see> feature.
/// </remarks>
[JsonProperty("identity_key_hash")]
public string? IdentityKeyHash { get; set; }
}
}

View File

@@ -0,0 +1,45 @@
namespace AMWD.Net.Api.LinkMobility.Webhook.WhatsApp
{
/// <summary>
/// Represents a received audio file.
/// </summary>
public class AudioContent
{
/// <summary>
/// Media asset MIME type.
/// </summary>
[JsonProperty("mime_type")]
public string? MimeType { get; set; }
/// <summary>
/// Media asset SHA-256 hash.
/// </summary>
[JsonProperty("sha256")]
public string? Sha256 { get; set; }
/// <summary>
/// Media asset ID.
/// </summary>
/// <remarks>
/// You can <see href="https://developers.facebook.com/documentation/business-messaging/whatsapp/business-phone-numbers/media">perform a GET on this ID</see> to get the asset URL,
/// then perform a GET on the returned URL (using your access token) to get the underlying asset.
/// </remarks>
[JsonProperty("id")]
public string? Id { get; set; }
/// <summary>
/// Media URL.
/// </summary>
/// <remarks>
/// You can query this URL directly with your access token to <see href="https://developers.facebook.com/documentation/business-messaging/whatsapp/business-phone-numbers/media#download-media">download the media asset</see>.
/// </remarks>
[JsonProperty("url")]
public string? Url { get; set; }
/// <summary>
/// indicating if audio is a recording made with the WhatsApp client voice recording feature.
/// </summary>
[JsonProperty("voice")]
public bool? IsVoiceRecord { get; set; }
}
}

View File

@@ -0,0 +1,51 @@
namespace AMWD.Net.Api.LinkMobility.Webhook.WhatsApp
{
/// <summary>
/// Represents a received document file.
/// </summary>
public class DocumentContent
{
/// <summary>
/// Media asset caption text.
/// </summary>
[JsonProperty("caption")]
public string? Caption { get; set; }
/// <summary>
/// Media asset filename.
/// </summary>
[JsonProperty("filename")]
public string? Filename { get; set; }
/// <summary>
/// Media asset MIME type.
/// </summary>
[JsonProperty("mime_type")]
public string? MimeType { get; set; }
/// <summary>
/// Media asset SHA-256 hash.
/// </summary>
[JsonProperty("sha256")]
public string? Sha256 { get; set; }
/// <summary>
/// Media asset ID.
/// </summary>
/// <remarks>
/// You can <see href="https://developers.facebook.com/documentation/business-messaging/whatsapp/business-phone-numbers/media">perform a GET on this ID</see> to get the asset URL,
/// then perform a GET on the returned URL (using your access token) to get the underlying asset.
/// </remarks>
[JsonProperty("id")]
public string? Id { get; set; }
/// <summary>
/// Media URL.
/// </summary>
/// <remarks>
/// You can query this URL directly with your access token to <see href="https://developers.facebook.com/documentation/business-messaging/whatsapp/business-phone-numbers/media#download-media">download the media asset</see>.
/// </remarks>
[JsonProperty("url")]
public string? Url { get; set; }
}
}

View File

@@ -0,0 +1,45 @@
namespace AMWD.Net.Api.LinkMobility.Webhook.WhatsApp
{
/// <summary>
/// Represents a received image file.
/// </summary>
public class ImageContent
{
/// <summary>
/// Media asset caption text.
/// </summary>
[JsonProperty("caption")]
public string? Caption { get; set; }
/// <summary>
/// Media asset MIME type.
/// </summary>
[JsonProperty("mime_type")]
public string? MimeType { get; set; }
/// <summary>
/// Media asset SHA-256 hash.
/// </summary>
[JsonProperty("sha256")]
public string? Sha256 { get; set; }
/// <summary>
/// Media asset ID.
/// </summary>
/// <remarks>
/// You can <see href="https://developers.facebook.com/documentation/business-messaging/whatsapp/business-phone-numbers/media">perform a GET on this ID</see> to get the asset URL,
/// then perform a GET on the returned URL (using your access token) to get the underlying asset.
/// </remarks>
[JsonProperty("id")]
public string? Id { get; set; }
/// <summary>
/// Media URL.
/// </summary>
/// <remarks>
/// You can query this URL directly with your access token to <see href="https://developers.facebook.com/documentation/business-messaging/whatsapp/business-phone-numbers/media#download-media">download the media asset</see>.
/// </remarks>
[JsonProperty("url")]
public string? Url { get; set; }
}
}

View File

@@ -0,0 +1,14 @@
namespace AMWD.Net.Api.LinkMobility.Webhook.WhatsApp
{
/// <summary>
/// Represents a received text.
/// </summary>
public class TextContent
{
/// <summary>
/// The text content of the message.
/// </summary>
[JsonProperty("body")]
public string? Body { get; set; }
}
}

View File

@@ -0,0 +1,45 @@
namespace AMWD.Net.Api.LinkMobility.Webhook.WhatsApp
{
/// <summary>
/// Represents a received video file.
/// </summary>
public class VideoContent
{
/// <summary>
/// Media asset caption text.
/// </summary>
[JsonProperty("caption")]
public string? Caption { get; set; }
/// <summary>
/// Media asset MIME type.
/// </summary>
[JsonProperty("mime_type")]
public string? MimeType { get; set; }
/// <summary>
/// Media asset SHA-256 hash.
/// </summary>
[JsonProperty("sha256")]
public string? Sha256 { get; set; }
/// <summary>
/// Media asset ID.
/// </summary>
/// <remarks>
/// You can <see href="https://developers.facebook.com/documentation/business-messaging/whatsapp/business-phone-numbers/media">perform a GET on this ID</see> to get the asset URL,
/// then perform a GET on the returned URL (using your access token) to get the underlying asset.
/// </remarks>
[JsonProperty("id")]
public string? Id { get; set; }
/// <summary>
/// Media URL.
/// </summary>
/// <remarks>
/// You can query this URL directly with your access token to <see href="https://developers.facebook.com/documentation/business-messaging/whatsapp/business-phone-numbers/media#download-media">download the media asset</see>.
/// </remarks>
[JsonProperty("url")]
public string? Url { get; set; }
}
}

View File

@@ -0,0 +1,66 @@
using AMWD.Net.Api.LinkMobility.Utils;
namespace AMWD.Net.Api.LinkMobility.Webhook.WhatsApp
{
/// <summary>
/// The conversation information.
/// </summary>
/// <remarks>
/// <list type="number">
/// <item>Only included with sent status, and one of either delivered or read status.</item>
/// <item>Omitted entirely for v24.0+ unless webhook is for a free entry point conversation.</item>
/// </list>
/// </remarks>
public class Conversation
{
/// <summary>
/// Unique identifier for the conversation.
/// </summary>
/// <remarks>
/// <para>
/// <strong>Version 24.0 and higher:</strong>
/// <br/>
/// The <see cref="Conversation"/> 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.
/// </para>
/// <para>
/// <strong>Version 23.0 and lower:</strong>
/// <br/>
/// 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.
/// </para>
/// </remarks>
[JsonProperty("id")]
public string? Id { get; set; }
/// <summary>
/// Timestamp indicating when the conversation will expire.
/// </summary>
/// <remarks>
/// The expiration_timestamp property is only included for <see cref="DeliveryStatus.Sent"/> status.
/// </remarks>
[JsonProperty("expiration_timestamp")]
[JsonConverter(typeof(UnixTimestampJsonConverter))]
public DateTime? ExpirationTimestamp { get; set; }
/// <summary>
/// The conversation origin.
/// </summary>
[JsonProperty("origin")]
public ConversationOrigin? Origin { get; set; }
}
/// <summary>
/// The conversation origin.
/// </summary>
public class ConversationOrigin
{
/// <summary>
/// The conversation category.
/// </summary>
[JsonProperty("type")]
public BillingCategory? Type { get; set; }
}
}

View File

@@ -0,0 +1,54 @@
using System.Runtime.Serialization;
using Newtonsoft.Json.Converters;
namespace AMWD.Net.Api.LinkMobility.Webhook.WhatsApp
{
/// <summary>
/// Defines the available pricing category (<see href="https://developers.facebook.com/documentation/business-messaging/whatsapp/pricing#rates">rates</see>).
/// </summary>
[JsonConverter(typeof(StringEnumConverter))]
public enum BillingCategory
{
/// <summary>
/// Indicates an authentication conversation.
/// </summary>
[EnumMember(Value = "authentication")]
Authentication = 1,
/// <summary>
/// Indicates an <see href="https://developers.facebook.com/documentation/business-messaging/whatsapp/pricing/authentication-international-rates">authentication-international</see> conversation.
/// </summary>
[EnumMember(Value = "authentication_international")]
AuthenticationInternational = 2,
/// <summary>
/// Indicates a <see href="https://developers.facebook.com/documentation/business-messaging/whatsapp/marketing-messages/overview">marketing</see> conversation.
/// </summary>
[EnumMember(Value = "marketing")]
Marketing = 3,
/// <summary>
/// Indicates a <see href="https://developers.facebook.com/docs/whatsapp/marketing-messages-lite-api">Marketing Messages Lite API</see> conversation.
/// </summary>
[EnumMember(Value = "marketing_lite")]
MarketingLite = 4,
/// <summary>
/// Indicates a free entry point conversation.
/// </summary>
[EnumMember(Value = "referral_conversion")]
ReferralConversion = 5,
/// <summary>
/// Indicates a service conversation.
/// </summary>
[EnumMember(Value = "service")]
Service = 6,
/// <summary>
/// Indicates a utility conversation.
/// </summary>
[EnumMember(Value = "utility")]
Utility = 7,
}
}

View File

@@ -0,0 +1,33 @@
using System.Runtime.Serialization;
using Newtonsoft.Json.Converters;
namespace AMWD.Net.Api.LinkMobility.Webhook.WhatsApp
{
/// <summary>
/// Defines the billing/pricing type.
/// </summary>
[JsonConverter(typeof(StringEnumConverter))]
public enum BillingType
{
/// <summary>
/// Indicates the message is billable.
/// </summary>
[EnumMember(Value = "regular")]
Regular = 1,
/// <summary>
/// Indicates the message is free because it was
/// either a utility template message
/// or non-template message sent within a customer service window.
/// </summary>
[EnumMember(Value = "free_customer_service")]
FreeCustomerService = 2,
/// <summary>
/// Indicates the message is free because
/// it was sent within an open free entry point window.
/// </summary>
[EnumMember(Value = "free_entry_point")]
FreeEntryPoint = 3,
}
}

View File

@@ -0,0 +1,52 @@
using System.Runtime.Serialization;
using Newtonsoft.Json.Converters;
namespace AMWD.Net.Api.LinkMobility.Webhook.WhatsApp
{
/// <summary>
/// WhatsApp message status.
/// </summary>
[JsonConverter(typeof(StringEnumConverter))]
public enum DeliveryStatus
{
/// <summary>
/// Indicates the message was successfully sent from our servers.
/// <br/>
/// WhatsApp UI equivalent: One checkmark.
/// </summary>
[EnumMember(Value = "sent")]
Sent = 1,
/// <summary>
/// Indicates message was successfully delivered to the WhatsApp user's device.
/// <br/>
/// WhatsApp UI equivalent: Two checkmarks.
/// </summary>
[EnumMember(Value = "delivered")]
Delivered = 2,
/// <summary>
/// Indicates failure to send or deliver the message to the WhatsApp user's device.
/// <br/>
/// WhatsApp UI equivalent: Red error triangle.
/// </summary>
[EnumMember(Value = "failed")]
Failed = 3,
/// <summary>
/// Indicates the message was displayed in an open chat thread in the WhatsApp user's device.
/// <br/>
/// WhatsApp UI equivalent: Two blue checkmarks.
/// </summary>
[EnumMember(Value = "read")]
Read = 4,
/// <summary>
/// Indicates the first time a voice message is played by the WhatsApp user's device.
/// <br/>
/// WhatsApp UI equivalent: Blue microphone.
/// </summary>
[EnumMember(Value = "played")]
Played = 5,
}
}

View File

@@ -0,0 +1,74 @@
using AMWD.Net.Api.LinkMobility.Utils;
using AMWD.Net.Api.LinkMobility.WhatsApp;
namespace AMWD.Net.Api.LinkMobility.Webhook.WhatsApp
{
/// <summary>
/// Represents a received WhatsApp message.
/// </summary>
public class Message
{
/// <summary>
/// WhatsApp user phone number.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[JsonProperty("from")]
public string? From { get; set; }
/// <summary>
/// WhatsApp message ID.
/// </summary>
[JsonProperty("id")]
public string? Id { get; set; }
/// <summary>
/// Unix timestamp indicating when the webhook was triggered.
/// </summary>
[JsonProperty("timestamp")]
[JsonConverter(typeof(UnixTimestampJsonConverter))]
public DateTime? Timestamp { get; set; }
/// <summary>
/// The type of message received.
/// </summary>
[JsonProperty("type")]
public MessageType? Type { get; set; }
#region Content depending on the message type
/// <summary>
/// Audio file content.
/// </summary>
[JsonProperty("audio")]
public AudioContent? Audio { get; set; }
/// <summary>
/// Document file content.
/// </summary>
[JsonProperty("document")]
public DocumentContent? Document { get; set; }
/// <summary>
/// Image file content.
/// </summary>
[JsonProperty("image")]
public ImageContent? Image { get; set; }
/// <summary>
/// Content of a text message.
/// </summary>
[JsonProperty("text")]
public TextContent? Text { get; set; }
/// <summary>
/// Video file content.
/// </summary>
[JsonProperty("video")]
public VideoContent? Video { get; set; }
#endregion Content depending on the message type
}
}

View File

@@ -0,0 +1,21 @@
namespace AMWD.Net.Api.LinkMobility.Webhook.WhatsApp
{
/// <summary>
/// Represents a Meta WhatsApp Notification payload.
/// </summary>
public class Notification
{
/// <summary>
/// The object.
/// In this case, it is specified as <c>whatsapp_business_account</c>.
/// </summary>
[JsonProperty("object")]
public string? Object { get; set; }
/// <summary>
/// Entries of the notification object.
/// </summary>
[JsonProperty("entry")]
public IReadOnlyCollection<NotificationEntry>? Entries { get; set; }
}
}

View File

@@ -0,0 +1,20 @@
namespace AMWD.Net.Api.LinkMobility.Webhook.WhatsApp
{
/// <summary>
/// A change of a WhatsApp notification entry.
/// </summary>
public class NotificationChange
{
/// <summary>
/// The field category.
/// </summary>
[JsonProperty("field")]
public string? Field { get; set; }
/// <summary>
/// The change value.
/// </summary>
[JsonProperty("value")]
public NotificationValue? Value { get; set; }
}
}

View File

@@ -0,0 +1,20 @@
namespace AMWD.Net.Api.LinkMobility.Webhook.WhatsApp
{
/// <summary>
/// A WhatsApp notification entry.
/// </summary>
public class NotificationEntry
{
/// <summary>
/// The WhatsApp business account ID.
/// </summary>
[JsonProperty("id")]
public string? Id { get; set; }
/// <summary>
/// Changes of that entry.
/// </summary>
[JsonProperty("changes")]
public IReadOnlyCollection<NotificationChange>? Changes { get; set; }
}
}

View File

@@ -0,0 +1,20 @@
namespace AMWD.Net.Api.LinkMobility.Webhook.WhatsApp
{
/// <summary>
/// Represents metadata for a notification.
/// </summary>
public class NotificationMetadata
{
/// <summary>
/// Business display phone number.
/// </summary>
[JsonProperty("display_phone_number")]
public string? DisplayPhoneNumber { get; set; }
/// <summary>
/// Business phone number ID.
/// </summary>
[JsonProperty("phone_number_id")]
public string? PhoneNumberId { get; set; }
}
}

View File

@@ -0,0 +1,39 @@
namespace AMWD.Net.Api.LinkMobility.Webhook.WhatsApp
{
/// <summary>
/// A value of the notification change.
/// </summary>
public class NotificationValue
{
/// <summary>
/// The type of messaging product that triggered the webhook.
/// Will be <c>whatsapp</c>.
/// </summary>
[JsonProperty("messaging_product")]
public string? MessagingProduct { get; set; }
/// <summary>
/// Metadata about the notification change.
/// </summary>
[JsonProperty("metadata")]
public NotificationMetadata? Metadata { get; set; }
/// <summary>
/// Contacts of the WhatsApp users involved in the notification change.
/// </summary>
[JsonProperty("contacts")]
public IReadOnlyCollection<Contact>? Contacts { get; set; }
/// <summary>
/// The messages involved in the notification change.
/// </summary>
[JsonProperty("messages")]
public IReadOnlyCollection<Message>? Messages { get; set; }
/// <summary>
/// Status changes of the messages involved in the notification change.
/// </summary>
[JsonProperty("statuses")]
public IReadOnlyCollection<Status>? Statuses { get; set; }
}
}

View File

@@ -0,0 +1,50 @@
namespace AMWD.Net.Api.LinkMobility.Webhook.WhatsApp
{
/// <summary>
/// The pricing information for a WhatsApp message.
/// </summary>
public class Pricing
{
/// <summary>
/// Indicates if the message is billable (<see langword="true"/>) or not (<see langword="false"/>).
/// </summary>
/// <remarks>
/// Note that the <see cref="Billable"/> property will be deprecated in a future versioned release,
/// so we recommend that you start using <see cref="Type"/> and <see cref="Category"/> together to determine if a message is billable,
/// and if so, its billing rate.
/// </remarks>
[JsonProperty("billable")]
public bool? Billable { get; set; }
/// <summary>
/// Pricing model.
/// </summary>
/// <remarks>
/// <list type="bullet">
/// <item>
/// <c>CBP</c>:
/// Indicates conversation-based pricing applies.
/// Will only be set to this value if the webhook was sent before <em>2025-07-01</em>.
/// </item>
/// <item>
/// <c>PMP</c>:
/// Indicates <see href="https://developers.facebook.com/documentation/business-messaging/whatsapp/pricing">per-message pricing</see> applies.
/// </item>
/// </list>
/// </remarks>
[JsonProperty("pricing_model")]
public string? PricingModel { get; set; }
/// <summary>
/// Pricing type.
/// </summary>
[JsonProperty("type")]
public BillingType? Type { get; set; }
/// <summary>
/// Pricing category (rate) applied if billable.
/// </summary>
[JsonProperty("category")]
public BillingCategory? Category { get; set; }
}
}

View File

@@ -0,0 +1,26 @@
namespace AMWD.Net.Api.LinkMobility.Webhook.WhatsApp
{
/// <summary>
/// Represents a WhatsApp user profile.
/// </summary>
public class Profile
{
/// <summary>
/// WhatsApp user's name as it appears in their profile in the WhatsApp client.
/// </summary>
[JsonProperty("name")]
public string? Name { get; set; }
/// <summary>
/// The username.
/// </summary>
[JsonProperty("username")]
public string? Username { get; set; }
/// <summary>
/// The country code.
/// </summary>
[JsonProperty("country_code")]
public string? CountryCode { get; set; }
}
}

View File

@@ -0,0 +1,66 @@
using AMWD.Net.Api.LinkMobility.Utils;
namespace AMWD.Net.Api.LinkMobility.Webhook.WhatsApp
{
/// <summary>
/// Represents a status change of a WhatsApp message.
/// </summary>
public class Status
{
/// <summary>
/// WhatsApp message ID.
/// </summary>
[JsonProperty("id")]
public string? Id { get; set; }
/// <summary>
/// Message status.
/// </summary>
[JsonProperty("status")]
public DeliveryStatus? DeliveryStatus { get; set; }
/// <summary>
/// Timestamp indicating when the webhook was triggered.
/// </summary>
[JsonProperty("timestamp")]
[JsonConverter(typeof(UnixTimestampJsonConverter))]
public DateTime? Timestamp { get; set; }
/// <summary>
/// WhatsApp user phone number or group ID.
/// </summary>
/// <remarks>
/// 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 <see cref="RecipientParticipantId"/> property.
/// </remarks>
[JsonProperty("recipient_id")]
public string? RecipientId { get; set; }
/// <summary>
/// WhatsApp user phone number. Property only included if message was sent to a group.
/// </summary>
[JsonProperty("recipient_participant_id")]
public string? RecipientParticipantId { get; set; }
/// <summary>
/// The conversation information.
/// </summary>
/// <remarks>
/// <list type="number">
/// <item>Only included with sent status, and one of either delivered or read status.</item>
/// <item>Omitted entirely for v24.0+ unless webhook is for a free entry point conversation.</item>
/// </list>
/// </remarks>
[JsonProperty("conversation")]
public Conversation? Conversation { get; set; }
/// <summary>
/// The pricing information.
/// </summary>
/// <remarks>
/// Only included with <see cref="DeliveryStatus.Sent"/> status, and one of either <see cref="DeliveryStatus.Delivered"/> or <see cref="DeliveryStatus.Read"/> status.
/// </remarks>
[JsonProperty("pricing")]
public Pricing? Pricing { get; set; }
}
}

View File

@@ -0,0 +1,45 @@
namespace AMWD.Net.Api.LinkMobility.Webhook.WhatsApp
{
/// <summary>
/// Represents a notification for an incoming WhatsApp message or delivery report.
/// (<see href="https://developer.linkmobility.eu/whatsapp-api/receive-whatsapp-messages">API</see>)
/// </summary>
public class WhatsAppNotification
{
/// <summary>
/// A unique identifier for the customer channel. It is typically in UUID format.
/// </summary>
[JsonProperty("customerChannelUuid")]
public Guid CustomerChannelUuid { get; set; }
/// <summary>
/// The sender's information in E164 formatted MSISDN (see Wikipedia <see href="https://en.wikipedia.org/wiki/MSISDN">MSISDN</see>).
/// In this case is the customer phone number.
/// </summary>
[JsonProperty("sender")]
public string? Sender { get; set; }
/// <summary>
/// The recipient's information in E164 formatted MSISDN (see Wikipedia <see href="https://en.wikipedia.org/wiki/MSISDN">MSISDN</see>).
/// In this case is the Customer Channel identifier.
/// </summary>
[JsonProperty("recipient")]
public string? Recipient { get; set; }
/// <summary>
/// The type of the communication channel.
/// In this case, it is specified as <c>whatsapp</c>.
/// </summary>
[JsonProperty("type")]
public string? Type { get; set; }
/// <summary>
/// Meta WhatsApp Notification payload.
/// </summary>
/// <remarks>
/// See specification on <see href="https://developers.facebook.com/docs/whatsapp/cloud-api/webhooks/components">Meta documentation</see>.
/// </remarks>
[JsonProperty("whatsappNotification")]
public Notification? Body { get; set; }
}
}

View File

@@ -0,0 +1,52 @@
namespace AMWD.Net.Api.LinkMobility.WhatsApp
{
/// <summary>
/// A WhatsApp audio message content.
/// </summary>
public class AudioMessageContent : IMessageContent
{
/// <summary>
/// Initializes a new instance of the <see cref="AudioMessageContent"/> class with the provided audio link.
/// </summary>
/// <param name="mediaLink">The link to an audio file (http/https only).</param>
public AudioMessageContent(string mediaLink)
{
Body = new Content { Link = mediaLink };
}
/// <inheritdoc/>
[JsonProperty("type")]
public MessageType Type => MessageType.Audio;
/// <summary>
/// The content container.
/// </summary>
[JsonProperty("audio")]
public Content Body { get; set; }
/// <inheritdoc/>
public bool IsValid()
{
return !string.IsNullOrWhiteSpace(Body?.Link)
&& (Body!.Link!.StartsWith("http://") || Body!.Link!.StartsWith("https://"));
}
/// <summary>
/// Container for the audio message content.
/// </summary>
public class Content
{
/// <summary>
/// The media link.
/// </summary>
[JsonProperty("link")]
public string? Link { get; set; }
/// <summary>
/// A caption for the audio.
/// </summary>
[JsonProperty("caption")]
public string? Caption { get; set; }
}
}
}

View File

@@ -0,0 +1,58 @@
namespace AMWD.Net.Api.LinkMobility.WhatsApp
{
/// <summary>
/// A WhatsApp document message content.
/// </summary>
public class DocumentMessageContent : IMessageContent
{
/// <summary>
/// Initializes a new instance of the <see cref="DocumentMessageContent"/> class with the provided document link.
/// </summary>
/// <param name="mediaLink">The link to a document (http/https only).</param>
public DocumentMessageContent(string mediaLink)
{
Body = new Content { Link = mediaLink };
}
/// <inheritdoc/>
[JsonProperty("type")]
public MessageType Type => MessageType.Document;
/// <summary>
/// The content container.
/// </summary>
[JsonProperty("document")]
public Content Body { get; set; }
/// <inheritdoc/>
public bool IsValid()
{
return !string.IsNullOrWhiteSpace(Body?.Link)
&& (Body!.Link!.StartsWith("http://") || Body!.Link!.StartsWith("https://"));
}
/// <summary>
/// Container for the document message content.
/// </summary>
public class Content
{
/// <summary>
/// The media link.
/// </summary>
[JsonProperty("link")]
public string? Link { get; set; }
/// <summary>
/// A caption for the document.
/// </summary>
[JsonProperty("caption")]
public string? Caption { get; set; }
/// <summary>
/// A filename for the document (e.g. "file.pdf").
/// </summary>
[JsonProperty("filename")]
public string? Filename { get; set; }
}
}
}

View File

@@ -0,0 +1,52 @@
namespace AMWD.Net.Api.LinkMobility.WhatsApp
{
/// <summary>
/// A WhatsApp image message content.
/// </summary>
public class ImageMessageContent : IMessageContent
{
/// <summary>
/// Initializes a new instance of the <see cref="ImageMessageContent"/> class with the provided message text.
/// </summary>
/// <param name="mediaLink">The link to an image (http/https only).</param>
public ImageMessageContent(string mediaLink)
{
Body = new Content { Link = mediaLink };
}
/// <inheritdoc/>
[JsonProperty("type")]
public MessageType Type => MessageType.Image;
/// <summary>
/// The content container.
/// </summary>
[JsonProperty("image")]
public Content Body { get; set; }
/// <inheritdoc/>
public bool IsValid()
{
return !string.IsNullOrWhiteSpace(Body?.Link)
&& (Body!.Link!.StartsWith("http://") || Body!.Link!.StartsWith("https://"));
}
/// <summary>
/// Container for the text message content.
/// </summary>
public class Content
{
/// <summary>
/// The message text.
/// </summary>
[JsonProperty("link")]
public string? Link { get; set; }
/// <summary>
/// A caption for the image.
/// </summary>
[JsonProperty("caption")]
public string? Caption { get; set; }
}
}
}

View File

@@ -0,0 +1,51 @@
namespace AMWD.Net.Api.LinkMobility.WhatsApp
{
/// <summary>
/// A WhatsApp text message content.
/// </summary>
public class TextMessageContent : IMessageContent
{
/// <summary>
/// Initializes a new instance of the <see cref="TextMessageContent"/> class with the provided message text.
/// </summary>
/// <param name="message">The message text.</param>
public TextMessageContent(string message)
{
Body = new Content { Text = message };
}
/// <inheritdoc/>
[JsonProperty("type")]
public MessageType Type => MessageType.Text;
/// <summary>
/// The content container.
/// </summary>
[JsonProperty("text")]
public Content Body { get; set; }
/// <inheritdoc/>
public bool IsValid()
{
return !string.IsNullOrWhiteSpace(Body?.Text);
}
/// <summary>
/// Container for the text message content.
/// </summary>
public class Content
{
/// <summary>
/// The message text.
/// </summary>
[JsonProperty("body")]
public string? Text { get; set; }
/// <summary>
/// Indicates whether urls should try to be previewed.
/// </summary>
[JsonProperty("preview_url")]
public bool PreviewUrl { get; set; } = false;
}
}
}

View File

@@ -0,0 +1,52 @@
namespace AMWD.Net.Api.LinkMobility.WhatsApp
{
/// <summary>
/// A WhatsApp video message content.
/// </summary>
public class VideoMessageContent : IMessageContent
{
/// <summary>
/// Initializes a new instance of the <see cref="VideoMessageContent"/> class with the provided video link.
/// </summary>
/// <param name="mediaLink">The link to a video (http/https only).</param>
public VideoMessageContent(string mediaLink)
{
Body = new Content { Link = mediaLink };
}
/// <inheritdoc/>
[JsonProperty("type")]
public MessageType Type => MessageType.Video;
/// <summary>
/// The content container.
/// </summary>
[JsonProperty("video")]
public Content Body { get; set; }
/// <inheritdoc/>
public bool IsValid()
{
return !string.IsNullOrWhiteSpace(Body?.Link)
&& (Body!.Link!.StartsWith("http://") || Body!.Link!.StartsWith("https://"));
}
/// <summary>
/// Container for the text message content.
/// </summary>
public class Content
{
/// <summary>
/// The message text.
/// </summary>
[JsonProperty("link")]
public string? Link { get; set; }
/// <summary>
/// A caption for the image.
/// </summary>
[JsonProperty("caption")]
public string? Caption { get; set; }
}
}
}

View File

@@ -0,0 +1,19 @@
namespace AMWD.Net.Api.LinkMobility.WhatsApp
{
/// <summary>
/// The message content of a WhatsApp message.
/// </summary>
public interface IMessageContent
{
/// <summary>
/// The type of the message content.
/// </summary>
[JsonProperty("type")]
MessageType Type { get; }
/// <summary>
/// Determines whether the content message is valid.
/// </summary>
bool IsValid();
}
}

View File

@@ -0,0 +1,42 @@
using System.Runtime.Serialization;
using Newtonsoft.Json.Converters;
namespace AMWD.Net.Api.LinkMobility.WhatsApp
{
/// <summary>
/// Represents the list of supported message types for WhatsApp messages.
/// </summary>
[JsonConverter(typeof(StringEnumConverter))]
public enum MessageType
{
/// <summary>
/// Send a simple text message.
/// </summary>
[EnumMember(Value = "text")]
Text = 1,
/// <summary>
/// Sends a media message, which contains the link to an image.
/// </summary>
[EnumMember(Value = "image")]
Image = 2,
/// <summary>
/// Sends a media message, which contains the link to a video.
/// </summary>
[EnumMember(Value = "video")]
Video = 3,
/// <summary>
/// Sends a media message, which contains the link to an audio file.
/// </summary>
[EnumMember(Value = "audio")]
Audio = 4,
/// <summary>
/// Sends a media message, which contains the link to a document (e.g. PDF).
/// </summary>
[EnumMember(Value = "document")]
Document = 5,
}
}

View File

@@ -0,0 +1,83 @@
namespace AMWD.Net.Api.LinkMobility.WhatsApp
{
/// <summary>
/// Request to send a WhatsApp message to a list of recipients.
/// </summary>
public class SendWhatsAppMessageRequest
{
/// <summary>
/// Initializes a new instance of the <see cref="SendWhatsAppMessageRequest"/> class.
/// </summary>
/// <param name="messageContent">The content of a WhatsApp message.</param>
/// <param name="recipientAddressList">A list of recipient numbers.</param>
public SendWhatsAppMessageRequest(IMessageContent messageContent, IReadOnlyCollection<string> recipientAddressList)
{
MessageContent = messageContent;
RecipientAddressList = recipientAddressList;
}
/// <summary>
/// <em>Optional</em>.
/// May contain a freely definable message id.
/// </summary>
[JsonProperty("clientMessageId")]
public string? ClientMessageId { get; set; }
/// <summary>
/// <em>Optional</em>.
/// The content category that is used to categorize the message (used for blacklisting).
/// </summary>
/// <remarks>
/// The following content categories are supported: <see cref="ContentCategory.Informational"/>, <see cref="ContentCategory.Advertisement"/> or <see cref="ContentCategory.Personal"/>.
/// If no content category is provided, the default setting is used (may be changed inside the web interface).
/// </remarks>
[JsonProperty("contentCategory")]
public ContentCategory? ContentCategory { get; set; }
/// <summary>
/// <em>UTF-8</em> encoded message content.
/// </summary>
[JsonProperty("messageContent")]
public IMessageContent MessageContent { get; set; }
/// <summary>
/// <em>Optional</em>.
/// Priority of the message.
/// </summary>
/// <remarks>
/// Must not exceed the value configured for the channel used to send the message.
/// </remarks>
[JsonProperty("priority")]
public int? Priority { get; set; }
/// <summary>
/// List of recipients (E.164 formatted <see href="https://en.wikipedia.org/wiki/MSISDN">MSISDN</see>s)
/// to whom the message should be sent.
/// <br/>
/// The list of recipients may contain a maximum of <em>1000</em> entries.
/// </summary>
[JsonProperty("recipientAddressList")]
public IReadOnlyCollection<string> RecipientAddressList { get; set; }
/// <summary>
/// <em>Optional</em>.
/// <br/>
/// <see langword="true"/>: The transmission is only simulated, no whatsapp message is sent.
/// Depending on the number of recipients the status code <see cref="StatusCodes.Ok"/> or <see cref="StatusCodes.OkQueued"/> is returned.
/// <br/>
/// <see langword="false"/>: No simulation is done. The whatsapp message is sent. (default)
/// </summary>
[JsonProperty("test")]
public bool? Test { get; set; }
/// <summary>
/// <em>Optional</em>.
/// Specifies the validity periode (in seconds) in which the message is tried to be delivered to the recipient.
/// </summary>
/// <remarks>
/// A minimum of 1 minute (<c>60</c> seconds) and a maximum of 3 days (<c>259200</c> seconds) are allowed.
/// </remarks>
[JsonProperty("validityPeriode")]
public int? ValidityPeriode { get; set; }
}
}

View File

@@ -0,0 +1,42 @@
using System.Threading;
using System.Threading.Tasks;
using AMWD.Net.Api.LinkMobility.Utils;
namespace AMWD.Net.Api.LinkMobility.WhatsApp
{
/// <summary>
/// Implementation of WhatsApp messaging. <see href="https://developer.linkmobility.eu/whatsapp-api/rest-api">API</see>
/// </summary>
public static class WhatsAppExtensions
{
/// <summary>
/// Sends a WhatsApp message to a list of recipients.
/// </summary>
/// <param name="client">The <see cref="ILinkMobilityClient"/> instance.</param>
/// <param name="uuid">The unique identifier of the WhatsApp channel.</param>
/// <param name="request">The request data.</param>
/// <param name="cancellationToken">A cancellation token to propagate notification that operations should be canceled.</param>
public static Task<SendMessageResponse> 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<SendMessageResponse, SendWhatsAppMessageRequest>($"/channels/{uuid}/send/whatsapp", request, cancellationToken: cancellationToken);
}
}
}

View File

@@ -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<WhatsAppNotification>(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<JsonReaderException>(() => JsonConvert.DeserializeObject<WhatsAppNotification>(invalid));
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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<IAuthentication> _authenticationMock;
private Mock<ClientOptions> _clientOptionsMock;
private HttpMessageHandlerMock _httpMessageHandlerMock;
private Guid _uuid;
private SendWhatsAppMessageRequest _request;
[TestInitialize]
public void Initialize()
{
_authenticationMock = new Mock<IAuthentication>();
_clientOptionsMock = new Mock<ClientOptions>();
_httpMessageHandlerMock = new HttpMessageHandlerMock();
_authenticationMock
.Setup(a => a.AddHeader(It.IsAny<HttpClient>()))
.Callback<HttpClient>(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<string, string>());
_clientOptionsMock.Setup(c => c.DefaultQueryParams).Returns(new Dictionary<string, string>());
_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<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>());
_clientOptionsMock.VerifyGet(o => o.DefaultQueryParams, Times.Once);
VerifyNoOtherCalls();
}
[TestMethod]
public void ShouldThrowOnNullRequest()
{
// Arrange
var client = GetClient();
// Act & Assert
var ex = Assert.ThrowsExactly<ArgumentNullException>(() => 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<ArgumentException>(() => 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<ArgumentException>(() => 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<ArgumentException>(() => 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<ArgumentException>(() => 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<HttpClient>(client, "_httpClient")?.Dispose();
ReflectionHelper.SetPrivateField(client, "_httpClient", httpClient);
return client;
}
}
}