Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 42 additions & 2 deletions src/Tests/WhatsAppClientTests.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Microsoft.Extensions.Configuration;
using System.Net.Http.Json;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Xunit.Abstractions;
Expand All @@ -17,7 +18,7 @@ public async Task ThrowsIfNoConfiguredNumberAsync()

var ex = await Assert.ThrowsAsync<ArgumentException>(() => client.SendAsync("1234", new { }));

Assert.Equal("from", ex.ParamName);
Assert.Equal("numberId", ex.ParamName);
}

[SecretsFact("Meta:VerifyToken", "SendFrom", "SendTo")]
Expand Down Expand Up @@ -60,6 +61,45 @@ public async Task SendsButtonAsync()
});
}

[SecretsFact("Meta:VerifyToken", "MediaTo")]
public async Task ResolvesMediaIdFromHttpClient()
{
var (configuration, client) = Initialize();

var media = await client.ResolveMediaAsync(configuration["MediaTo"]!, "4075001832719300");

Assert.NotNull(media);

using var http = client.CreateHttp(configuration["MediaTo"]!);
var stream = await http.GetStreamAsync(media.Url);
using var fs = new FileStream("document.pdf", FileMode.Create, FileAccess.Write);
await stream.CopyToAsync(fs);
}

[SecretsFact("Meta:VerifyToken", "MediaTo")]
public async Task ResolveMediaThrowsForNonExistentId()
{
var (configuration, client) = Initialize();

var ex = await Assert.ThrowsAsync<GraphMethodException>(() => client.ResolveMediaAsync(configuration["MediaTo"]!, "123456789"));

Assert.Contains("123456789", ex.Message);
Assert.Equal(100, ex.Code);
Assert.Equal(33, ex.Subcode);
}

[SecretsFact("Meta:VerifyToken", "MediaTo")]
public async Task ResolveMediaThrowsForNonMediaMessage()
{
var (configuration, client) = Initialize();

await Assert.ThrowsAsync<NotSupportedException>(() => client.ResolveMediaAsync(
new ContentMessage("asdf", new Service("asdf", "1234"), new User("kzu", "2134"), 0,
new UnknownContent(new System.Text.Json.JsonElement()))));
}

record Media(string Url, string MimeType, long FileSize);

