diff --git a/src/Tests/WhatsAppClientTests.cs b/src/Tests/WhatsAppClientTests.cs index f165bdc..23239fd 100644 --- a/src/Tests/WhatsAppClientTests.cs +++ b/src/Tests/WhatsAppClientTests.cs @@ -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; @@ -17,7 +18,7 @@ public async Task ThrowsIfNoConfiguredNumberAsync() var ex = await Assert.ThrowsAsync(() => client.SendAsync("1234", new { })); - Assert.Equal("from", ex.ParamName); + Assert.Equal("numberId", ex.ParamName); } [SecretsFact("Meta:VerifyToken", "SendFrom", "SendTo")] @@ -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(() => 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(() => 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() diff --git a/src/WhatsApp/GraphMethodException.cs b/src/WhatsApp/GraphMethodException.cs new file mode 100644 index 0000000..9d3015c --- /dev/null +++ b/src/WhatsApp/GraphMethodException.cs @@ -0,0 +1,26 @@ +using System.Text.Json.Serialization; + +namespace Devlooped.WhatsApp; + +/// +/// Generic exception for Meta Graph API errors. +/// +public class GraphMethodException(string message, int code) : Exception(message) +{ + /// + /// The error code returned by the API. + /// + public int Code { get; } = code; + + /// + /// Optional error subcode returned by the API. + /// + [JsonPropertyName("error_subcode")] + public int? Subcode { get; init; } + + /// + /// Meta Graph API trace ID for the error. + /// + [JsonPropertyName("fbtrace_id")] + public required string TraceId { get; init; } +} diff --git a/src/WhatsApp/IWhatsAppClient.cs b/src/WhatsApp/IWhatsAppClient.cs index a19dace..d46cf7b 100644 --- a/src/WhatsApp/IWhatsAppClient.cs +++ b/src/WhatsApp/IWhatsAppClient.cs @@ -7,15 +7,25 @@ namespace Devlooped.WhatsApp; /// public interface IWhatsAppClient { + /// + /// Creates an authenticated HTTP client for the given number, with the + /// base address of https://graph.facebook.com/{api_version}/ as + /// configured for it via . + /// + /// The configured number ID to use for authentication via . + /// An HTTP client that can safely be disposed after usage. + /// The number is not registered in . + HttpClient CreateHttp(string numberId); + /// /// Sends a raw payload object that must match the WhatsApp API. /// - /// The phone identifier to send the message from. + /// The phone identifier to send the message from, which must be configured via . /// The message payload. /// Whether the message was successfully sent. /// - /// The number is not registered in . + /// The number is not registered in . /// The HTTP request failed. Exception message contains the error response body from WhatsApp. [Description(nameof(Devlooped) + nameof(WhatsApp) + nameof(IWhatsAppClient) + nameof(SendAsync))] - Task SendAsync(string from, object payload); + Task SendAsync(string numberId, object payload); } \ No newline at end of file diff --git a/src/WhatsApp/MediaReference.cs b/src/WhatsApp/MediaReference.cs new file mode 100644 index 0000000..5f47dd4 --- /dev/null +++ b/src/WhatsApp/MediaReference.cs @@ -0,0 +1,3 @@ +namespace Devlooped.WhatsApp; + +public record MediaReference(string Id, string Url, string MimeType, long FileSize, string Sha256); \ No newline at end of file diff --git a/src/WhatsApp/Message.cs b/src/WhatsApp/Message.cs index addc0a8..37728d0 100644 --- a/src/WhatsApp/Message.cs +++ b/src/WhatsApp/Message.cs @@ -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; @@ -237,19 +237,4 @@ .value.statuses[0] as $status | /// [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 { } } diff --git a/src/WhatsApp/WhatsAppClient.cs b/src/WhatsApp/WhatsAppClient.cs index 374da16..e280450 100644 --- a/src/WhatsApp/WhatsAppClient.cs +++ b/src/WhatsApp/WhatsAppClient.cs @@ -27,22 +27,36 @@ public static IWhatsAppClient Create(IHttpClientFactory httpFactory, MetaOptions => new WhatsAppClient(httpFactory, Options.Create(options), logger); /// - 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; + } + + /// + 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); } } diff --git a/src/WhatsApp/WhatsAppClientExtensions.ResolveMedia.cs b/src/WhatsApp/WhatsAppClientExtensions.ResolveMedia.cs new file mode 100644 index 0000000..966b906 --- /dev/null +++ b/src/WhatsApp/WhatsAppClientExtensions.ResolveMedia.cs @@ -0,0 +1,51 @@ +using System.Net.Http.Json; + +namespace Devlooped.WhatsApp; + +partial class WhatsAppClientExtensions +{ + /// + /// Resolves a media content message to a object. + /// + /// The WhatsApp client. + /// A with in . + /// Optional cancellation token. + /// The resolved media reference. + /// The is not . + /// The media content could not be resolved to a . + /// An unknown HTTP exception occurred while resolving the media. + public static async Task 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); + } + + /// + /// Resolves a media identifier to a object. + /// + /// The WhatsApp client. + /// The service number identifier that received the media. + /// The media identifier. + /// Optional cancellation token. + /// The resolved media reference. + /// The media content could not be resolved to a . + /// An unknown HTTP exception occurred while resolving the media. + /// The number is not registered in . + public static async Task 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."); + } +} diff --git a/src/WhatsApp/WhatsAppClientExtensions.cs b/src/WhatsApp/WhatsAppClientExtensions.cs index c7a9123..4b5b15d 100644 --- a/src/WhatsApp/WhatsAppClientExtensions.cs +++ b/src/WhatsApp/WhatsAppClientExtensions.cs @@ -3,8 +3,20 @@ /// /// Usability extensions for common messaging scenarios for WhatsApp. /// -public static class WhatsAppClientExtensions +public static partial class WhatsAppClientExtensions { + /// + /// Creates an authenticated HTTP client for the given service number. + /// + public static HttpClient CreateHttp(this IWhatsAppClient client, Service service) + => client.CreateHttp(service.Id); + + /// + /// Creates an authenticated HTTP client for the service number that received the given message. + /// + public static HttpClient CreateHttp(this IWhatsAppClient client, Message message) + => client.CreateHttp(message.To.Id); + /// /// Marks the message as read. Happens automatically when the /// webhook endpoint is invoked with a message. diff --git a/src/WhatsApp/WhatsAppSerializerContext.cs b/src/WhatsApp/WhatsAppSerializerContext.cs new file mode 100644 index 0000000..3d7f751 --- /dev/null +++ b/src/WhatsApp/WhatsAppSerializerContext.cs @@ -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); \ No newline at end of file