1
0

7 Commits
v0.1.1 ... main

Author SHA1 Message Date
101b8eef07 Bump to v0.2.0
All checks were successful
Branch Build / build-test-deploy (push) Successful in 42s
Release Build / build-test-deploy (push) Successful in 1m7s
2026-03-24 20:13:42 +01:00
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
7392b0eb98 Added DocFX
All checks were successful
Branch Build / build-test-deploy (push) Successful in 1m22s
2026-03-24 18:01:30 +01:00
3f8cece95c Reorganized namespaces
All checks were successful
Branch Build / build-test-deploy (push) Successful in 1m19s
2026-03-18 22:44:26 +01:00
79567c730c Extensions instead of partial classes
All checks were successful
Branch Build / build-test-deploy (push) Successful in 44s
2026-03-16 20:13:25 +01:00
94706dd82d Remove missing doc generation
All checks were successful
Branch Build / build-test-deploy (push) Successful in 1m13s
2026-03-13 19:12:16 +01:00
75 changed files with 3560 additions and 1484 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

@@ -7,7 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
_No changes yet_ _no changes yet_
## [v0.2.0] - 2026-03-24
### Added
- `Validation` utility class for specifications as [MSISDN](https://en.wikipedia.org/wiki/MSISDN)
- Docs rendering using DocFX
- `Personal` as enum value for `ContentCategory`
- WhatsApp support for
- `Audio`
- `Document`
- `Image`
- `Text`
- `Video`
### Changed
- Channel implementations (SMS, WhatsApp, ...) are extensions to the `ILinkMobilityClient` interface.
- Reorganized namespaces to reflect parts of the API
### Removed
- `IQueryParameter` as no usage on API docs found (for now)
## [v0.1.1] - 2026-03-13 ## [v0.1.1] - 2026-03-13
@@ -35,7 +60,8 @@ _Initial release, SMS only._
[Unreleased]: https://github.com/AM-WD/LinkMobility/compare/v0.1.1...HEAD [Unreleased]: https://github.com/AM-WD/LinkMobility/compare/v0.2.0...HEAD
[v0.2.0]: https://github.com/AM-WD/LinkMobility/compare/v0.1.1...v0.2.0
[v0.1.1]: https://github.com/AM-WD/LinkMobility/compare/v0.1.0...v0.1.1 [v0.1.1]: https://github.com/AM-WD/LinkMobility/compare/v0.1.0...v0.1.1
[v0.1.0]: https://github.com/AM-WD/LinkMobility/commits/v0.1.0 [v0.1.0]: https://github.com/AM-WD/LinkMobility/commits/v0.1.0

View File

@@ -10,11 +10,11 @@ So I decided to implement the current available API myself with a more modern (a
--- ---
Published under [MIT License] (see [**tl;dr**Legal]) Published under [MIT License] (see [choose a license])
[LINK Mobility REST API]: https://developer.linkmobility.eu/ [LINK Mobility REST API]: https://developer.linkmobility.eu/
[outdated repository]: https://github.com/websms-com/websmscom-csharp [outdated repository]: https://github.com/websms-com/websmscom-csharp
[.NET Standard 2.0]: https://learn.microsoft.com/en-us/dotnet/standard/net-standard?tabs=net-standard-2-0 [.NET Standard 2.0]: https://learn.microsoft.com/en-us/dotnet/standard/net-standard?tabs=net-standard-2-0
[MIT License]: LICENSE.txt [MIT License]: LICENSE.txt
[**tl;dr**Legal]: https://www.tldrlegal.com/license/mit-license [choose a license]: https://choosealicense.com/licenses/mit/

61
docs/docfx.json Normal file
View File

@@ -0,0 +1,61 @@
{
"$schema": "https://raw.githubusercontent.com/dotnet/docfx/main/schemas/docfx.schema.json",
"metadata": [
{
"src": [
{
"src": "../",
"files": [
"src/LinkMobility/bin/Release/netstandard2.0/amwd-linkmobility.dll"
]
}
],
"dest": "api",
"outputFormat": "apiPage"
}
],
"build": {
"content": [
{
"files": [ "**/*.{md,yml}" ],
"exclude": [ "_site/**", "obj/**" ]
}
],
"resource": [
{
"files": [ "images/**" ],
"exclude": [ "_site/**", "obj/**" ]
}
],
"output": "_site",
"template": [ "default", "modern", "templates/amwd" ],
"postProcessors": ["ExtractSearchIndex"],
"globalMetadata": {
"_appName": "LINK Mobility REST API for .NET",
"_appTitle": "LINK Mobility REST API for .NET",
"_appFooter": "<span>&copy; AM.WD &mdash; Docs generated using <a href=\"https://dotnet.github.io/docfx\" target=\"_blank\">docfx</a>.</span>",
"_appLogoPath": "images/icon.png",
"_appFaviconPath": "images/favicon.ico",
"_disableBreadcrumb": true,
"_disableContribution": true,
"_enableSearch": true,
"_enableNewTab": true,
"pdf": false
},
"markdownEngineName": "markdig",
"markdownEngineProperties": {
"alerts": {
"TODO": "alert alert-secondary"
}
},
"sitemap": {
"baseUrl": "https://docs.am-wd.de/linkmobility",
"priority": 0.5,
"changefreq": "weekly"
},
"noLangKeyword": false,
"keepFileLink": false,
"cleanupCacheHistory": false,
"disableGitFeatures": true
}
}

BIN
docs/images/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

BIN
docs/images/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 978 B

24
docs/index.md Normal file
View File

@@ -0,0 +1,24 @@
---
_layout: landing
---
# LINK Mobility API
[LINK Mobility] is a company specialized in customer communication.
The available channels are SMS, RCS and WhatsApp Business.
Here you can find the API documentation: https://developer.linkmobility.eu/
## NuGet packages
Here is an overview of the latest package.
| Package URL | Version | Short Description |
|-------------|---------|-------------------|
| [AMWD.Net.Api.LinkMobility] | ![NuGet Version](https://img.shields.io/nuget/v/AMWD.Net.Api.LinkMobility?style=flat-square&logo=nuget) | Package contains the interfaces to handle the API. |
[LINK Mobility]: https://linkmobility.eu
[AMWD.Net.Api.LinkMobility]: https://www.nuget.org/packages/AMWD.Net.Api.LinkMobility

3
docs/templates/amwd/public/main.css vendored Normal file
View File

@@ -0,0 +1,3 @@
#logo {
margin-right: 8px;
}

4
docs/toc.yml Normal file
View File

@@ -0,0 +1,4 @@
- name: API
href: api/
- name: GitHub
href: https://github.com/AM-WD/LinkMobility

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

@@ -1,7 +1,8 @@
namespace AMWD.Net.Api.LinkMobility namespace AMWD.Net.Api.LinkMobility
{ {
/// <summary> /// <summary>
/// Custom status codes as defined by <see href="https://developer.linkmobility.eu/sms-api/rest-api#section/Status-codes">Link Mobility</see>. /// Custom status codes as defined by
/// <see href="https://developer.linkmobility.eu/sms-api/rest-api#section/Status-codes">LINK Mobility</see>.
/// </summary> /// </summary>
public enum StatusCodes : int public enum StatusCodes : int
{ {

View File

@@ -9,17 +9,13 @@ namespace AMWD.Net.Api.LinkMobility
public interface ILinkMobilityClient public interface ILinkMobilityClient
{ {
/// <summary> /// <summary>
/// Sends a text message to a list of recipients. /// Performs a POST request to the LINK Mobility API.
/// </summary> /// </summary>
/// <typeparam name="TResponse">The type of the response.</typeparam>
/// <typeparam name="TRequest">The type of the request.</typeparam>
/// <param name="requestPath">The path of the API endpoint.</param>
/// <param name="request">The request data.</param> /// <param name="request">The request data.</param>
/// <param name="cancellationToken">A cancellation token to propagate notification that operations should be canceled.</param> /// <param name="cancellationToken">A cancellation token to propagate notification that operations should be canceled.</param>
Task<SendMessageResponse> SendTextMessage(SendTextMessageRequest request, CancellationToken cancellationToken = default); Task<TResponse> PostAsync<TResponse, TRequest>(string requestPath, TRequest? request, CancellationToken cancellationToken = default);
/// <summary>
/// Sends a binary message to a list of recipients.
/// </summary>
/// <param name="request">The request data.</param>
/// <param name="cancellationToken">A cancellation token to propagate notification that operations should be canceled.</param>
Task<SendMessageResponse> SendBinaryMessage(SendBinaryMessageRequest request, CancellationToken cancellationToken = default);
} }
} }

View File

@@ -1,68 +0,0 @@
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
namespace AMWD.Net.Api.LinkMobility
{
/// <summary>
/// Implementation of text messaging (SMS). <see href="https://developer.linkmobility.eu/sms-api/rest-api">API</see>
/// </summary>
public partial class LinkMobilityClient : ILinkMobilityClient, IDisposable
{
/// <inheritdoc/>
public Task<SendMessageResponse> SendTextMessage(SendTextMessageRequest request, CancellationToken cancellationToken = default)
{
if (request == null)
throw new ArgumentNullException(nameof(request));
if (string.IsNullOrWhiteSpace(request.MessageContent))
throw new ArgumentException("A message must be provided.", nameof(request.MessageContent));
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 (!IsValidMSISDN(recipient))
throw new ArgumentException($"Recipient address '{recipient}' is not a valid MSISDN format.", nameof(request.RecipientAddressList));
}
return PostAsync<SendMessageResponse, SendTextMessageRequest>("/smsmessaging/text", request, cancellationToken: cancellationToken);
}
/// <inheritdoc/>
public Task<SendMessageResponse> SendBinaryMessage(SendBinaryMessageRequest request, CancellationToken cancellationToken = default)
{
if (request == null)
throw new ArgumentNullException(nameof(request));
if (request.MessageContent?.Count > 0)
{
// Validate that the string is a valid Base64 string
// Might throw a ArgumentNullException or FormatException
foreach (string str in request.MessageContent)
Convert.FromBase64String(str);
}
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 (!IsValidMSISDN(recipient))
throw new ArgumentException($"Recipient address '{recipient}' is not a valid MSISDN format.", nameof(request.RecipientAddressList));
}
return PostAsync<SendMessageResponse, SendBinaryMessageRequest>("/smsmessaging/binary", request, cancellationToken: cancellationToken);
}
// https://en.wikipedia.org/wiki/MSISDN
private static bool IsValidMSISDN(string msisdn)
{
if (string.IsNullOrWhiteSpace(msisdn))
return false;
return Regex.IsMatch(msisdn, @"^[1-9][0-9]{7,14}$", RegexOptions.Compiled);
}
}
}

View File

@@ -7,13 +7,14 @@ using System.Security.Authentication;
using System.Text; using System.Text;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using AMWD.Net.Api.LinkMobility.Utils;
namespace AMWD.Net.Api.LinkMobility namespace AMWD.Net.Api.LinkMobility
{ {
/// <summary> /// <summary>
/// Provides a client for interacting with the Link Mobility API. /// Provides a client for interacting with the Link Mobility API.
/// </summary> /// </summary>
public partial class LinkMobilityClient : ILinkMobilityClient, IDisposable public class LinkMobilityClient : ILinkMobilityClient, IDisposable
{ {
private readonly ClientOptions _clientOptions; private readonly ClientOptions _clientOptions;
private readonly HttpClient _httpClient; private readonly HttpClient _httpClient;
@@ -26,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)
{ {
@@ -37,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)
{ {
@@ -49,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)
@@ -65,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()
{ {
@@ -78,6 +80,45 @@ namespace AMWD.Net.Api.LinkMobility
GC.SuppressFinalize(this); GC.SuppressFinalize(this);
} }
/// <inheritdoc/>
public async Task<TResponse> PostAsync<TResponse, TRequest>(string requestPath, TRequest? request, CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
ValidateRequestPath(requestPath);
string requestUrl = BuildRequestUrl(requestPath);
var httpContent = ConvertRequest(request);
var httpRequest = new HttpRequestMessage
{
Method = HttpMethod.Post,
RequestUri = new Uri(requestUrl, UriKind.Relative),
Content = httpContent,
};
var httpResponse = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
var response = await GetResponse<TResponse>(httpResponse, cancellationToken).ConfigureAwait(false);
return response;
}
private string BuildRequestUrl(string requestPath)
{
string path = requestPath.Trim().TrimStart('/');
var param = new Dictionary<string, string>();
if (_clientOptions.DefaultQueryParams.Count > 0)
{
foreach (var kvp in _clientOptions.DefaultQueryParams)
param[kvp.Key] = kvp.Value;
}
if (param.Count == 0)
return path;
string queryString = string.Join("&", param.Select(kvp => $"{WebUtility.UrlEncode(kvp.Key)}={WebUtility.UrlEncode(kvp.Value)}"));
return $"{path}?{queryString}";
}
private void ValidateClientOptions() private void ValidateClientOptions()
{ {
if (string.IsNullOrWhiteSpace(_clientOptions.BaseUrl)) if (string.IsNullOrWhiteSpace(_clientOptions.BaseUrl))
@@ -148,51 +189,6 @@ namespace AMWD.Net.Api.LinkMobility
} }
} }
private async Task<TResponse> PostAsync<TResponse, TRequest>(string requestPath, TRequest? request, IQueryParameter? queryParams = null, CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
ValidateRequestPath(requestPath);
string requestUrl = BuildRequestUrl(requestPath, queryParams);
var httpContent = ConvertRequest(request);
var httpRequest = new HttpRequestMessage
{
Method = HttpMethod.Post,
RequestUri = new Uri(requestUrl, UriKind.Relative),
Content = httpContent,
};
var httpResponse = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
var response = await GetResponse<TResponse>(httpResponse, cancellationToken).ConfigureAwait(false);
return response;
}
private string BuildRequestUrl(string requestPath, IQueryParameter? queryParams = null)
{
string path = requestPath.Trim().TrimStart('/');
var param = new Dictionary<string, string>();
if (_clientOptions.DefaultQueryParams.Count > 0)
{
foreach (var kvp in _clientOptions.DefaultQueryParams)
param[kvp.Key] = kvp.Value;
}
var customQueryParams = queryParams?.GetQueryParameters();
if (customQueryParams?.Count > 0)
{
foreach (var kvp in customQueryParams)
param[kvp.Key] = kvp.Value;
}
if (param.Count == 0)
return path;
string queryString = string.Join("&", param.Select(kvp => $"{WebUtility.UrlEncode(kvp.Key)}={WebUtility.UrlEncode(kvp.Value)}"));
return $"{path}?{queryString}";
}
private static HttpContent? ConvertRequest<T>(T request) private static HttpContent? ConvertRequest<T>(T request)
{ {
if (request == null) if (request == null)

View File

@@ -1,13 +0,0 @@
namespace AMWD.Net.Api.LinkMobility
{
/// <summary>
/// Represents options defined via query parameters.
/// </summary>
public interface IQueryParameter
{
/// <summary>
/// Retrieves the query parameters.
/// </summary>
IReadOnlyDictionary<string, string> GetQueryParameters();
}
}

View File

@@ -9,10 +9,11 @@ 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)
--- ---
Published under MIT License (see [**tl;dr**Legal]) Published under MIT License (see [choose a license])
[LINK Mobility REST API]: https://developer.linkmobility.eu/ [LINK Mobility REST API]: https://developer.linkmobility.eu/
[**tl;dr**Legal]: https://www.tldrlegal.com/license/mit-license [choose a license]: https://choosealicense.com/licenses/mit/

View File

@@ -11,12 +11,6 @@
[JsonProperty("clientMessageId")] [JsonProperty("clientMessageId")]
public string? ClientMessageId { get; set; } public string? ClientMessageId { get; set; }
/// <summary>
/// The actual number of generated SMS.
/// </summary>
[JsonProperty("smsCount")]
public int? SmsCount { get; set; }
/// <summary> /// <summary>
/// Status code. /// Status code.
/// </summary> /// </summary>

View File

@@ -0,0 +1,14 @@
namespace AMWD.Net.Api.LinkMobility
{
/// <summary>
/// Response of a text message sent to a list of recipients.
/// </summary>
public class SendTextMessageResponse : SendMessageResponse
{
/// <summary>
/// The actual number of generated SMS.
/// </summary>
[JsonProperty("smsCount")]
public int? SmsCount { get; set; }
}
}

View File

@@ -1,7 +1,7 @@
using System.Runtime.Serialization; using System.Runtime.Serialization;
using Newtonsoft.Json.Converters; using Newtonsoft.Json.Converters;
namespace AMWD.Net.Api.LinkMobility namespace AMWD.Net.Api.LinkMobility.Text
{ {
/// <summary> /// <summary>
/// Specifies the type of sender address. /// Specifies the type of sender address.

View File

@@ -1,7 +1,7 @@
using System.Runtime.Serialization; using System.Runtime.Serialization;
using Newtonsoft.Json.Converters; using Newtonsoft.Json.Converters;
namespace AMWD.Net.Api.LinkMobility namespace AMWD.Net.Api.LinkMobility.Text
{ {
/// <summary> /// <summary>
/// Defines the types of delivery methods on a report. /// Defines the types of delivery methods on a report.

View File

@@ -1,7 +1,7 @@
using System.Runtime.Serialization; using System.Runtime.Serialization;
using Newtonsoft.Json.Converters; using Newtonsoft.Json.Converters;
namespace AMWD.Net.Api.LinkMobility namespace AMWD.Net.Api.LinkMobility.Text
{ {
/// <summary> /// <summary>
/// Specifies the message type. /// Specifies the message type.

View File

@@ -1,4 +1,4 @@
namespace AMWD.Net.Api.LinkMobility namespace AMWD.Net.Api.LinkMobility.Text
{ {
/// <summary> /// <summary>
/// Request to send a text message to a list of recipients. /// Request to send a text message to a list of recipients.
@@ -8,9 +8,11 @@
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="SendBinaryMessageRequest"/> class. /// Initializes a new instance of the <see cref="SendBinaryMessageRequest"/> class.
/// </summary> /// </summary>
/// <param name="recipientAddressList">The recipient list.</param> /// <param name="messageContent">A binary message as base64 encoded lines.</param>
public SendBinaryMessageRequest(IReadOnlyCollection<string> recipientAddressList) /// <param name="recipientAddressList">A list of recipient numbers.</param>
public SendBinaryMessageRequest(IReadOnlyCollection<string> messageContent, IReadOnlyCollection<string> recipientAddressList)
{ {
MessageContent = messageContent;
RecipientAddressList = recipientAddressList; RecipientAddressList = recipientAddressList;
} }
@@ -41,7 +43,7 @@
/// The binary data is transmitted without being changed (using 8 bit alphabet). /// The binary data is transmitted without being changed (using 8 bit alphabet).
/// </remarks> /// </remarks>
[JsonProperty("messageContent")] [JsonProperty("messageContent")]
public IReadOnlyCollection<string>? MessageContent { get; set; } public IReadOnlyCollection<string> MessageContent { get; set; }
/// <summary> /// <summary>
/// <em>Optional</em>. /// <em>Optional</em>.

View File

@@ -1,4 +1,4 @@
namespace AMWD.Net.Api.LinkMobility namespace AMWD.Net.Api.LinkMobility.Text
{ {
/// <summary> /// <summary>
/// Request to send a text message to a list of recipients. /// Request to send a text message to a list of recipients.
@@ -8,8 +8,8 @@
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="SendTextMessageRequest"/> class. /// Initializes a new instance of the <see cref="SendTextMessageRequest"/> class.
/// </summary> /// </summary>
/// <param name="messageContent">The message.</param> /// <param name="messageContent">A text message.</param>
/// <param name="recipientAddressList">The recipient list.</param> /// <param name="recipientAddressList">A list of recipient numbers.</param>
public SendTextMessageRequest(string messageContent, IReadOnlyCollection<string> recipientAddressList) public SendTextMessageRequest(string messageContent, IReadOnlyCollection<string> recipientAddressList)
{ {
MessageContent = messageContent; MessageContent = messageContent;

View File

@@ -0,0 +1,78 @@
using System.Threading;
using System.Threading.Tasks;
using AMWD.Net.Api.LinkMobility.Utils;
namespace AMWD.Net.Api.LinkMobility.Text
{
/// <summary>
/// Implementation of text messaging (SMS). <see href="https://developer.linkmobility.eu/sms-api/rest-api">API</see>
/// </summary>
public static class TextMessageExtensions
{
/// <summary>
/// Sends a text message to a list of recipients.
/// </summary>
/// <param name="client">The <see cref="ILinkMobilityClient"/> instance.</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<SendTextMessageResponse> SendTextMessage(this ILinkMobilityClient client, SendTextMessageRequest request, CancellationToken cancellationToken = default)
{
if (request == null)
throw new ArgumentNullException(nameof(request));
if (string.IsNullOrWhiteSpace(request.MessageContent))
throw new ArgumentException("A message must be provided.", nameof(request.MessageContent));
ValidateRecipientList(request.RecipientAddressList);
ValidateContentCategory(request.ContentCategory);
return client.PostAsync<SendTextMessageResponse, SendTextMessageRequest>("/smsmessaging/text", request, cancellationToken: cancellationToken);
}
/// <summary>
/// Sends a binary message to a list of recipients.
/// </summary>
/// <param name="client">The <see cref="ILinkMobilityClient"/> instance.</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<SendTextMessageResponse> SendBinaryMessage(this ILinkMobilityClient client, SendBinaryMessageRequest request, CancellationToken cancellationToken = default)
{
if (request == null)
throw new ArgumentNullException(nameof(request));
if (request.MessageContent == null || request.MessageContent.Count == 0)
throw new ArgumentException("A message must be provided.", nameof(request.MessageContent));
// Easiest way to validate that the string is a valid Base64 string.
// Might throw a ArgumentNullException or FormatException.
foreach (string str in request.MessageContent)
Convert.FromBase64String(str);
ValidateRecipientList(request.RecipientAddressList);
ValidateContentCategory(request.ContentCategory);
return client.PostAsync<SendTextMessageResponse, SendBinaryMessageRequest>("/smsmessaging/binary", request, cancellationToken: cancellationToken);
}
private static void ValidateRecipientList(IReadOnlyCollection<string>? recipientAddressList)
{
if (recipientAddressList == null || recipientAddressList.Count == 0)
throw new ArgumentException("At least one recipient must be provided.", nameof(recipientAddressList));
foreach (string recipient in recipientAddressList)
{
if (!Validation.IsValidMSISDN(recipient))
throw new ArgumentException($"Recipient address '{recipient}' is not a valid MSISDN format.", nameof(recipientAddressList));
}
}
private static void ValidateContentCategory(ContentCategory? contentCategory)
{
if (!contentCategory.HasValue)
return;
if (contentCategory.Value != ContentCategory.Informational && contentCategory.Value != ContentCategory.Advertisement)
throw new ArgumentException($"Content category '{contentCategory.Value}' is not valid.", nameof(contentCategory));
}
}
}

View File

@@ -1,6 +1,6 @@
using System.Globalization; using System.Globalization;
namespace AMWD.Net.Api.LinkMobility namespace AMWD.Net.Api.LinkMobility.Utils
{ {
internal static class SerializerExtensions internal static class SerializerExtensions
{ {

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

@@ -0,0 +1,32 @@
using System.Text.RegularExpressions;
namespace AMWD.Net.Api.LinkMobility.Utils
{
/// <summary>
/// Validation helper for LINK Mobility API requirements.
/// </summary>
public static class Validation
{
/// <summary>
/// Validates whether the provided string is a valid MSISDN (E.164 formatted).
/// <br/>
/// See <see href="https://en.wikipedia.org/wiki/MSISDN">Wikipedia: MSISDN</see> for more information.
/// </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>
/// <returns><see langword="true"/> for a valid MSISDN number, <see langword="false"/> otherwise.</returns>
public static bool IsValidMSISDN(string msisdn)
{
if (string.IsNullOrWhiteSpace(msisdn))
return false;
return Regex.IsMatch(msisdn, @"^[1-9][0-9]{7,14}$", RegexOptions.Compiled);
}
}
}

View File

@@ -1,9 +1,15 @@
namespace AMWD.Net.Api.LinkMobility using AMWD.Net.Api.LinkMobility.Utils;
namespace AMWD.Net.Api.LinkMobility.Webhook
{ {
/// <summary> /// <summary>
/// Representes the response to an incoming message notification. (<see href="https://developer.linkmobility.eu/sms-api/receive-incoming-messages">API</see>) /// Representes the response to an incoming message notification.
/// (See <see href="https://developer.linkmobility.eu/sms-api/receive-incoming-messages">API</see>)
/// </summary> /// </summary>
public class IncomingMessageNotificationResponse /// <remarks>
/// This notification acknowlegement is the same for all webhooks of LINK Mobility.
/// </remarks>
public class NotificationResponse
{ {
/// <summary> /// <summary>
/// Gets or sets the status code of the response. /// Gets or sets the status code of the response.

View File

@@ -1,7 +1,7 @@
using System.Runtime.Serialization; using System.Runtime.Serialization;
using Newtonsoft.Json.Converters; using Newtonsoft.Json.Converters;
namespace AMWD.Net.Api.LinkMobility namespace AMWD.Net.Api.LinkMobility.Webhook.Text
{ {
/// <summary> /// <summary>
/// Defines the delivery status of a message on a report. /// Defines the delivery status of a message on a report.

View File

@@ -0,0 +1,30 @@
using System.Runtime.Serialization;
using Newtonsoft.Json.Converters;
namespace AMWD.Net.Api.LinkMobility.Webhook.Text
{
/// <summary>
/// Defines the type of notification.
/// </summary>
[JsonConverter(typeof(StringEnumConverter))]
public enum TextMessageType
{
/// <summary>
/// Notification of an incoming text message.
/// </summary>
[EnumMember(Value = "text")]
Text = 1,
/// <summary>
/// Notification of an incoming binary message.
/// </summary>
[EnumMember(Value = "binary")]
Binary = 2,
/// <summary>
/// Notification of a delivery report.
/// </summary>
[EnumMember(Value = "deliveryReport")]
DeliveryReport = 3
}
}

View File

@@ -1,19 +1,20 @@
using System.Runtime.Serialization; using AMWD.Net.Api.LinkMobility.Text;
using Newtonsoft.Json.Converters; using AMWD.Net.Api.LinkMobility.Utils;
namespace AMWD.Net.Api.LinkMobility namespace AMWD.Net.Api.LinkMobility.Webhook.Text
{ {
/// <summary> /// <summary>
/// Represents a notification for an incoming message or delivery report. (<see href="https://developer.linkmobility.eu/sms-api/receive-incoming-messages">API</see>) /// Represents a notification for an incoming text message or delivery report.
/// (<see href="https://developer.linkmobility.eu/sms-api/receive-incoming-messages">API</see>)
/// </summary> /// </summary>
public class IncomingMessageNotification public class TextNotification
{ {
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="IncomingMessageNotification"/> class. /// Initializes a new instance of the <see cref="TextNotification"/> class.
/// </summary> /// </summary>
/// <param name="notificationId">The notification id.</param> /// <param name="notificationId">The notification id.</param>
/// <param name="transferId">The transfer id.</param> /// <param name="transferId">The transfer id.</param>
public IncomingMessageNotification(string notificationId, string transferId) public TextNotification(string notificationId, string transferId)
{ {
NotificationId = notificationId; NotificationId = notificationId;
TransferId = transferId; TransferId = transferId;
@@ -23,7 +24,7 @@ namespace AMWD.Net.Api.LinkMobility
/// Defines the content type of your notification. /// Defines the content type of your notification.
/// </summary> /// </summary>
[JsonProperty("messageType")] [JsonProperty("messageType")]
public Type MessageType { get; set; } public TextMessageType MessageType { get; set; }
/// <summary> /// <summary>
/// 20 digit long identification of your notification. /// 20 digit long identification of your notification.
@@ -32,7 +33,7 @@ namespace AMWD.Net.Api.LinkMobility
public string NotificationId { get; set; } public string NotificationId { get; set; }
/// <summary> /// <summary>
/// <see cref="Type.DeliveryReport"/>: /// <see cref="Text.TextMessageType.DeliveryReport"/>:
/// <br/> /// <br/>
/// Unique transfer-id to connect the deliveryReport to the initial message. /// Unique transfer-id to connect the deliveryReport to the initial message.
/// </summary> /// </summary>
@@ -40,7 +41,7 @@ namespace AMWD.Net.Api.LinkMobility
public string TransferId { get; set; } public string TransferId { get; set; }
/// <summary> /// <summary>
/// <see cref="Type.Text"/>, <see cref="Type.Binary"/>: /// <see cref="Text.TextMessageType.Text"/>, <see cref="Text.TextMessageType.Binary"/>:
/// <br/> /// <br/>
/// Indicates whether you received message is a SMS or a flash-SMS. /// Indicates whether you received message is a SMS or a flash-SMS.
/// </summary> /// </summary>
@@ -54,9 +55,9 @@ namespace AMWD.Net.Api.LinkMobility
public string? SenderAddress { get; set; } public string? SenderAddress { get; set; }
/// <summary> /// <summary>
/// <see cref="Type.Text"/>, <see cref="Type.Binary"/>: /// <see cref="Text.TextMessageType.Text"/>, <see cref="Text.TextMessageType.Binary"/>:
/// <br/> /// <br/>
/// <see cref="AddressType.International"/> defines the number format of the mobile originated <see cref="SenderAddress"/>. /// <see cref="AddressType.International"/> - defines the number format of the mobile originated <see cref="SenderAddress"/>.
/// International numbers always includes the country prefix. /// International numbers always includes the country prefix.
/// </summary> /// </summary>
[JsonProperty("senderAddressType")] [JsonProperty("senderAddressType")]
@@ -72,7 +73,7 @@ namespace AMWD.Net.Api.LinkMobility
public string? RecipientAddress { get; set; } public string? RecipientAddress { get; set; }
/// <summary> /// <summary>
/// <see cref="Type.Text"/>, <see cref="Type.Binary"/>: /// <see cref="Text.TextMessageType.Text"/>, <see cref="Text.TextMessageType.Binary"/>:
/// <br/> /// <br/>
/// Defines the number format of the mobile originated message. /// Defines the number format of the mobile originated message.
/// </summary> /// </summary>
@@ -80,7 +81,7 @@ namespace AMWD.Net.Api.LinkMobility
public AddressType? RecipientAddressType { get; set; } public AddressType? RecipientAddressType { get; set; }
/// <summary> /// <summary>
/// <see cref="Type.Text"/>: /// <see cref="Text.TextMessageType.Text"/>:
/// <br/> /// <br/>
/// Text body of the message encoded in <c>UTF-8</c>. /// Text body of the message encoded in <c>UTF-8</c>.
/// In the case of concatenated SMS it will contain the complete content of all segments. /// In the case of concatenated SMS it will contain the complete content of all segments.
@@ -89,7 +90,7 @@ namespace AMWD.Net.Api.LinkMobility
public string? TextMessageContent { get; set; } public string? TextMessageContent { get; set; }
/// <summary> /// <summary>
/// <see cref="Type.Binary"/>: /// <see cref="Text.TextMessageType.Binary"/>:
/// <br/> /// <br/>
/// Indicates whether a user-data-header is included within a <c>Base64</c> encoded byte segment. /// Indicates whether a user-data-header is included within a <c>Base64</c> encoded byte segment.
/// </summary> /// </summary>
@@ -97,7 +98,7 @@ namespace AMWD.Net.Api.LinkMobility
public bool? UserDataHeaderPresent { get; set; } public bool? UserDataHeaderPresent { get; set; }
/// <summary> /// <summary>
/// <see cref="Type.Binary"/>: /// <see cref="Text.TextMessageType.Binary"/>:
/// <br/> /// <br/>
/// Content of a binary SMS in an array of <c>Base64</c> strings (URL safe). /// Content of a binary SMS in an array of <c>Base64</c> strings (URL safe).
/// </summary> /// </summary>
@@ -105,7 +106,7 @@ namespace AMWD.Net.Api.LinkMobility
public IReadOnlyCollection<string>? BinaryMessageContent { get; set; } public IReadOnlyCollection<string>? BinaryMessageContent { get; set; }
/// <summary> /// <summary>
/// <see cref="Type.DeliveryReport"/>: /// <see cref="Text.TextMessageType.DeliveryReport"/>:
/// <br/> /// <br/>
/// Status of the message. /// Status of the message.
/// </summary> /// </summary>
@@ -113,7 +114,7 @@ namespace AMWD.Net.Api.LinkMobility
public DeliveryStatus? DeliveryReportMessageStatus { get; set; } public DeliveryStatus? DeliveryReportMessageStatus { get; set; }
/// <summary> /// <summary>
/// <see cref="Type.DeliveryReport"/>: /// <see cref="Text.TextMessageType.DeliveryReport"/>:
/// <br/> /// <br/>
/// ISO 8601 timestamp. Point of time sending the message to recipients address. /// ISO 8601 timestamp. Point of time sending the message to recipients address.
/// </summary> /// </summary>
@@ -121,7 +122,7 @@ namespace AMWD.Net.Api.LinkMobility
public DateTime? SentOn { get; set; } public DateTime? SentOn { get; set; }
/// <summary> /// <summary>
/// <see cref="Type.DeliveryReport"/>: /// <see cref="Text.TextMessageType.DeliveryReport"/>:
/// <br/> /// <br/>
/// ISO 8601 timestamp. Point of time of submitting the message to the mobile operators network. /// ISO 8601 timestamp. Point of time of submitting the message to the mobile operators network.
/// </summary> /// </summary>
@@ -129,7 +130,7 @@ namespace AMWD.Net.Api.LinkMobility
public DateTime? DeliveredOn { get; set; } public DateTime? DeliveredOn { get; set; }
/// <summary> /// <summary>
/// <see cref="Type.DeliveryReport"/>: /// <see cref="Text.TextMessageType.DeliveryReport"/>:
/// <br/> /// <br/>
/// Type of delivery used to send the message. /// Type of delivery used to send the message.
/// </summary> /// </summary>
@@ -137,7 +138,7 @@ namespace AMWD.Net.Api.LinkMobility
public DeliveryType? DeliveredAs { get; set; } public DeliveryType? DeliveredAs { get; set; }
/// <summary> /// <summary>
/// <see cref="Type.DeliveryReport"/>: /// <see cref="Text.TextMessageType.DeliveryReport"/>:
/// <br/> /// <br/>
/// In the case of a delivery report, the <see cref="ClientMessageId"/> contains the optional submitted message id. /// In the case of a delivery report, the <see cref="ClientMessageId"/> contains the optional submitted message id.
/// </summary> /// </summary>
@@ -145,43 +146,18 @@ namespace AMWD.Net.Api.LinkMobility
public string? ClientMessageId { get; set; } public string? ClientMessageId { get; set; }
/// <summary> /// <summary>
/// Defines the type of notification. /// Tries to parse the given content as <see cref="TextNotification"/>.
/// </summary>
[JsonConverter(typeof(StringEnumConverter))]
public enum Type
{
/// <summary>
/// Notification of an incoming text message.
/// </summary>
[EnumMember(Value = "text")]
Text = 1,
/// <summary>
/// Notification of an incoming binary message.
/// </summary>
[EnumMember(Value = "binary")]
Binary = 2,
/// <summary>
/// Notification of a delivery report.
/// </summary>
[EnumMember(Value = "deliveryReport")]
DeliveryReport = 3
}
/// <summary>
/// Tries to parse the given content as <see cref="IncomingMessageNotification"/>.
/// </summary> /// </summary>
/// <param name="json">The given content (should be the notification json).</param> /// <param name="json">The given content (should be the notification json).</param>
/// <param name="notification">The deserialized notification.</param> /// <param name="notification">The deserialized notification.</param>
/// <returns> /// <returns>
/// <see langword="true"/> if the content could be parsed; otherwise, <see langword="false"/>. /// <see langword="true"/> if the content could be parsed; otherwise, <see langword="false"/>.
/// </returns> /// </returns>
public static bool TryParse(string json, out IncomingMessageNotification? notification) public static bool TryParse(string json, out TextNotification? notification)
{ {
try try
{ {
notification = json.DeserializeObject<IncomingMessageNotification>(); notification = json.DeserializeObject<TextNotification>();
return notification != null; return notification != null;
} }
catch catch

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,45 @@
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>
/// <remarks>
/// LINK Mobility API docs: <see href="https://developer.linkmobility.eu/whatsapp-api/rest-api#/paths/receiveIncomingWhatsappmessaging/post"/>
/// </remarks>
[JsonProperty("messages")]
public IReadOnlyCollection<Message>? Messages { get; set; }
/// <summary>
/// Status changes of the messages involved in the notification change.
/// </summary>
/// <remarks>
/// LINK Mobility API docs: <see href="https://developer.linkmobility.eu/whatsapp-api/rest-api#/paths/receiveStatusWhatsappmessaging/post"/>
/// </remarks>
[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

@@ -39,6 +39,8 @@ namespace LinkMobility.Tests.Helpers
public Queue<HttpResponseMessage> Responses { get; } = new(); public Queue<HttpResponseMessage> Responses { get; } = new();
public Mock<HttpClientHandler> Mock { get; } public Mock<HttpClientHandler> Mock { get; }
public IProtectedMock<HttpClientHandler> Protected => Mock.Protected();
} }
internal class HttpRequestMessageCallback internal class HttpRequestMessageCallback

View File

@@ -145,7 +145,7 @@ namespace LinkMobility.Tests
var client = GetClient(); var client = GetClient();
// Act // Act
var response = await ReflectionHelper.InvokePrivateMethodAsync<TestClass>(client, "PostAsync", "test", _request, null, TestContext.CancellationToken); var response = await client.PostAsync<TestClass, TestClass>("test", _request, TestContext.CancellationToken);
// Assert // Assert
Assert.IsNotNull(response); Assert.IsNotNull(response);
@@ -166,57 +166,12 @@ namespace LinkMobility.Tests
Assert.AreEqual("Scheme Parameter", callback.Headers["Authorization"]); Assert.AreEqual("Scheme Parameter", callback.Headers["Authorization"]);
Assert.AreEqual("LinkMobilityClient/1.0.0", callback.Headers["User-Agent"]); Assert.AreEqual("LinkMobilityClient/1.0.0", callback.Headers["User-Agent"]);
_httpMessageHandlerMock.Mock _httpMessageHandlerMock.Protected.Verify("SendAsync", Times.Once(), ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>());
.Protected()
.Verify("SendAsync", Times.Once(), ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>());
_clientOptionsMock.VerifyGet(o => o.DefaultQueryParams, Times.Exactly(2)); _clientOptionsMock.VerifyGet(o => o.DefaultQueryParams, Times.Exactly(2));
VerifyNoOtherCalls(); VerifyNoOtherCalls();
} }
[TestMethod]
public async Task ShouldAddCustomQueryParameters()
{
// Arrange
var queryParams = new TestParams();
_httpMessageHandlerMock.Responses.Enqueue(new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent(@"{ ""string"": ""some-string"", ""integer"": 123 }", Encoding.UTF8, "application/json"),
});
var client = GetClient();
// Act
var response = await ReflectionHelper.InvokePrivateMethodAsync<TestClass>(client, "PostAsync", "params/path", _request, queryParams, TestContext.CancellationToken);
// Assert
Assert.IsNotNull(response);
Assert.HasCount(1, _httpMessageHandlerMock.RequestCallbacks);
var callback = _httpMessageHandlerMock.RequestCallbacks.First();
Assert.AreEqual(HttpMethod.Post, callback.HttpMethod);
Assert.AreEqual("https://localhost/rest/params/path?test=query+text", callback.Url);
Assert.AreEqual(@"{""string"":""Happy Testing"",""integer"":54321}", 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.Mock
.Protected()
.Verify("SendAsync", Times.Once(), ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>());
_clientOptionsMock.VerifyGet(o => o.DefaultQueryParams, Times.Once);
VerifyNoOtherCalls();
}
[TestMethod] [TestMethod]
public void ShouldDisposeHttpClient() public void ShouldDisposeHttpClient()
{ {
@@ -227,9 +182,7 @@ namespace LinkMobility.Tests
client.Dispose(); client.Dispose();
// Assert // Assert
_httpMessageHandlerMock.Mock _httpMessageHandlerMock.Protected.Verify("Dispose", Times.Once(), exactParameterMatch: true, true);
.Protected()
.Verify("Dispose", Times.Once(), exactParameterMatch: true, true);
VerifyNoOtherCalls(); VerifyNoOtherCalls();
} }
@@ -245,9 +198,7 @@ namespace LinkMobility.Tests
client.Dispose(); client.Dispose();
// Assert // Assert
_httpMessageHandlerMock.Mock _httpMessageHandlerMock.Protected.Verify("Dispose", Times.Once(), exactParameterMatch: true, true);
.Protected()
.Verify("Dispose", Times.Once(), exactParameterMatch: true, true);
VerifyNoOtherCalls(); VerifyNoOtherCalls();
} }
@@ -256,7 +207,7 @@ namespace LinkMobility.Tests
public void ShouldAssertClientOptions() public void ShouldAssertClientOptions()
{ {
// Arrange + Act // Arrange + Act
var client = GetClient(); _ = GetClient();
// Assert // Assert
VerifyNoOtherCalls(); VerifyNoOtherCalls();
@@ -320,7 +271,7 @@ namespace LinkMobility.Tests
// Act & Assert // Act & Assert
await Assert.ThrowsExactlyAsync<ObjectDisposedException>(async () => await Assert.ThrowsExactlyAsync<ObjectDisposedException>(async () =>
{ {
await ReflectionHelper.InvokePrivateMethodAsync<object>(client, "PostAsync", "/request/path", _request, null, TestContext.CancellationToken); await client.PostAsync<object, TestClass>("/request/path", _request, TestContext.CancellationToken);
}); });
} }
@@ -336,7 +287,7 @@ namespace LinkMobility.Tests
// Act & Assert // Act & Assert
await Assert.ThrowsExactlyAsync<ArgumentNullException>(async () => await Assert.ThrowsExactlyAsync<ArgumentNullException>(async () =>
{ {
await ReflectionHelper.InvokePrivateMethodAsync<object>(client, "PostAsync", path, _request, null, TestContext.CancellationToken); await client.PostAsync<object, TestClass>(path, _request, TestContext.CancellationToken);
}); });
} }
@@ -349,7 +300,7 @@ namespace LinkMobility.Tests
// Act & Assert // Act & Assert
await Assert.ThrowsExactlyAsync<ArgumentException>(async () => await Assert.ThrowsExactlyAsync<ArgumentException>(async () =>
{ {
await ReflectionHelper.InvokePrivateMethodAsync<object>(client, "PostAsync", "foo?bar=baz", _request, null, TestContext.CancellationToken); await client.PostAsync<object, TestClass>("foo?bar=baz", _request, TestContext.CancellationToken);
}); });
} }
@@ -366,7 +317,7 @@ namespace LinkMobility.Tests
var client = GetClient(); var client = GetClient();
// Act // Act
var response = await ReflectionHelper.InvokePrivateMethodAsync<TestClass>(client, "PostAsync", "/request/path", _request, null, TestContext.CancellationToken); var response = await client.PostAsync<TestClass, TestClass>("/request/path", _request, TestContext.CancellationToken);
// Assert // Assert
Assert.IsNotNull(response); Assert.IsNotNull(response);
@@ -389,9 +340,7 @@ namespace LinkMobility.Tests
Assert.AreEqual("Scheme Parameter", callback.Headers["Authorization"]); Assert.AreEqual("Scheme Parameter", callback.Headers["Authorization"]);
Assert.AreEqual("LinkMobilityClient/1.0.0", callback.Headers["User-Agent"]); Assert.AreEqual("LinkMobilityClient/1.0.0", callback.Headers["User-Agent"]);
_httpMessageHandlerMock.Mock _httpMessageHandlerMock.Protected.Verify("SendAsync", Times.Once(), ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>());
.Protected()
.Verify("SendAsync", Times.Once(), ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>());
_clientOptionsMock.VerifyGet(o => o.DefaultQueryParams, Times.Once); _clientOptionsMock.VerifyGet(o => o.DefaultQueryParams, Times.Once);
VerifyNoOtherCalls(); VerifyNoOtherCalls();
@@ -411,7 +360,7 @@ namespace LinkMobility.Tests
var client = GetClient(); var client = GetClient();
// Act // Act
var response = await ReflectionHelper.InvokePrivateMethodAsync<TestClass>(client, "PostAsync", "/request/path", stringContent, null, TestContext.CancellationToken); var response = await client.PostAsync<TestClass, HttpContent>("/request/path", stringContent, TestContext.CancellationToken);
// Assert // Assert
Assert.IsNotNull(response); Assert.IsNotNull(response);
@@ -434,9 +383,7 @@ namespace LinkMobility.Tests
Assert.AreEqual("Scheme Parameter", callback.Headers["Authorization"]); Assert.AreEqual("Scheme Parameter", callback.Headers["Authorization"]);
Assert.AreEqual("LinkMobilityClient/1.0.0", callback.Headers["User-Agent"]); Assert.AreEqual("LinkMobilityClient/1.0.0", callback.Headers["User-Agent"]);
_httpMessageHandlerMock.Mock _httpMessageHandlerMock.Protected.Verify("SendAsync", Times.Once(), ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>());
.Protected()
.Verify("SendAsync", Times.Once(), ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>());
_clientOptionsMock.VerifyGet(o => o.DefaultQueryParams, Times.Once); _clientOptionsMock.VerifyGet(o => o.DefaultQueryParams, Times.Once);
VerifyNoOtherCalls(); VerifyNoOtherCalls();
@@ -455,7 +402,7 @@ namespace LinkMobility.Tests
var client = GetClient(); var client = GetClient();
// Act // Act
var response = await ReflectionHelper.InvokePrivateMethodAsync<TestClass>(client, "PostAsync", "posting", null, null, TestContext.CancellationToken); var response = await client.PostAsync<TestClass, object>("posting", null, TestContext.CancellationToken);
// Assert // Assert
Assert.IsNotNull(response); Assert.IsNotNull(response);
@@ -479,9 +426,7 @@ namespace LinkMobility.Tests
Assert.AreEqual("Scheme Parameter", callback.Headers["Authorization"]); Assert.AreEqual("Scheme Parameter", callback.Headers["Authorization"]);
Assert.AreEqual("LinkMobilityClient/1.0.0", callback.Headers["User-Agent"]); Assert.AreEqual("LinkMobilityClient/1.0.0", callback.Headers["User-Agent"]);
_httpMessageHandlerMock.Mock _httpMessageHandlerMock.Protected.Verify("SendAsync", Times.Once(), ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>());
.Protected()
.Verify("SendAsync", Times.Once(), ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>());
} }
[TestMethod] [TestMethod]
@@ -501,7 +446,7 @@ namespace LinkMobility.Tests
// Act & Assert // Act & Assert
var ex = await Assert.ThrowsExactlyAsync<AuthenticationException>(async () => var ex = await Assert.ThrowsExactlyAsync<AuthenticationException>(async () =>
{ {
await ReflectionHelper.InvokePrivateMethodAsync<object>(client, "PostAsync", "foo", _request, null, TestContext.CancellationToken); await client.PostAsync<object, TestClass>("foo", _request, TestContext.CancellationToken);
}); });
Assert.IsNull(ex.InnerException); Assert.IsNull(ex.InnerException);
Assert.AreEqual($"HTTP auth missing: {statusCode}", ex.Message); Assert.AreEqual($"HTTP auth missing: {statusCode}", ex.Message);
@@ -524,7 +469,7 @@ namespace LinkMobility.Tests
// Act & Assert // Act & Assert
var ex = await Assert.ThrowsExactlyAsync<ApplicationException>(async () => var ex = await Assert.ThrowsExactlyAsync<ApplicationException>(async () =>
{ {
await ReflectionHelper.InvokePrivateMethodAsync<object>(client, "PostAsync", "foo", _request, null, TestContext.CancellationToken); await client.PostAsync<object, TestClass>("foo", _request, TestContext.CancellationToken);
}); });
Assert.IsNull(ex.InnerException); Assert.IsNull(ex.InnerException);
Assert.AreEqual($"Unknown HTTP response: {statusCode}", ex.Message); Assert.AreEqual($"Unknown HTTP response: {statusCode}", ex.Message);
@@ -545,7 +490,7 @@ namespace LinkMobility.Tests
// Act & Assert // Act & Assert
await Assert.ThrowsExactlyAsync<JsonReaderException>(async () => await Assert.ThrowsExactlyAsync<JsonReaderException>(async () =>
{ {
await ReflectionHelper.InvokePrivateMethodAsync<TestClass>(client, "PostAsync", "some-path", _request, null, TestContext.CancellationToken); await client.PostAsync<TestClass, TestClass>("some-path", _request, TestContext.CancellationToken);
}); });
} }
@@ -563,7 +508,7 @@ namespace LinkMobility.Tests
var client = GetClient(); var client = GetClient();
// Act // Act
string response = await ReflectionHelper.InvokePrivateMethodAsync<string>(client, "PostAsync", "path", _request, null, TestContext.CancellationToken); string response = await client.PostAsync<string, TestClass>("path", _request, TestContext.CancellationToken);
// Assert // Assert
Assert.IsNotNull(response); Assert.IsNotNull(response);
@@ -586,9 +531,7 @@ namespace LinkMobility.Tests
Assert.AreEqual("Scheme Parameter", callback.Headers["Authorization"]); Assert.AreEqual("Scheme Parameter", callback.Headers["Authorization"]);
Assert.AreEqual("LinkMobilityClient/1.0.0", callback.Headers["User-Agent"]); Assert.AreEqual("LinkMobilityClient/1.0.0", callback.Headers["User-Agent"]);
_httpMessageHandlerMock.Mock _httpMessageHandlerMock.Protected.Verify("SendAsync", Times.Once(), ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>());
.Protected()
.Verify("SendAsync", Times.Once(), ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>());
_clientOptionsMock.VerifyGet(o => o.DefaultQueryParams, Times.Once); _clientOptionsMock.VerifyGet(o => o.DefaultQueryParams, Times.Once);
VerifyNoOtherCalls(); VerifyNoOtherCalls();
@@ -638,16 +581,5 @@ namespace LinkMobility.Tests
[JsonProperty("integer")] [JsonProperty("integer")]
public int Int { get; set; } public int Int { get; set; }
} }
private class TestParams : IQueryParameter
{
public IReadOnlyDictionary<string, string> GetQueryParameters()
{
return new Dictionary<string, string>
{
{ "test", "query text" }
};
}
}
} }
} }

View File

@@ -6,10 +6,11 @@ using System.Text;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using AMWD.Net.Api.LinkMobility; using AMWD.Net.Api.LinkMobility;
using AMWD.Net.Api.LinkMobility.Text;
using LinkMobility.Tests.Helpers; using LinkMobility.Tests.Helpers;
using Moq.Protected; using Moq.Protected;
namespace LinkMobility.Tests.Sms namespace LinkMobility.Tests.Text
{ {
[TestClass] [TestClass]
public class SendBinaryMessageTest public class SendBinaryMessageTest
@@ -42,10 +43,7 @@ namespace LinkMobility.Tests.Sms
_clientOptionsMock.Setup(c => c.AllowRedirects).Returns(true); _clientOptionsMock.Setup(c => c.AllowRedirects).Returns(true);
_clientOptionsMock.Setup(c => c.UseProxy).Returns(false); _clientOptionsMock.Setup(c => c.UseProxy).Returns(false);
_request = new SendBinaryMessageRequest(["436991234567"]) _request = new SendBinaryMessageRequest(["SGVsbG8gV29ybGQ="], ["436991234567"]); // "Hello World" in Base64
{
MessageContent = ["SGVsbG8gV29ybGQ="] // "Hello World" base64
};
} }
[TestMethod] [TestMethod]
@@ -87,14 +85,27 @@ namespace LinkMobility.Tests.Sms
Assert.AreEqual("Scheme Parameter", callback.Headers["Authorization"]); Assert.AreEqual("Scheme Parameter", callback.Headers["Authorization"]);
Assert.AreEqual("LinkMobilityClient/1.0.0", callback.Headers["User-Agent"]); Assert.AreEqual("LinkMobilityClient/1.0.0", callback.Headers["User-Agent"]);
_httpMessageHandlerMock.Mock _httpMessageHandlerMock.Protected.Verify("SendAsync", Times.Once(), ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>());
.Protected()
.Verify("SendAsync", Times.Once(), ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>());
_clientOptionsMock.VerifyGet(o => o.DefaultQueryParams, Times.Once); _clientOptionsMock.VerifyGet(o => o.DefaultQueryParams, Times.Once);
VerifyNoOtherCalls(); VerifyNoOtherCalls();
} }
[TestMethod]
public void ShouldThrowOnInvalidContentCategoryForBinary()
{
// Arrange
_request.ContentCategory = 0;
var client = GetClient();
// Act & Assert
var ex = Assert.ThrowsExactly<ArgumentException>(() => client.SendBinaryMessage(_request, TestContext.CancellationToken));
Assert.AreEqual("contentCategory", ex.ParamName);
Assert.StartsWith("Content category '0' is not valid.", ex.Message);
VerifyNoOtherCalls();
}
[TestMethod] [TestMethod]
public async Task ShouldSendBinaryMessageFullDetails() public async Task ShouldSendBinaryMessageFullDetails()
{ {
@@ -145,9 +156,7 @@ namespace LinkMobility.Tests.Sms
Assert.AreEqual("Scheme Parameter", callback.Headers["Authorization"]); Assert.AreEqual("Scheme Parameter", callback.Headers["Authorization"]);
Assert.AreEqual("LinkMobilityClient/1.0.0", callback.Headers["User-Agent"]); Assert.AreEqual("LinkMobilityClient/1.0.0", callback.Headers["User-Agent"]);
_httpMessageHandlerMock.Mock _httpMessageHandlerMock.Protected.Verify("SendAsync", Times.Once(), ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>());
.Protected()
.Verify("SendAsync", Times.Once(), ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>());
_clientOptionsMock.VerifyGet(o => o.DefaultQueryParams, Times.Once); _clientOptionsMock.VerifyGet(o => o.DefaultQueryParams, Times.Once);
VerifyNoOtherCalls(); VerifyNoOtherCalls();
@@ -167,6 +176,36 @@ namespace LinkMobility.Tests.Sms
VerifyNoOtherCalls(); VerifyNoOtherCalls();
} }
[TestMethod]
public void ShouldThrowOnNullMessageContentList()
{
// Arrange
_request.MessageContent = null;
var client = GetClient();
// Act & Assert
var ex = Assert.ThrowsExactly<ArgumentException>(() => client.SendBinaryMessage(_request, TestContext.CancellationToken));
Assert.AreEqual("MessageContent", ex.ParamName);
VerifyNoOtherCalls();
}
[TestMethod]
public void ShouldThrowOnEmptyMessageContentList()
{
// Arrange
_request.MessageContent = [];
var client = GetClient();
// Act & Assert
var ex = Assert.ThrowsExactly<ArgumentException>(() => client.SendBinaryMessage(_request, TestContext.CancellationToken));
Assert.AreEqual("MessageContent", ex.ParamName);
VerifyNoOtherCalls();
}
[TestMethod] [TestMethod]
public void ShouldThrowOnInvalidMessageEncoding() public void ShouldThrowOnInvalidMessageEncoding()
{ {
@@ -205,7 +244,7 @@ namespace LinkMobility.Tests.Sms
// Act & Assert // Act & Assert
var ex = Assert.ThrowsExactly<ArgumentException>(() => client.SendBinaryMessage(_request, TestContext.CancellationToken)); var ex = Assert.ThrowsExactly<ArgumentException>(() => client.SendBinaryMessage(_request, TestContext.CancellationToken));
Assert.AreEqual("RecipientAddressList", ex.ParamName); Assert.AreEqual("recipientAddressList", ex.ParamName);
VerifyNoOtherCalls(); VerifyNoOtherCalls();
} }
@@ -224,7 +263,7 @@ namespace LinkMobility.Tests.Sms
// Act & Assert // Act & Assert
var ex = Assert.ThrowsExactly<ArgumentException>(() => client.SendBinaryMessage(_request, TestContext.CancellationToken)); var ex = Assert.ThrowsExactly<ArgumentException>(() => client.SendBinaryMessage(_request, TestContext.CancellationToken));
Assert.AreEqual("RecipientAddressList", ex.ParamName); Assert.AreEqual("recipientAddressList", ex.ParamName);
Assert.StartsWith($"Recipient address '{recipient}' is not a valid MSISDN format.", ex.Message); Assert.StartsWith($"Recipient address '{recipient}' is not a valid MSISDN format.", ex.Message);
VerifyNoOtherCalls(); VerifyNoOtherCalls();

View File

@@ -6,10 +6,11 @@ using System.Text;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using AMWD.Net.Api.LinkMobility; using AMWD.Net.Api.LinkMobility;
using AMWD.Net.Api.LinkMobility.Text;
using LinkMobility.Tests.Helpers; using LinkMobility.Tests.Helpers;
using Moq.Protected; using Moq.Protected;
namespace LinkMobility.Tests.Sms namespace LinkMobility.Tests.Text
{ {
[TestClass] [TestClass]
public class SendTextMessageTest public class SendTextMessageTest
@@ -85,14 +86,27 @@ namespace LinkMobility.Tests.Sms
Assert.AreEqual("Scheme Parameter", callback.Headers["Authorization"]); Assert.AreEqual("Scheme Parameter", callback.Headers["Authorization"]);
Assert.AreEqual("LinkMobilityClient/1.0.0", callback.Headers["User-Agent"]); Assert.AreEqual("LinkMobilityClient/1.0.0", callback.Headers["User-Agent"]);
_httpMessageHandlerMock.Mock _httpMessageHandlerMock.Protected.Verify("SendAsync", Times.Once(), ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>());
.Protected()
.Verify("SendAsync", Times.Once(), ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>());
_clientOptionsMock.VerifyGet(o => o.DefaultQueryParams, Times.Once); _clientOptionsMock.VerifyGet(o => o.DefaultQueryParams, Times.Once);
VerifyNoOtherCalls(); VerifyNoOtherCalls();
} }
[TestMethod]
public void ShouldThrowOnInvalidContentCategory()
{
// Arrange
_request.ContentCategory = 0;
var client = GetClient();
// Act & Assert
var ex = Assert.ThrowsExactly<ArgumentException>(() => client.SendTextMessage(_request, TestContext.CancellationToken));
Assert.AreEqual("contentCategory", ex.ParamName);
Assert.StartsWith("Content category '0' is not valid.", ex.Message);
VerifyNoOtherCalls();
}
[TestMethod] [TestMethod]
public async Task ShouldSendTextMessageFullDetails() public async Task ShouldSendTextMessageFullDetails()
{ {
@@ -145,9 +159,7 @@ namespace LinkMobility.Tests.Sms
Assert.AreEqual("Scheme Parameter", callback.Headers["Authorization"]); Assert.AreEqual("Scheme Parameter", callback.Headers["Authorization"]);
Assert.AreEqual("LinkMobilityClient/1.0.0", callback.Headers["User-Agent"]); Assert.AreEqual("LinkMobilityClient/1.0.0", callback.Headers["User-Agent"]);
_httpMessageHandlerMock.Mock _httpMessageHandlerMock.Protected.Verify("SendAsync", Times.Once(), ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>());
.Protected()
.Verify("SendAsync", Times.Once(), ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>());
_clientOptionsMock.VerifyGet(o => o.DefaultQueryParams, Times.Once); _clientOptionsMock.VerifyGet(o => o.DefaultQueryParams, Times.Once);
VerifyNoOtherCalls(); VerifyNoOtherCalls();
@@ -192,7 +204,7 @@ namespace LinkMobility.Tests.Sms
// Act & Assert // Act & Assert
var ex = Assert.ThrowsExactly<ArgumentException>(() => client.SendTextMessage(req, TestContext.CancellationToken)); var ex = Assert.ThrowsExactly<ArgumentException>(() => client.SendTextMessage(req, TestContext.CancellationToken));
Assert.AreEqual("RecipientAddressList", ex.ParamName); Assert.AreEqual("recipientAddressList", ex.ParamName);
VerifyNoOtherCalls(); VerifyNoOtherCalls();
} }
@@ -211,7 +223,7 @@ namespace LinkMobility.Tests.Sms
// Act & Assert // Act & Assert
var ex = Assert.ThrowsExactly<ArgumentException>(() => client.SendTextMessage(req, TestContext.CancellationToken)); var ex = Assert.ThrowsExactly<ArgumentException>(() => client.SendTextMessage(req, TestContext.CancellationToken));
Assert.AreEqual("RecipientAddressList", ex.ParamName); Assert.AreEqual("recipientAddressList", ex.ParamName);
Assert.StartsWith($"Recipient address '{recipient}' is not a valid MSISDN format.", ex.Message); Assert.StartsWith($"Recipient address '{recipient}' is not a valid MSISDN format.", ex.Message);
VerifyNoOtherCalls(); VerifyNoOtherCalls();

View File

@@ -0,0 +1,33 @@
using AMWD.Net.Api.LinkMobility.Utils;
namespace LinkMobility.Tests.Utils
{
[TestClass]
public class ValidationTest
{
[TestMethod]
[DataRow("10000000")]
[DataRow("12345678")]
[DataRow("123456789012345")]
[DataRow("14155552671")]
public void ShouldValidateMSISDNSuccessful(string msisdn)
{
Assert.IsTrue(Validation.IsValidMSISDN(msisdn));
}
[TestMethod]
[DataRow(null)]
[DataRow("")]
[DataRow(" ")]
[DataRow("012345678")]
[DataRow("+123456789")]
[DataRow("1234 5678")]
[DataRow("1234567")]
[DataRow("1234567890123456")]
[DataRow("abc1234567")]
public void ShouldValidateMSISDNNotSuccessful(string msisdn)
{
Assert.IsFalse(Validation.IsValidMSISDN(msisdn));
}
}
}

View File

@@ -1,9 +1,10 @@
using AMWD.Net.Api.LinkMobility; using AMWD.Net.Api.LinkMobility.Text;
using AMWD.Net.Api.LinkMobility.Webhook.Text;
namespace LinkMobility.Tests.Models namespace LinkMobility.Tests.Webhook.Text
{ {
[TestClass] [TestClass]
public class IncomingMessageNotificationTest public class TextNotificationTest
{ {
[TestMethod] [TestMethod]
public void ShouldParseAllPropertiesForTextNotification() public void ShouldParseAllPropertiesForTextNotification()
@@ -29,13 +30,13 @@ namespace LinkMobility.Tests.Models
}"; }";
// Act // Act
bool successful = IncomingMessageNotification.TryParse(json, out var notification); bool successful = TextNotification.TryParse(json, out var notification);
// Assert // Assert
Assert.IsTrue(successful, "TryParse should return true for valid json"); Assert.IsTrue(successful, "TryParse should return true for valid json");
Assert.IsNotNull(notification); Assert.IsNotNull(notification);
Assert.AreEqual(IncomingMessageNotification.Type.Text, notification.MessageType); Assert.AreEqual(TextMessageType.Text, notification.MessageType);
Assert.AreEqual("notif-123", notification.NotificationId); Assert.AreEqual("notif-123", notification.NotificationId);
Assert.AreEqual("trans-456", notification.TransferId); Assert.AreEqual("trans-456", notification.TransferId);
@@ -80,7 +81,7 @@ namespace LinkMobility.Tests.Models
string invalid = "this is not json"; string invalid = "this is not json";
// Act // Act
bool successful = IncomingMessageNotification.TryParse(invalid, out var notification); bool successful = TextNotification.TryParse(invalid, out var notification);
// Assert // Assert
Assert.IsFalse(successful); Assert.IsFalse(successful);

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