(IConfiguration configuration, WhatsAppClient client) Initialize()
{
var configuration = new ConfigurationBuilder()
Expand Down
26 changes: 26 additions & 0 deletions src/WhatsApp/GraphMethodException.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
using System.Text.Json.Serialization;

namespace Devlooped.WhatsApp;

/// <summary>
/// Generic exception for Meta Graph API errors.
/// </summary>
public class GraphMethodException(string message, int code) : Exception(message)
{
/// <summary>
/// The error code returned by the API.
/// </summary>
public int Code { get; } = code;

/// <summary>
/// Optional error subcode returned by the API.
/// </summary>
[JsonPropertyName("error_subcode")]
public int? Subcode { get; init; }

/// <summary>
/// Meta Graph API trace ID for the error.
/// </summary>
[JsonPropertyName("fbtrace_id")]
public required string TraceId { get; init; }
}
16 changes: 13 additions & 3 deletions src/WhatsApp/IWhatsAppClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,25 @@ namespace Devlooped.WhatsApp;
/// </summary>
public interface IWhatsAppClient
{
/// <summary>
/// Creates an authenticated HTTP client for the given number, with the
/// base address of <c>https://graph.facebook.com/{api_version}/</c> as
/// configured for it via <see cref="MetaOptions.ApiVersion"/>.
/// </summary>
/// <param name="numberId">The configured number ID to use for authentication via <see cref="MetaOptions.Numbers"/>.</param>
/// <returns>An HTTP client that can safely be disposed after usage.</returns>
/// <exception cref="ArgumentException">The number <paramref name="numberId"/> is not registered in <see cref="MetaOptions"/>.</exception>
HttpClient CreateHttp(string numberId);

/// <summary>
/// Sends a raw payload object that must match the WhatsApp API.
/// </summary>
/// <param name="from">The phone identifier to send the message from.</param>
/// <param name="numberId">The phone identifier to send the message from, which must be configured via <see cref="MetaOptions.Numbers"/>.</param>
/// <param name="payload">The message payload.</param>
/// <returns>Whether the message was successfully sent.</returns>
/// <see cref="https://developers.facebook.com/docs/whatsapp/cloud-api/reference/messages"/>
/// <exception cref="ArgumentException">The number <paramref name="from"/> is not registered in <see cref="MetaOptions"/>.</exception>
/// <exception cref="ArgumentException">The number <paramref name="numberId"/> is not registered in <see cref="MetaOptions"/>.</exception>
/// <exception cref="HttpRequestException">The HTTP request failed. Exception message contains the error response body from WhatsApp.</exception>
[Description(nameof(Devlooped) + nameof(WhatsApp) + nameof(IWhatsAppClient) + nameof(SendAsync))]
Task SendAsync(string from, object payload);
Task SendAsync(string numberId, object payload);
}
3 changes: 3 additions & 0 deletions src/WhatsApp/MediaReference.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
namespace Devlooped.WhatsApp;

public record MediaReference(string Id, string Url, string MimeType, long FileSize, string Sha256);
17 changes: 1 addition & 16 deletions src/WhatsApp/Message.cs
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@ .value.statuses[0] as $status |

var jq = await Devlooped.JQ.ExecuteAsync(json, JQ);
if (!string.IsNullOrEmpty(jq))
return JsonSerializer.Deserialize(jq, MessageSerializerContext.Default.Message);
return JsonSerializer.Deserialize(jq, WhatsAppSerializerContext.Default.Message);

// NOTE: unsupported payloads would not generate a JQ output, so we can safely ignore them.
return default;
Expand All @@ -237,19 +237,4 @@ .value.statuses[0] as $status |
/// </summary>
[JsonIgnore]
public abstract MessageType Type { get; }

[JsonSourceGenerationOptions(JsonSerializerDefaults.Web,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
UseStringEnumConverter = true,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
WriteIndented = true
)]
[JsonSerializable(typeof(Message))]
[JsonSerializable(typeof(ContentMessage))]
[JsonSerializable(typeof(ErrorMessage))]
[JsonSerializable(typeof(InteractiveMessage))]
[JsonSerializable(typeof(ReactionMessage))]
[JsonSerializable(typeof(StatusMessage))]
[JsonSerializable(typeof(UnsupportedMessage))]
partial class MessageSerializerContext : JsonSerializerContext { }
}
24 changes: 19 additions & 5 deletions src/WhatsApp/WhatsAppClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,22 +27,36 @@ public static IWhatsAppClient Create(IHttpClientFactory httpFactory, MetaOptions
=> new WhatsAppClient(httpFactory, Options.Create(options), logger);

/// <inheritdoc />
public async Task SendAsync(string from, object payload)
public HttpClient CreateHttp(string numberId)
{
if (!options.Numbers.TryGetValue(from, out var token))
throw new ArgumentException($"The number '{from}' is not registered in the options.", nameof(from));
if (!options.Numbers.TryGetValue(numberId, out var token))
throw new ArgumentException($"The number '{numberId}' is not registered in the options.", nameof(numberId));

var http = httpFactory.CreateClient("whatsapp");
http.BaseAddress = new Uri($"https://graph.facebook.com/{options.ApiVersion}/");
http.DefaultRequestHeaders.TryAddWithoutValidation("Authorization", $"Bearer {token}");
http.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));

return http;
}

/// <inheritdoc />
public async Task SendAsync(string numberId, object payload)
{
if (!options.Numbers.TryGetValue(numberId, out var token))
throw new ArgumentException($"The number '{numberId}' is not registered in the options.", nameof(numberId));

using var http = httpFactory.CreateClient("whatsapp");

http.DefaultRequestHeaders.TryAddWithoutValidation("Authorization", $"Bearer {token}");
http.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));

var result = await http.PostAsJsonAsync($"https://graph.facebook.com/{options.ApiVersion}/{from}/messages", payload);
var result = await http.PostAsJsonAsync($"https://graph.facebook.com/{options.ApiVersion}/{numberId}/messages", payload);

if (!result.IsSuccessStatusCode)
{
var error = JsonNode.Parse(await result.Content.ReadAsStringAsync())?.ToJsonString(new() { WriteIndented = true });
logger.LogError("Failed to send WhatsApp message from {From}: {Error}", from, error);
logger.LogError("Failed to send WhatsApp message from {From}: {Error}", numberId, error);
throw new HttpRequestException(error, null, result.StatusCode);
}
}
Expand Down
51 changes: 51 additions & 0 deletions src/WhatsApp/WhatsAppClientExtensions.ResolveMedia.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
using System.Net.Http.Json;

namespace Devlooped.WhatsApp;

partial class WhatsAppClientExtensions
{
/// <summary>
/// Resolves a media content message to a <see cref="MediaReference"/> object.
/// </summary>
/// <param name="client">The WhatsApp client.</param>
/// <param name="message">A <see cref="ContentMessage"/> with <see cref="MediaContent"/> in <see cref="ContentMessage.Content"/>.</param>
/// <param name="cancellation">Optional cancellation token.</param>
/// <returns>The resolved media reference.</returns>
/// <exception cref="NotSupportedException">The <see cref="ContentMessage.Content"/> is not <see cref="MediaContent"/>.</exception>
/// <exception cref="GraphMethodException">The media content could not be resolved to a <see cref="MediaReference"/>.</exception>
/// <exception cref="HttpRequestException">An unknown HTTP exception occurred while resolving the media.</exception>
public static async Task<MediaReference> ResolveMediaAsync(this IWhatsAppClient client, ContentMessage message, CancellationToken cancellation = default)
{
if (message.Content is not MediaContent media)
throw new NotSupportedException("Message does not contain media.");

return await ResolveMediaAsync(client, message.To.Id, media.Id, cancellation);
}

/// <summary>
/// Resolves a media identifier to a <see cref="MediaReference"/> object.
/// </summary>
/// <param name="client">The WhatsApp client.</param>
/// <param name="numberId">The service number identifier that received the media.</param>
/// <param name="mediaId">The media identifier.</param>
/// <param name="cancellation">Optional cancellation token.</param>
/// <returns>The resolved media reference.</returns>
/// <exception cref="GraphMethodException">The media content could not be resolved to a <see cref="MediaReference"/>.</exception>
/// <exception cref="HttpRequestException">An unknown HTTP exception occurred while resolving the media.</exception>
/// <exception cref="ArgumentException">The number <paramref name="numberId"/> is not registered in <see cref="MetaOptions"/>.</exception>
public static async Task<MediaReference> ResolveMediaAsync(this IWhatsAppClient client, string numberId, string mediaId, CancellationToken cancellation = default)
{
using var http = client.CreateHttp(numberId);
var response = await http.GetAsync(mediaId, cancellation);
await response.Content.LoadIntoBufferAsync();

if (!response.IsSuccessStatusCode &&
await response.Content.ReadFromJsonAsync(WhatsAppSerializerContext.Default.ErrorResponse, cancellation) is { } error)
throw error.Error;

response.EnsureSuccessStatusCode();

return await response.Content.ReadFromJsonAsync(WhatsAppSerializerContext.Default.MediaReference, cancellation) ??
throw new InvalidOperationException("Failed to deserialize media reference.");
}
}
14 changes: 13 additions & 1 deletion src/WhatsApp/WhatsAppClientExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,20 @@
/// <summary>
/// Usability extensions for common messaging scenarios for WhatsApp.
/// </summary>
public static class WhatsAppClientExtensions
public static partial class WhatsAppClientExtensions
{
/// <summary>
/// Creates an authenticated HTTP client for the given service number.
/// </summary>
public static HttpClient CreateHttp(this IWhatsAppClient client, Service service)
=> client.CreateHttp(service.Id);

/// <summary>
/// Creates an authenticated HTTP client for the service number that received the given message.
/// </summary>
public static HttpClient CreateHttp(this IWhatsAppClient client, Message message)
=> client.CreateHttp(message.To.Id);

/// <summary>
/// Marks the message as read. Happens automatically when the <see cref="AzureFunctions.Message(Microsoft.AspNetCore.Http.HttpRequest)"/>
/// webhook endpoint is invoked with a message.
Expand Down
26 changes: 26 additions & 0 deletions src/WhatsApp/WhatsAppSerializerContext.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
using System.Text.Json;
using System.Text.Json.Serialization;

namespace Devlooped.WhatsApp;

[JsonSourceGenerationOptions(JsonSerializerDefaults.Web,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
UseStringEnumConverter = true,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower,
WriteIndented = true
)]
[JsonSerializable(typeof(Message))]
[JsonSerializable(typeof(ContentMessage))]
[JsonSerializable(typeof(ErrorMessage))]
[JsonSerializable(typeof(InteractiveMessage))]
[JsonSerializable(typeof(ReactionMessage))]
[JsonSerializable(typeof(StatusMessage))]
[JsonSerializable(typeof(UnsupportedMessage))]
[JsonSerializable(typeof(MediaReference))]
[JsonSerializable(typeof(GraphMethodException))]
[JsonSerializable(typeof(ErrorResponse))]
partial class WhatsAppSerializerContext : JsonSerializerContext { }

record ErrorResponse(GraphMethodException Error);