diff --git a/readme.md b/readme.md index 55090a0..8b6d4d8 100644 --- a/readme.md +++ b/readme.md @@ -55,7 +55,7 @@ builder.Services.AddWhatsApp((messages, cancellation) => Console.WriteLine($"Error: {error.Error.Message} ({error.Error.Code})"); break; case InteractiveMessage interactive: - Console.WriteLine($"Interactive: {interactive.Selection.Title} ({interactive.Selection.Id})"); + Console.WriteLine($"Interactive: {interactive.Selection.Text} ({interactive.Selection.Value})"); break; case StatusMessage status: Console.WriteLine($"Status: {status.Status}"); @@ -348,6 +348,49 @@ builder.Services.AddWhatsApp() .UseConversation(); ``` +Finally, the pipeline handlers are encouraged to use the `Response` model +for sending messages, instead of invoking the `IWhatsAppClient` directly. +This allows the pipeline to automatically manage the sending and integrate +better with persistence or other cross-cutting concerns, while allowing for +flexible in-progress message generation (i.e. notify user that processing +is ongoing via reactions, etc.). + +For example, to send the typing indicator when starting processing messages +in a handler: + +```csharp +public override async IAsyncEnumerable HandleAsync(IEnumerable messages, [EnumeratorCancellation] CancellationToken cancellation = default) +{ + foreach (var message in messages) + { + if (message is UserMessage user) + yield return user.Typing(); + + // Do some processing, send final response + + yield return message.Reply("Processing complete!"); + } +} +``` + +The response model supports all the common WhatsApp message types, including +plain text responses, interactive buttons, reactions and templates, such as: + +```csharp + yield return message.Template(new MessageTemplate("order", "en") + { + Buttons = + [ + // i.e. template button get tracking info for an order + ButtonComponent.Payload(orderId), + // i.e. a template url button to navigate to the order page + ButtonComponent.Url(orderId), + // i.e. a template catalog button to view the WhatsApp business catalog + ButtonComponent.Catalog() + ] + }); +``` + ### OpenTelemetry The configurable built-in support for OpenTelemetry shown above allows tracking diff --git a/src/SampleApp/Sample/ProcessHandler.cs b/src/SampleApp/Sample/ProcessHandler.cs index b7e1e43..01cbd9f 100644 --- a/src/SampleApp/Sample/ProcessHandler.cs +++ b/src/SampleApp/Sample/ProcessHandler.cs @@ -31,8 +31,8 @@ public async IAsyncEnumerable HandleAsync(IEnumerable messag } else if (message is InteractiveMessage interactive) { - logger.LogWarning("👤 chose {Id} ({Title})", interactive.Selection.Id, interactive.Selection.Title); - yield return interactive.Reply($"👤 chose: {interactive.Selection.Title} ({interactive.Selection.Id})"); + logger.LogWarning("👤 chose {Id} ({Title})", interactive.Selection.Value, interactive.Selection.Text); + yield return interactive.Reply($"👤 chose: {interactive.Selection.Text} ({interactive.Selection.Value})"); } else if (message is ReactionMessage reaction) { diff --git a/src/Tests/Content/WhatsApp/TemplateButton.json b/src/Tests/Content/WhatsApp/TemplateButton.json new file mode 100644 index 0000000..c08d35c --- /dev/null +++ b/src/Tests/Content/WhatsApp/TemplateButton.json @@ -0,0 +1,42 @@ +{ + "object": "whatsapp_business_account", + "entry": [ + { + "id": "123456789012345", + "changes": [ + { + "value": { + "messaging_product": "whatsapp", + "metadata": { + "display_phone_number": "1234567890", + "phone_number_id": "987654321098765" + }, + "contacts": [ + { + "profile": { "name": "RandomName" }, + "wa_id": "1234567890" + } + ], + "messages": [ + { + "context": { + "from": "1234567890", + "id": "wamid.RandomContextID" + }, + "from": "1234567890", + "id": "wamid.RandomMessageID", + "timestamp": "1741288810", + "type": "button", + "button": { + "payload": "id1", + "text": "Track" + } + } + ] + }, + "field": "messages" + } + ] + } + ] +} \ No newline at end of file diff --git a/src/Tests/WhatsAppClientTests.cs b/src/Tests/WhatsAppClientTests.cs index 07a5b71..c2c7829 100644 --- a/src/Tests/WhatsAppClientTests.cs +++ b/src/Tests/WhatsAppClientTests.cs @@ -165,35 +165,87 @@ public async Task SendsTemplateAsync() { var (configuration, client) = Initialize(); - await client.SendTemplateAsync(configuration["SendFrom"]!, configuration["SendTo"]!, new + await client.SendTemplateAsync(configuration["SendFrom"]!, configuration["SendTo"]!, new MessageTemplate("reminder", "es") { - name = "reminder", - language = new - { - code = "es" - }, - components = new[] - { - new - { - type = "body", - parameters = new[] - { - new - { - type = "text", - parameter_name = "reminder_text", - text = "Dentista" - }, - new - { - type = "text", - parameter_name = "reminder_datetime", - text = "3pm" - } - } - } - } + Body = new BodyComponent([ + new TextParameter("🦷", "emoji"), + new TextParameter("Dentista", "text"), + new TextParameter("3pm", "when") + ]) + }); + } + + [SecretsFact("Meta:VerifyToken", "SendFrom", "SendTo")] + public async Task SendsTemplateMeetingAsync() + { + var (configuration, client) = Initialize(); + + await client.SendTemplateAsync(configuration["SendFrom"]!, configuration["SendTo"]!, new MessageTemplate("meeting", "es") + { + Header = new HeaderComponent(new LocationParameter(37.483307, -122.148981, "Pablo Morales", "1 Hacker Way, Menlo Park, CA 94025")), + Body = new BodyComponent( + [ + new TextParameter("kzu", "who"), + new TextParameter("office", "where"), + new TextParameter("15'", "when") + ]) + }); + } + + [SecretsFact("Meta:VerifyToken", "SendFrom", "SendTo")] + public async Task SendsTemplate2Async() + { + var (configuration, client) = Initialize(); + + await client.SendTemplateAsync(configuration["SendFrom"]!, configuration["SendTo"]!, new MessageTemplate("reminder2", "en") + { + Header = new HeaderComponent(new LocationParameter(37.483307, -122.148981, "Pablo Morales", "1 Hacker Way, Menlo Park, CA 94025")), + Body = new BodyComponent( + [ + new TextParameter("🦷", "emoji"), + new TextParameter("Dentista", "text"), + new TextParameter("3pm", "when") + ]) + }); + } + + [SecretsFact("Meta:VerifyToken", "SendFrom", "SendTo")] + public async Task SendsTemplateUrlAsync() + { + var (configuration, client) = Initialize(); + + await client.SendTemplateAsync(configuration["SendFrom"]!, configuration["SendTo"]!, new MessageTemplate("variables", "en") + { + Header = new HeaderComponent(new TextParameter("kzu", "name")), + Body = new BodyComponent( + [ + new TextParameter("dotnet", "tag"), + ]), + Buttons = + [ + ButtonComponent.Url("dotnet"), + ButtonComponent.Default + ] + }); + } + + [SecretsFact("Meta:VerifyToken", "SendFrom", "SendTo")] + public async Task SendsTemplateButtonsAsync() + { + var (configuration, client) = Initialize(); + + await client.SendTemplateAsync(configuration["SendFrom"]!, configuration["SendTo"]!, new MessageTemplate("buttons", "en") + { + Buttons = + [ + ButtonComponent.Payload("id1"), + //NOTE: we can omit the buttons if we don't need custom payloads for them. + // the webhook will get the payload == button text in that case. + //ButtonComponent.Text("id2"), + // Since we omitted the second button, we'll need to specify the index in this case. + // otherwise, it defaults to its index in the array. + ButtonComponent.Url("dotnet", index: 2), + ] }); } @@ -267,6 +319,24 @@ await Assert.ThrowsAsync(() => client.ResolveMediaAsync( new UnknownContent(new System.Text.Json.JsonElement())))); } + [SecretsFact("Meta:VerifyToken", "SendFrom", "SendTo")] + public async Task SendsTemplateWithMessageTemplateObjectAsync() + { + var (configuration, client) = Initialize(); + + // Using the new MessageTemplate object instead of anonymous object + var template = new MessageTemplate("reminder", "es") + { + Body = new BodyComponent([ + new TextParameter("🦷", "emoji"), + new TextParameter("Dentista", "text"), + new TextParameter("3pm", "when") + ]) + }; + + await client.SendTemplateAsync(configuration["SendFrom"]!, configuration["SendTo"]!, template); + } + record Media(string Url, string MimeType, long FileSize); (IConfiguration configuration, WhatsAppClient client) Initialize() diff --git a/src/Tests/WhatsAppModelTests.cs b/src/Tests/WhatsAppModelTests.cs index 4147ce4..550888f 100644 --- a/src/Tests/WhatsAppModelTests.cs +++ b/src/Tests/WhatsAppModelTests.cs @@ -130,8 +130,24 @@ public async Task DeserializeInteractiveButton() Assert.NotNull(message.NotificationId); Assert.NotNull(message.Service); Assert.NotNull(message.User); - Assert.Equal("btn_yes", interactive.Selection.Id); - Assert.Equal("Yes", interactive.Selection.Title); + Assert.Equal("btn_yes", interactive.Selection.Value); + Assert.Equal("Yes", interactive.Selection.Text); + } + + [Fact] + public async Task DeserializeTemplateButton() + { + var json = await File.ReadAllTextAsync($"Content/WhatsApp/TemplateButton.json"); + var message = await Message.DeserializeAsync(json); + + var interactive = Assert.IsType(message); + + Assert.NotNull(message); + Assert.NotNull(message.NotificationId); + Assert.NotNull(message.Service); + Assert.NotNull(message.User); + Assert.Equal("id1", interactive.Selection.Value); + Assert.Equal("Track", interactive.Selection.Text); } [Fact] @@ -146,8 +162,8 @@ public async Task DeserializeInteractiveList() Assert.NotNull(message.NotificationId); Assert.NotNull(message.Service); Assert.NotNull(message.User); - Assert.Equal("conversation", interactive.Selection.Id); - Assert.Equal("Conversación", interactive.Selection.Title); + Assert.Equal("conversation", interactive.Selection.Value); + Assert.Equal("Conversación", interactive.Selection.Text); } [Fact] @@ -191,4 +207,701 @@ public void SerializeAnonymous() Assert.NotNull(message); Assert.Null(message.Sender); } + + [Fact] + public void RoundtripTextParameter() + { + var parameter = new TextParameter("Hello", "message"); + var json = JsonSerializer.Serialize(parameter, JsonContext.DefaultOptions); + + var deserialized = JsonSerializer.Deserialize(json, JsonContext.DefaultOptions); + Assert.NotNull(deserialized); + var typed = Assert.IsType(deserialized); + Assert.Equal("Hello", typed.Text); + Assert.Equal("message", typed.Name); + } + + [Fact] + public void RoundtripTextParameterWithoutName() + { + var parameter = new TextParameter("Hello World"); + var json = JsonSerializer.Serialize(parameter, JsonContext.DefaultOptions); + + var deserialized = JsonSerializer.Deserialize(json, JsonContext.DefaultOptions); + Assert.NotNull(deserialized); + var typed = Assert.IsType(deserialized); + Assert.Equal("Hello World", typed.Text); + Assert.Null(typed.Name); + } + + [Fact] + public void RoundtripCurrencyParameter() + { + var parameter = new CurrencyParameter("$100.00", "USD", 100000); + var json = JsonSerializer.Serialize(parameter, JsonContext.DefaultOptions); + + var deserialized = JsonSerializer.Deserialize(json, JsonContext.DefaultOptions); + Assert.NotNull(deserialized); + var typed = Assert.IsType(deserialized); + Assert.Equal("$100.00", typed.FallbackValue); + Assert.Equal("USD", typed.Code); + Assert.Equal(100000, typed.Amount1000); + } + + [Fact] + public void RoundtripDateTimeParameter() + { + var parameter = new DateTimeParameter("January 1, 2025"); + var json = JsonSerializer.Serialize(parameter, JsonContext.DefaultOptions); + + var deserialized = JsonSerializer.Deserialize(json, JsonContext.DefaultOptions); + Assert.NotNull(deserialized); + var typed = Assert.IsType(deserialized); + Assert.Equal("January 1, 2025", typed.FallbackValue); + } + + [Fact] + public void RoundtripImageParameterWithId() + { + var parameter = new ImageParameter("image123"); + var json = JsonSerializer.Serialize(parameter, JsonContext.DefaultOptions); + + var deserialized = JsonSerializer.Deserialize(json, JsonContext.Default.Options); + Assert.NotNull(deserialized); + var typed = Assert.IsType(deserialized); + Assert.Equal("image123", typed.Id); + Assert.Null(typed.Link); + } + + [Fact] + public void RoundtripImageParameterWithLink() + { + var uri = new Uri("https://example.com/image.jpg"); + var parameter = new ImageParameter(uri); + var json = JsonSerializer.Serialize(parameter, JsonContext.Default.Options); + + var deserialized = JsonSerializer.Deserialize(json, JsonContext.Default.Options); + Assert.NotNull(deserialized); + var typed = Assert.IsType(deserialized); + Assert.Null(typed.Id); + Assert.Equal(uri, typed.Link); + } + + [Fact] + public void RoundtripVideoParameterWithId() + { + var parameter = new VideoParameter("video456"); + var json = JsonSerializer.Serialize(parameter, JsonContext.DefaultOptions); + + var deserialized = JsonSerializer.Deserialize(json, JsonContext.Default.Options); + Assert.NotNull(deserialized); + var typed = Assert.IsType(deserialized); + Assert.Equal("video456", typed.Id); + Assert.Null(typed.Link); + } + + [Fact] + public void RoundtripVideoParameterWithLink() + { + var uri = new Uri("https://example.com/video.mp4"); + var parameter = new VideoParameter(uri); + var json = JsonSerializer.Serialize(parameter, JsonContext.DefaultOptions); + + var deserialized = JsonSerializer.Deserialize(json, JsonContext.Default.Options); + Assert.NotNull(deserialized); + var typed = Assert.IsType(deserialized); + Assert.Null(typed.Id); + Assert.Equal(uri, typed.Link); + } + + [Fact] + public void RoundtripDocumentParameterWithId() + { + var parameter = new DocumentParameter("doc789"); + var json = JsonSerializer.Serialize(parameter, JsonContext.DefaultOptions); + + var deserialized = JsonSerializer.Deserialize(json, JsonContext.Default.Options); + Assert.NotNull(deserialized); + var typed = Assert.IsType(deserialized); + Assert.Equal("doc789", typed.Id); + Assert.Null(typed.Link); + } + + [Fact] + public void RoundtripDocumentParameterWithLink() + { + var uri = new Uri("https://example.com/document.pdf"); + var parameter = new DocumentParameter(uri); + var json = JsonSerializer.Serialize(parameter, JsonContext.Default.Options); + + var deserialized = JsonSerializer.Deserialize(json, JsonContext.Default.Options); + Assert.NotNull(deserialized); + var typed = Assert.IsType(deserialized); + Assert.Null(typed.Id); + Assert.Equal(uri, typed.Link); + } + + [Fact] + public void RoundtripLocationParameter() + { + var parameter = new LocationParameter(37.483307, -122.148981, "Facebook HQ", "1 Hacker Way, Menlo Park, CA 94025"); + var json = JsonSerializer.Serialize(parameter, JsonContext.Default.Options); + + var deserialized = JsonSerializer.Deserialize(json, JsonContext.Default.Options); + Assert.NotNull(deserialized); + var typed = Assert.IsType(deserialized); + Assert.Equal(37.483307, typed.Latitude); + Assert.Equal(-122.148981, typed.Longitude); + Assert.Equal("Facebook HQ", typed.Name); + Assert.Equal("1 Hacker Way, Menlo Park, CA 94025", typed.Address); + } + + [Fact] + public void RoundtripTemplateParameterPolymorphism() + { + // Test that we can serialize/deserialize as the base TemplateParameter type + TemplateParameter[] parameters = [ + new TextParameter("Hello", "greeting"), + new CurrencyParameter("€50.00", "EUR", 50000), + new DateTimeParameter("December 25, 2024"), + new ImageParameter("img001"), + new VideoParameter(new Uri("https://example.com/video.mp4")), + new DocumentParameter("doc002"), + new LocationParameter(40.7128, -74.0060, "New York City", "New York, NY, USA") + ]; + + var json = JsonSerializer.Serialize(parameters, JsonContext.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(json, JsonContext.DefaultOptions); + + Assert.NotNull(deserialized); + Assert.Equal(7, deserialized.Length); + + // Verify each parameter type was correctly deserialized + Assert.IsType(deserialized[0]); + Assert.IsType(deserialized[1]); + Assert.IsType(deserialized[2]); + Assert.IsType(deserialized[3]); + Assert.IsType(deserialized[4]); + Assert.IsType(deserialized[5]); + Assert.IsType(deserialized[6]); + + // Verify the values are preserved + var textParam = (TextParameter)deserialized[0]; + Assert.Equal("Hello", textParam.Text); + Assert.Equal("greeting", textParam.Name); + + var currencyParam = (CurrencyParameter)deserialized[1]; + Assert.Equal("€50.00", currencyParam.FallbackValue); + Assert.Equal("EUR", currencyParam.Code); + Assert.Equal(50000, currencyParam.Amount1000); + + var videoParam = (VideoParameter)deserialized[4]; + Assert.Equal("https://example.com/video.mp4", videoParam.Link?.AbsoluteUri); + Assert.Null(videoParam.Id); + } + + [Fact] + public void VerifyGenericConverterArchitecture() + { + // Test that concrete type deserialization works with the new generic converter architecture + var imageJson = @"{""type"":""image"",""image"":{""id"":""test123""}}"; + var videoJson = @"{""type"":""video"",""video"":{""link"":""https://example.com/test.mp4""}}"; + var textJson = @"{""type"":""text"",""text"":""Hello World"",""parameter_name"":""greeting""}"; + + // Test direct concrete type deserialization + var image = JsonSerializer.Deserialize(imageJson, JsonContext.DefaultOptions); + Assert.NotNull(image); + Assert.Equal("test123", image.Id); + Assert.Null(image.Link); + + var video = JsonSerializer.Deserialize(videoJson, JsonContext.DefaultOptions); + Assert.NotNull(video); + Assert.Equal("https://example.com/test.mp4", video.Link?.AbsoluteUri); + Assert.Null(video.Id); + + var text = JsonSerializer.Deserialize(textJson, JsonContext.DefaultOptions); + Assert.NotNull(text); + Assert.Equal("Hello World", text.Text); + Assert.Equal("greeting", text.Name); + + // Test polymorphic deserialization still works + var imageBase = JsonSerializer.Deserialize(imageJson, JsonContext.DefaultOptions); + Assert.IsType(imageBase); + + var videoBase = JsonSerializer.Deserialize(videoJson, JsonContext.Default.Options); + Assert.IsType(videoBase); + + var textBase = JsonSerializer.Deserialize(textJson, JsonContext.Default.Options); + Assert.IsType(textBase); + } + + [Fact] + public void VerifyUnifiedMediaConverterArchitecture() + { + // Test that all three media parameter types use the unified MediaParameterConverter base class + var imageIdJson = @"{""type"":""image"",""image"":{""id"":""img123""}}"; + var imageLinkJson = @"{""type"":""image"",""image"":{""link"":""https://example.com/image.jpg""}}"; + + var videoIdJson = @"{""type"":""video"",""video"":{""id"":""vid456""}}"; + var videoLinkJson = @"{""type"":""video"",""video"":{""link"":""https://example.com/video.mp4""}}"; + + var docIdJson = @"{""type"":""document"",""document"":{""id"":""doc789""}}"; + var docLinkJson = @"{""type"":""document"",""document"":{""link"":""https://example.com/document.pdf""}}"; + + // Test ID-based creation for all media types + var imageFromId = JsonSerializer.Deserialize(imageIdJson, JsonContext.DefaultOptions); + Assert.NotNull(imageFromId); + Assert.Equal("img123", imageFromId.Id); + Assert.Null(imageFromId.Link); + + var videoFromId = JsonSerializer.Deserialize(videoIdJson, JsonContext.DefaultOptions); + Assert.NotNull(videoFromId); + Assert.Equal("vid456", videoFromId.Id); + Assert.Null(videoFromId.Link); + + var docFromId = JsonSerializer.Deserialize(docIdJson, JsonContext.DefaultOptions); + Assert.NotNull(docFromId); + Assert.Equal("doc789", docFromId.Id); + Assert.Null(docFromId.Link); + + // Test Link-based creation for all media types + var imageFromLink = JsonSerializer.Deserialize(imageLinkJson, JsonContext.DefaultOptions); + Assert.NotNull(imageFromLink); + Assert.Null(imageFromLink.Id); + Assert.Equal("https://example.com/image.jpg", imageFromLink.Link?.AbsoluteUri); + + var videoFromLink = JsonSerializer.Deserialize(videoLinkJson, JsonContext.Default.Options); + Assert.NotNull(videoFromLink); + Assert.Null(videoFromLink.Id); + Assert.Equal("https://example.com/video.mp4", videoFromLink.Link?.AbsoluteUri); + + var docFromLink = JsonSerializer.Deserialize(docLinkJson, JsonContext.Default.Options); + Assert.NotNull(docFromLink); + Assert.Null(docFromLink.Id); + Assert.Equal("https://example.com/document.pdf", docFromLink.Link?.AbsoluteUri); + } + + [Fact] + public void VerifyUnifiedPropertyNameArchitecture() + { + // Test that the PropertyName concept works correctly for all parameter types + var currencyJson = @"{""type"":""currency"",""currency"":{""fallback_value"":""$100.00"",""code"":""USD"",""amount_1000"":100000}}"; + var dateTimeJson = @"{""type"":""date_time"",""date_time"":{""fallback_value"":""January 1, 2025""}}"; + var locationJson = @"{""type"":""location"",""location"":{""latitude"":37.483307,""longitude"":-122.148981,""name"":""Facebook HQ"",""address"":""1 Hacker Way, Menlo Park, CA 94025""}}"; + var imageJson = @"{""type"":""image"",""image"":{""id"":""img123""}}"; + var textJson = @"{""type"":""text"",""text"":""Hello World"",""parameter_name"":""greeting""}"; + + // Test that each converter gets the correct JsonElement based on PropertyName + var currency = JsonSerializer.Deserialize(currencyJson, JsonContext.DefaultOptions); + Assert.NotNull(currency); + Assert.Equal("$100.00", currency.FallbackValue); + Assert.Equal("USD", currency.Code); + Assert.Equal(100000, currency.Amount1000); + + var dateTime = JsonSerializer.Deserialize(dateTimeJson, JsonContext.DefaultOptions); + Assert.NotNull(dateTime); + Assert.Equal("January 1, 2025", dateTime.FallbackValue); + + var location = JsonSerializer.Deserialize(locationJson, JsonContext.DefaultOptions); + Assert.NotNull(location); + Assert.Equal(37.483307, location.Latitude); + Assert.Equal(-122.148981, location.Longitude); + Assert.Equal("Facebook HQ", location.Name); + Assert.Equal("1 Hacker Way, Menlo Park, CA 94025", location.Address); + + var image = JsonSerializer.Deserialize(imageJson, JsonContext.Default.Options); + Assert.NotNull(image); + Assert.Equal("img123", image.Id); + Assert.Null(image.Link); + + var text = JsonSerializer.Deserialize(textJson, JsonContext.Default.Options); + Assert.NotNull(text); + Assert.Equal("Hello World", text.Text); + Assert.Equal("greeting", text.Name); + } + + [Fact] + public void MessageTemplateWithHeaderAndBodySerializesCorrectly() + { + var template = new MessageTemplate("meeting", "es") + { + Header = new HeaderComponent(new LocationParameter(37.483307, -122.148981, "Pablo Morales", "1 Hacker Way, Menlo Park, CA 94025")), + Body = new BodyComponent([ + new TextParameter("kzu", "who"), + new TextParameter("office", "where"), + new TextParameter("15'", "when") + ]) + }; + + var json = JsonSerializer.Serialize(template, new JsonSerializerOptions { WriteIndented = true }); + + output.WriteLine(json); + + // Verify that both header and body components are present + var templateObj = JsonSerializer.Deserialize(json); + Assert.True(templateObj.TryGetProperty("components", out var components)); + + var componentArray = components.EnumerateArray().ToArray(); + Assert.Equal(2, componentArray.Length); + + // Check that we have both header and body + var types = componentArray.Select(c => c.GetProperty("type").GetString()).ToArray(); + Assert.Contains("header", types); + Assert.Contains("body", types); + + Assert.Equal("meeting", templateObj.GetProperty("name").GetString()); + Assert.Equal("es", templateObj.GetProperty("language").GetProperty("code").GetString()); + } + + [Fact] + public void MessageTemplateProducesSameJsonAsAnonymousObject() + { + // Create MessageTemplate instance + var messageTemplate = new MessageTemplate("reminder", "es") + { + Body = new BodyComponent([ + new TextParameter("🦷", "emoji"), + new TextParameter("Dentista", "text"), + new TextParameter("3pm", "when") + ]) + }; + + // Create anonymous object like in the test example + var anonymousTemplate = new + { + name = "reminder", + language = new + { + code = "es" + }, + components = new object[] + { + new + { + type = "body", + parameters = new object[] + { + new TextParameter("🦷", "emoji"), + new TextParameter("Dentista", "text"), + new TextParameter("3pm", "when") + } + } + } + }; + + var messageTemplateJson = JsonSerializer.Serialize(messageTemplate, new JsonSerializerOptions { WriteIndented = true }); + var anonymousJson = JsonSerializer.Serialize(anonymousTemplate, new JsonSerializerOptions { WriteIndented = true }); + + output.WriteLine("MessageTemplate JSON:"); + output.WriteLine(messageTemplateJson); + output.WriteLine("\nAnonymous Object JSON:"); + output.WriteLine(anonymousJson); + + // Parse both JSONs and compare structure + var messageTemplateObj = JsonSerializer.Deserialize(messageTemplateJson); + var anonymousObj = JsonSerializer.Deserialize(anonymousJson); + + // Compare key properties + Assert.Equal( + messageTemplateObj.GetProperty("name").GetString(), + anonymousObj.GetProperty("name").GetString()); + + Assert.Equal( + messageTemplateObj.GetProperty("language").GetProperty("code").GetString(), + anonymousObj.GetProperty("language").GetProperty("code").GetString()); + + // Both should have components array with same structure + var messageComponents = messageTemplateObj.GetProperty("components").EnumerateArray().ToArray(); + var anonymousComponents = anonymousObj.GetProperty("components").EnumerateArray().ToArray(); + Assert.Equal(messageComponents.Length, anonymousComponents.Length); + } + + + [Fact] + public void MessageTemplateWorksWithJsonContext() + { + var template = new MessageTemplate("test", "en") + { + Header = new HeaderComponent(new ImageParameter("image123")), + Body = new BodyComponent([ + new TextParameter("Hello", "greeting"), + new CurrencyParameter("$10", "USD", 10000) + ]) + }; + + // Test with the library's JsonContext + var jsonWithContext = JsonSerializer.Serialize(template, JsonContext.DefaultOptions); + var deserializedWithContext = JsonSerializer.Deserialize(jsonWithContext, JsonContext.DefaultOptions); + + Assert.NotNull(deserializedWithContext); + Assert.Equal(template.Name, deserializedWithContext.Name); + Assert.Equal(template.Language, deserializedWithContext.Language); + Assert.NotNull(deserializedWithContext.Header); + Assert.NotNull(deserializedWithContext.Body); + Assert.Equal(2, deserializedWithContext.Body.Parameters.Count); + + output.WriteLine("JSON with JsonContext:"); + output.WriteLine(jsonWithContext); + } + + [Fact] + public void MessageTemplateSerializesToExpectedFormat() + { + var template = new MessageTemplate("reminder", "es") + { + Header = new HeaderComponent(new TextParameter("Recordatorio", "title")), + Body = new BodyComponent([ + new TextParameter("🦷", "emoji"), + new TextParameter("Dentista", "text"), + new TextParameter("3pm", "when") + ]) + }; + + var json = JsonSerializer.Serialize(template, JsonContext.DefaultOptions); + + output.WriteLine(json); + + // Verify the JSON structure matches the expected format + var expected = """ + { + "name": "reminder", + "language": { + "code": "es" + }, + "components": [ + { + "type": "header", + "parameters": [ + { + "type": "text", + "text": "Recordatorio", + "parameter_name": "title" + } + ] + }, + { + "type": "body", + "parameters": [ + { + "type": "text", + "text": "🦷", + "parameter_name": "emoji" + }, + { + "type": "text", + "text": "Dentista", + "parameter_name": "text" + }, + { + "type": "text", + "text": "3pm", + "parameter_name": "when" + } + ] + } + ] + } + """; + + var expectedObj = JsonSerializer.Deserialize(expected); + var actualObj = JsonSerializer.Deserialize(json); + + Assert.Equal(JsonSerializer.Serialize(expectedObj), JsonSerializer.Serialize(actualObj)); + } + + [Fact] + public void MessageTemplateDeserializesFromExpectedFormat() + { + var json = """ + { + "name": "reminder", + "language": { + "code": "es" + }, + "components": [ + { + "type": "body", + "parameters": [ + { + "type": "text", + "text": "🦷", + "parameter_name": "emoji" + }, + { + "type": "text", + "text": "Dentista", + "parameter_name": "text" + } + ] + } + ] + } + """; + + var template = JsonSerializer.Deserialize(json); + + Assert.NotNull(template); + Assert.Equal("reminder", template.Name); + Assert.Equal("es", template.Language); + Assert.NotNull(template.Body); + Assert.Null(template.Header); + Assert.Equal(2, template.Body.Parameters.Count); + + var firstParam = template.Body.Parameters[0] as TextParameter; + Assert.NotNull(firstParam); + Assert.Equal("🦷", firstParam.Text); + Assert.Equal("emoji", firstParam.Name); + } + + [Fact] + public void MessageTemplateWithTextAndPayloadButtonsSerialization() + { + var template = new MessageTemplate("test", "en") + { + Buttons = + [ + ButtonComponent.Payload("foo"), + ButtonComponent.Url("bar"), + ButtonComponent.Catalog() + ] + }; + + var json = JsonSerializer.Serialize(template, JsonContext.DefaultOptions); + + output.WriteLine(json); + + // Verify the JSON structure matches the expected format + + } + + [Fact] + public void MatchMessageJsonTemplate() + { + var json = + """ + { + "messaging_product": "whatsapp", + "recipient_type": "individual", + "to": "PHONE_NUMBER", + "type": "template", + "template": { + "name": "TEMPLATE_NAME", + "language": { + "code": "LANGUAGE_AND_LOCALE_CODE" + }, + "components": [ + { + "type": "header", + "parameters": [ + { + "type": "image", + "image": { + "link": "https://foo.com/" + } + } + ] + }, + { + "type": "body", + "parameters": [ + { + "type": "text", + "text": "TEXT_STRING" + }, + { + "type": "currency", + "currency": { + "fallback_value": "VALUE", + "code": "USD", + "amount_1000": 123 + } + }, + { + "type": "date_time", + "date_time": { + "fallback_value": "MONTH DAY, YEAR" + } + } + ] + }, + { + "type": "button", + "sub_type": "quick_reply", + "index": "0", + "parameters": [ + { + "type": "payload", + "payload": "PAYLOAD" + } + ] + }, + { + "type": "button", + "sub_type": "quick_reply", + "index": "1", + "parameters": [ + { + "type": "payload", + "payload": "PAYLOAD" + } + ] + } + ] + } + } + """; + + // Extract just the template portion for MessageTemplate deserialization + var templateJson = JsonSerializer.Deserialize(json).GetProperty("template").GetRawText(); + var template = JsonSerializer.Deserialize(templateJson, JsonContext.DefaultOptions); + + // Verify basic template properties + Assert.NotNull(template); + Assert.Equal("TEMPLATE_NAME", template.Name); + Assert.Equal("LANGUAGE_AND_LOCALE_CODE", template.Language); + + // Verify header component + Assert.NotNull(template.Header); + Assert.Single(template.Header.Parameters); + var headerParam = Assert.IsType(template.Header.Parameters[0]); + Assert.Equal("https://foo.com/", headerParam.Link?.OriginalString); + Assert.Null(headerParam.Id); + + // Verify body component + Assert.NotNull(template.Body); + Assert.Equal(3, template.Body.Parameters.Count); + + // Verify body parameters + var textParam = Assert.IsType(template.Body.Parameters[0]); + Assert.Equal("TEXT_STRING", textParam.Text); + Assert.Null(textParam.Name); // No parameter_name provided in JSON + + var currencyParam = Assert.IsType(template.Body.Parameters[1]); + Assert.Equal("VALUE", currencyParam.FallbackValue); + Assert.Equal("USD", currencyParam.Code); + Assert.Equal(123, currencyParam.Amount1000); + + var dateTimeParam = Assert.IsType(template.Body.Parameters[2]); + Assert.Equal("MONTH DAY, YEAR", dateTimeParam.FallbackValue); + + // Verify buttons + Assert.NotNull(template.Buttons); + Assert.Equal(2, template.Buttons.Count); + + // Verify first button + var firstButton = template.Buttons[0]; + Assert.Equal(ButtonSubType.QuickReply, firstButton.SubType); + Assert.NotNull(firstButton.Parameters); + Assert.Single(firstButton.Parameters); + var firstPayload = Assert.IsType(firstButton.Parameters[0]); + Assert.Equal("PAYLOAD", firstPayload.Payload); + + // Verify second button + var secondButton = template.Buttons[1]; + Assert.Equal(ButtonSubType.QuickReply, secondButton.SubType); + Assert.NotNull(secondButton.Parameters); + Assert.Single(secondButton.Parameters); + var secondPayload = Assert.IsType(secondButton.Parameters[0]); + Assert.Equal("PAYLOAD", secondPayload.Payload); + } } diff --git a/src/WhatsApp/InteractiveMessage.cs b/src/WhatsApp/InteractiveMessage.cs index 27a68d3..68d3e80 100644 --- a/src/WhatsApp/InteractiveMessage.cs +++ b/src/WhatsApp/InteractiveMessage.cs @@ -20,6 +20,6 @@ public record InteractiveMessage(string Id, Service Service, User User, long Tim /// /// Selection made by the user in an interactive message, such as a button or list item. /// -/// The identifier of the selection. -/// The title of the selection. -public record Selection(string Id, string Title); \ No newline at end of file +/// The selection text (i.e. button or list item text). +/// The value associated with the selection (i.e. button id or payload). +public record Selection(string Text, string Value); \ No newline at end of file diff --git a/src/WhatsApp/JsonContext.cs b/src/WhatsApp/JsonContext.cs index acc9428..6dc6422 100644 --- a/src/WhatsApp/JsonContext.cs +++ b/src/WhatsApp/JsonContext.cs @@ -28,7 +28,8 @@ namespace Devlooped.WhatsApp; UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, PropertyNameCaseInsensitive = true, PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower, - WriteIndented = true + WriteIndented = true, + GenerationMode = JsonSourceGenerationMode.Metadata )] [JsonSerializable(typeof(Message))] [JsonSerializable(typeof(ContentMessage))] @@ -40,9 +41,25 @@ namespace Devlooped.WhatsApp; [JsonSerializable(typeof(MediaReference))] [JsonSerializable(typeof(Conversation))] [JsonSerializable(typeof(AdditionalPropertiesDictionary))] +[JsonSerializable(typeof(MessageTemplate))] +[JsonSerializable(typeof(TemplateParameter))] +[JsonSerializable(typeof(TemplateComponent))] +[JsonSerializable(typeof(HeaderComponent))] +[JsonSerializable(typeof(BodyComponent))] +[JsonSerializable(typeof(ButtonComponent))] +[JsonSerializable(typeof(ButtonParameter))] +[JsonSerializable(typeof(PayloadButtonParameter))] +[JsonSerializable(typeof(TextButtonParameter))] +[JsonSerializable(typeof(TextParameter))] +[JsonSerializable(typeof(CurrencyParameter))] +[JsonSerializable(typeof(DateTimeParameter))] +[JsonSerializable(typeof(ImageParameter))] +[JsonSerializable(typeof(VideoParameter))] +[JsonSerializable(typeof(DocumentParameter))] +[JsonSerializable(typeof(LocationParameter))] public partial class JsonContext : JsonSerializerContext { - static readonly Lazy options = new(() => CreateDefaultOptions()); + static readonly Lazy options = new(CreateDefaultOptions); /// /// Provides a pre-configured instance of that aligns with the context's settings. @@ -55,6 +72,21 @@ static JsonSerializerOptions CreateDefaultOptions() { JsonSerializerOptions options = new(Default.Options) { + Converters = + { + new MessageTemplateConverter(), + new HeaderConverter(), + new BodyConverter(), + new TemplateParameterConverter(), + new TextParameterConverter(), + new CurrencyParameterConverter(), + new DateTimeParameterConverter(), + new ImageParameterConverter(), + new VideoParameterConverter(), + new DocumentParameterConverter(), + new LocationParameterConverter(), + new ButtonParameterConverter(), + }, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, WriteIndented = true, }; diff --git a/src/WhatsApp/Message.jq b/src/WhatsApp/Message.jq index 8e4e898..2d74777 100644 --- a/src/WhatsApp/Message.jq +++ b/src/WhatsApp/Message.jq @@ -10,7 +10,7 @@ ($msg.type as $msgType | # Compute context once for all message types (if $msgType == "reaction" then $msg.reaction.message_id else ($msg.context.id // null) end) as $context | - if $msgType == "interactive" then + if $msgType == "interactive" or $msgType == "button" then { "$type": "interactive", "notification": $notification, @@ -26,8 +26,8 @@ "number": $msg.from }, "selection": { - "id": ($msg.interactive.button_reply?.id // $msg.interactive.list_reply?.id), - "title": ($msg.interactive.button_reply?.title // $msg.interactive.list_reply?.title) + "text": ($msg.interactive.button_reply?.title // $msg.interactive.list_reply?.title // $msg.button.text), + "value": ($msg.interactive.button_reply?.id // $msg.interactive.list_reply?.id // $msg.button.payload) } } elif $msgType == "reaction" then diff --git a/src/WhatsApp/MessageExtensions.cs b/src/WhatsApp/MessageExtensions.cs index 9074067..c6d34cc 100644 --- a/src/WhatsApp/MessageExtensions.cs +++ b/src/WhatsApp/MessageExtensions.cs @@ -73,7 +73,7 @@ public TemplateResponse Template(string name, string language) /// /// /// - public TemplateResponse Template(object template) + public TemplateResponse Template(MessageTemplate template) => new(message.ServiceId, message.UserNumber, message.Id, template); /// diff --git a/src/WhatsApp/MessageTemplate.cs b/src/WhatsApp/MessageTemplate.cs new file mode 100644 index 0000000..bc8396a --- /dev/null +++ b/src/WhatsApp/MessageTemplate.cs @@ -0,0 +1,379 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Devlooped.WhatsApp; + +/// Represents a WhatsApp message template. +/// Template name +/// Template language +/// +/// +/// +[JsonConverter(typeof(MessageTemplateConverter))] +public record MessageTemplate(string Name, string Language) +{ + /// Optional template header. + [JsonConverter(typeof(HeaderConverter))] + public HeaderComponent? Header { get; init; } + [JsonConverter(typeof(BodyConverter))] + /// Optional template body. + public BodyComponent? Body { get; init; } + + // New property for buttons + public List? Buttons { get; init; } +} + +/// Converter for MessageTemplate. +class MessageTemplateConverter : JsonConverter +{ + public override MessageTemplate Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + using var document = JsonDocument.ParseValue(ref reader); + var root = document.RootElement; + + var name = root.GetProperty("name").GetString() ?? throw new JsonException("Missing 'name' property."); + var languageCode = root.GetProperty("language").GetProperty("code").GetString() ?? throw new JsonException("Missing 'language.code' property."); + + HeaderComponent? header = null; + BodyComponent? body = null; + List buttons = []; + + if (root.TryGetProperty("components", out var componentsElement)) + { + foreach (var component in componentsElement.EnumerateArray()) + { + var type = component.GetProperty("type").GetString(); + switch (type) + { + case "header": + header = JsonSerializer.Deserialize(component, options); + break; + case "body": + body = JsonSerializer.Deserialize(component, options); + break; + case "button": + var subType = component.GetProperty("sub_type").GetString() switch + { + null => throw new JsonException("Missing 'sub_type' property."), + "quick_reply" => ButtonSubType.QuickReply, + "url" => ButtonSubType.Url, + "catalog" => ButtonSubType.Catalog, + var other => throw new JsonException($"Unsupported button sub_type: {other}") + }; + + List parameters = []; + if (component.TryGetProperty("parameters", out var parametersElement)) + { + parameters = JsonSerializer.Deserialize>(parametersElement, options) ?? []; + } + + buttons.Add(new ButtonComponent(subType, parameters)); + break; + } + } + } + + return new MessageTemplate(name, languageCode) + { + Header = header, + Body = body, + Buttons = buttons.Count != 0 ? buttons : null + }; + } + + public override void Write(Utf8JsonWriter writer, MessageTemplate value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + writer.WriteString("name", value.Name); + + writer.WritePropertyName("language"); + writer.WriteStartObject(); + writer.WriteString("code", value.Language); + writer.WriteEndObject(); + + if (value.Header is not null || value.Body is not null || value.Buttons is { Count: > 0 }) + { + writer.WritePropertyName("components"); + writer.WriteStartArray(); + + if (value.Header is not null) + JsonSerializer.Serialize(writer, value.Header, options); + + if (value.Body is not null) + JsonSerializer.Serialize(writer, value.Body, options); + + if (value.Buttons != null) + { + for (var i = 0; i < value.Buttons.Count; i++) + { + var button = value.Buttons[i]; + writer.WriteStartObject(); + writer.WriteString("type", "button"); + var subType = button.SubType switch + { + ButtonSubType.QuickReply => "quick_reply", + ButtonSubType.Url => "url", + ButtonSubType.Catalog => "catalog", + _ => throw new JsonException($"Unsupported ButtonSubType: {button.SubType}") + }; + writer.WriteString("sub_type", subType); + writer.WriteNumber("index", button.Index ?? i); + writer.WritePropertyName("parameters"); + JsonSerializer.Serialize(writer, button.Parameters ?? [], options); + writer.WriteEndObject(); + } + } + + writer.WriteEndArray(); + } + + writer.WriteEndObject(); + } +} + +/// Base record for template components (header and body). +public abstract record TemplateComponent(string Type); + +/// Base record for parameters within components. +[JsonConverter(typeof(TemplateParameterConverter))] +public abstract record TemplateParameter([property: JsonIgnore] string Type); + +/// Positional or named text parameter. +/// The parameter text to replace in the template. +/// Optional parameter name for named parameters. +[JsonConverter(typeof(TextParameterConverter))] +public record TextParameter(string Text, [property: JsonPropertyName("parameter_name")] string? Name = null) : TemplateParameter("text") +{ + /// Creates a positional text parameter from the given text. + public static implicit operator TextParameter(string text) => new(text); +} + +/// For body only, positional. +[JsonConverter(typeof(CurrencyParameterConverter))] +public record CurrencyParameter(string FallbackValue, string Code, int Amount1000) : TemplateParameter("currency"); + +/// For body only, positional. +[JsonConverter(typeof(DateTimeParameterConverter))] +public record DateTimeParameter(string FallbackValue) : TemplateParameter("date_time"); + +/// Base class for media parameters (image, video, document) that support both ID and Link. +public abstract record MediaTemplateParameter : TemplateParameter +{ + protected MediaTemplateParameter(string type, string id) : base(type) => Id = id; + protected MediaTemplateParameter(string type, Uri link) : base(type) => Link = link; + + /// Media ID. + public string? Id { get; } + /// Public URL of the media. + public Uri? Link { get; } +} + +/// Image template parameter, used in header component only. +[JsonConverter(typeof(ImageParameterConverter))] +public record ImageParameter : MediaTemplateParameter +{ + /// Image parameter from a previously uploaded media ID. + public ImageParameter(string id) : base("image", id) { } + /// Image parameter from a public URL. + public ImageParameter(Uri link) : base("image", link) { } +} + +/// Video template parameter, used in header component only. +[JsonConverter(typeof(VideoParameterConverter))] +public record VideoParameter : MediaTemplateParameter +{ + /// Video parameter from a previously uploaded media ID. + public VideoParameter(string id) : base("video", id) { } + /// Video parameter from a public URL. + public VideoParameter(Uri link) : base("video", link) { } +} + +/// Document template parameter, used in header component only. +[JsonConverter(typeof(DocumentParameterConverter))] +public record DocumentParameter : MediaTemplateParameter +{ + /// Document parameter from a previously uploaded media ID. + public DocumentParameter(string id) : base("document", id) { } + /// Document parameter from a public URL. + public DocumentParameter(Uri link) : base("document", link) { } +} + +/// Location template parameter, used in header component only. +[JsonConverter(typeof(LocationParameterConverter))] +public record LocationParameter(double Latitude, double Longitude, string Name, string Address) : TemplateParameter("location"); + +/// Header component in a template message. +[JsonConverter(typeof(HeaderConverter))] +public record HeaderComponent : TemplateComponent +{ + /// List of parameters in the header component. + public List Parameters { get; } + + /// Creates a header component with a location parameter. + public HeaderComponent(LocationParameter location) : this([location]) { } + /// Creates a header component with an image parameter. + public HeaderComponent(ImageParameter image) : this([image]) { } + /// Creates a header component with a video parameter. + public HeaderComponent(VideoParameter video) : this([video]) { } + /// Creates a header component with a document parameter. + public HeaderComponent(DocumentParameter document) : this([document]) { } + /// Creates a header component with text parameters. + public HeaderComponent(params TextParameter[] parameters) : this(new List(parameters)) { } + + /// Creates a header component with multiple parameters. + /// Internal since not every combination of parameters is valid. + internal HeaderComponent(List parameters) : base("header") => Parameters = parameters; +} + +/// Converter for HeaderComponent. +public class HeaderConverter : JsonConverter +{ + public override HeaderComponent Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + using var document = JsonDocument.ParseValue(ref reader); + var root = document.RootElement; + + // Verify this is a header component + var type = root.GetProperty("type").GetString(); + if (type != "header") + throw new JsonException($"Expected header component, got {type}"); + + // Extract parameters if they exist + List parameters = []; + if (root.TryGetProperty("parameters", out var parametersElement)) + { + parameters = JsonSerializer.Deserialize>(parametersElement, options) ?? []; + } + + return new HeaderComponent(parameters); + } + + public override void Write(Utf8JsonWriter writer, HeaderComponent value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + writer.WriteString("type", value.Type); + writer.WritePropertyName("parameters"); + JsonSerializer.Serialize(writer, value.Parameters, options); + writer.WriteEndObject(); + } +} + +/// Supports text, currency and date_time parameters. +[JsonConverter(typeof(BodyConverter))] +public record BodyComponent(List Parameters) : TemplateComponent("body"); + +/// Converter for BodyComponent. +public class BodyConverter : JsonConverter +{ + public override BodyComponent Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + using var document = JsonDocument.ParseValue(ref reader); + var root = document.RootElement; + + // Verify this is a body component + var type = root.GetProperty("type").GetString(); + if (type != "body") + throw new JsonException($"Expected body component, got {type}"); + + // Extract parameters if they exist + List parameters = []; + if (root.TryGetProperty("parameters", out var parametersElement)) + { + parameters = JsonSerializer.Deserialize>(parametersElement, options) ?? []; + } + + return new BodyComponent(parameters); + } + + public override void Write(Utf8JsonWriter writer, BodyComponent value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + writer.WriteString("type", value.Type); + writer.WritePropertyName("parameters"); + JsonSerializer.Serialize(writer, value.Parameters, options); + writer.WriteEndObject(); + } +} + +// New enum for button sub-types +public enum ButtonSubType +{ + QuickReply, + Url, + Catalog +} + +/// Base record for parameters within button components. +[JsonConverter(typeof(ButtonParameterConverter))] +public abstract record ButtonParameter(string Type) +{ + // add two factory methods for payload and text + /// Creates a payload button parameter. + public static PayloadButtonParameter CreatePayload(string payload) => new(payload); + + /// Creates a text button parameter. + public static TextButtonParameter CreateText(string text) => new(text); +} + +/// Converter for ButtonParameter subclasses. +public class ButtonParameterConverter : JsonConverter +{ + public override ButtonParameter? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + using var document = JsonDocument.ParseValue(ref reader); + var root = document.RootElement; + + var type = root.GetProperty("type").GetString() ?? throw new JsonException("Missing 'type' property in button parameter."); + + return type switch + { + "payload" => new PayloadButtonParameter(root.GetProperty("payload").GetString() ?? throw new JsonException("Missing 'payload' property.")), + "text" => new TextButtonParameter(root.GetProperty("text").GetString() ?? throw new JsonException("Missing 'text' property.")), + _ => throw new JsonException($"Unsupported button parameter type: {type}") + }; + } + + public override void Write(Utf8JsonWriter writer, ButtonParameter value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + writer.WriteString("type", value.Type); + + switch (value) + { + case PayloadButtonParameter payload: + writer.WriteString("payload", payload.Payload); + break; + case TextButtonParameter text: + writer.WriteString("text", text.Text); + break; + default: + throw new JsonException($"Unsupported button parameter type: {value.GetType().Name}"); + } + + writer.WriteEndObject(); + } +} + +/// Payload parameter for quick_reply buttons. +public record PayloadButtonParameter(string Payload) : ButtonParameter("payload"); + +/// Text parameter for url buttons (suffix to append to URL). +public record TextButtonParameter(string Text) : ButtonParameter("text"); + +/// Button component in a template message. +public record ButtonComponent(ButtonSubType SubType, List? Parameters = default) : TemplateComponent("button") +{ + /// Creates a default button component with sub-type and no + /// button parameters. This maps to the default text buttons in the template definition. + public static ButtonComponent Default { get; } = new(ButtonSubType.QuickReply); + + /// Optional index for the button, used to maintain order in the template. Defaults to its order in the list of . + public int? Index { get; init; } + + /// Creates a catalog button component. + public static ButtonComponent Catalog() => new(ButtonSubType.Catalog); + /// Creates a quick-reply button component with a custom payload on user selection. + public static ButtonComponent Payload(string payload) => new(ButtonSubType.QuickReply, [ButtonParameter.CreatePayload(payload)]); + /// Creates a URL button component with the given text suffix. + public static ButtonComponent Url(string suffix, int? index = null) => new(ButtonSubType.Url, [ButtonParameter.CreateText(suffix)]) { Index = index }; +} diff --git a/src/WhatsApp/TemplateParameterConverter.cs b/src/WhatsApp/TemplateParameterConverter.cs new file mode 100644 index 0000000..1774e07 --- /dev/null +++ b/src/WhatsApp/TemplateParameterConverter.cs @@ -0,0 +1,348 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Devlooped.WhatsApp; + +/// +/// Base converter class for TemplateParameter types that can handle both polymorphic and concrete type scenarios. +/// +/// The specific TemplateParameter type to convert +public abstract class TemplateParameterConverter : JsonConverter where T : TemplateParameter +{ + /// + /// Gets the JSON property name for this parameter type (e.g., "text", "currency", "image", etc.). + /// Return null for types that don't have a specific property (like the base TemplateParameter). + /// + protected virtual string? PropertyName => null; + + public override bool CanConvert(Type typeToConvert) => typeof(T).IsAssignableFrom(typeToConvert); + + public override T? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + using var document = JsonDocument.ParseValue(ref reader); + var root = document.RootElement; + + // For concrete types, we don't need to check the type property - we know what type we're deserializing to + if (typeof(T) != typeof(TemplateParameter)) + { + // If the converter specifies a PropertyName, extract that property and pass it to ReadConcrete + if (PropertyName is not null) + { + var propertyElement = root.GetProperty(PropertyName); + return ReadConcrete(propertyElement); + } + else + { + // For converters without a specific property (like polymorphic base), pass the root + return ReadConcrete(root); + } + } + + // For the base TemplateParameter type, we need to check the type property for polymorphic deserialization + var paramType = root.GetProperty("type").GetString() ?? throw new JsonException("Missing 'type' property."); + return (T)ReadPolymorphic(root, paramType); + } + + /// + /// Reads the concrete parameter from the specified JsonElement. + /// For types with PropertyName, this element will be the specific property's content. + /// For types without PropertyName, this element will be the root JSON element. + /// + protected abstract T ReadConcrete(JsonElement element); + + protected virtual TemplateParameter ReadPolymorphic(JsonElement root, string paramType) + { + return paramType switch + { + "text" => ReadTextParameter(root), + "currency" => ReadCurrencyParameter(root), + "date_time" => ReadDateTimeParameter(root), + "image" => ReadImageParameter(root), + "video" => ReadVideoParameter(root), + "document" => ReadDocumentParameter(root), + "location" => ReadLocationParameter(root), + _ => throw new JsonException($"Unsupported Parameter type: {paramType}") + }; + } + + static TextParameter ReadTextParameter(JsonElement root) + { + var text = root.GetProperty("text").GetString() ?? throw new JsonException("Missing 'text' for TextParameter."); + string? parameterName = null; + if (root.TryGetProperty("parameter_name", out var nameElem)) + { + parameterName = nameElem.GetString(); + } + return new TextParameter(text, parameterName); + } + + static CurrencyParameter ReadCurrencyParameter(JsonElement root) + { + var currencyObj = root.GetProperty("currency"); + var fallbackValue = currencyObj.GetProperty("fallback_value").GetString() ?? throw new JsonException("Missing 'fallback_value' for CurrencyParameter."); + var code = currencyObj.GetProperty("code").GetString() ?? throw new JsonException("Missing 'code' for CurrencyParameter."); + var amount1000 = currencyObj.GetProperty("amount_1000").GetInt32(); + return new CurrencyParameter(fallbackValue, code, amount1000); + } + + static DateTimeParameter ReadDateTimeParameter(JsonElement root) + { + var dateTimeObj = root.GetProperty("date_time"); + var fallbackValue = dateTimeObj.GetProperty("fallback_value").GetString() ?? throw new JsonException("Missing 'fallback_value' for DateTimeParameter."); + return new DateTimeParameter(fallbackValue); + } + + static ImageParameter ReadImageParameter(JsonElement root) + { + return ReadMediaParameter(root, "image", (id) => new ImageParameter(id), (link) => new ImageParameter(link)); + } + + static VideoParameter ReadVideoParameter(JsonElement root) + { + return ReadMediaParameter(root, "video", (id) => new VideoParameter(id), (link) => new VideoParameter(link)); + } + + static DocumentParameter ReadDocumentParameter(JsonElement root) + { + return ReadMediaParameter(root, "document", (id) => new DocumentParameter(id), (link) => new DocumentParameter(link)); + } + + static TMedia ReadMediaParameter(JsonElement root, string propertyName, Func createFromId, Func createFromLink) + { + var mediaObj = root.GetProperty(propertyName); + if (mediaObj.TryGetProperty("link", out var linkElem) && linkElem.GetString() is { } link) + { + return createFromLink(new Uri(link)); + } + else if (mediaObj.TryGetProperty("id", out var idElem) && idElem.GetString() is { } id) + { + return createFromId(id); + } + else + { + throw new JsonException($"{typeof(TMedia).Name} requires either 'link' or 'id'."); + } + } + + static LocationParameter ReadLocationParameter(JsonElement root) + { + var locationObj = root.GetProperty("location"); + var latitude = locationObj.GetProperty("latitude").GetDouble(); + var longitude = locationObj.GetProperty("longitude").GetDouble(); + var name = locationObj.GetProperty("name").GetString() ?? throw new JsonException("Missing 'name' for LocationParameter."); + var address = locationObj.GetProperty("address").GetString() ?? throw new JsonException("Missing 'address' for LocationParameter."); + return new LocationParameter(latitude, longitude, name, address); + } + + public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + writer.WriteString("type", value.Type); + + switch (value) + { + case TextParameter text: + writer.WriteString("text", text.Text); + if (text.Name is not null) + { + writer.WriteString("parameter_name", text.Name); + } + break; + + case CurrencyParameter currency: + writer.WritePropertyName("currency"); + writer.WriteStartObject(); + writer.WriteString("fallback_value", currency.FallbackValue); + writer.WriteString("code", currency.Code); + writer.WriteNumber("amount_1000", currency.Amount1000); + writer.WriteEndObject(); + break; + + case DateTimeParameter dateTime: + writer.WritePropertyName("date_time"); + writer.WriteStartObject(); + writer.WriteString("fallback_value", dateTime.FallbackValue); + writer.WriteEndObject(); + break; + + case MediaTemplateParameter media: + writer.WritePropertyName(value.Type); + writer.WriteStartObject(); + if (media.Id is not null) + { + writer.WriteString("id", media.Id); + } + else if (media.Link is not null) + { + writer.WriteString("link", media.Link.AbsoluteUri); + } + else + { + throw new JsonException($"Media parameter requires either Id or Link."); + } + writer.WriteEndObject(); + break; + + case LocationParameter location: + writer.WritePropertyName("location"); + writer.WriteStartObject(); + writer.WriteNumber("latitude", location.Latitude); + writer.WriteNumber("longitude", location.Longitude); + writer.WriteString("name", location.Name); + writer.WriteString("address", location.Address); + writer.WriteEndObject(); + break; + + default: + throw new JsonException($"Unsupported Parameter subtype: {value.GetType().Name}"); + } + + writer.WriteEndObject(); + } +} + +/// +/// Polymorphic converter for the base TemplateParameter type. +/// +public class TemplateParameterConverter : TemplateParameterConverter +{ + protected override TemplateParameter ReadConcrete(JsonElement root) + { + // For the base type, we need to check the type property + var paramType = root.GetProperty("type").GetString() ?? throw new JsonException("Missing 'type' property."); + return ReadPolymorphic(root, paramType); + } +} + +/// +/// Converter for TextParameter. +/// +public class TextParameterConverter : TemplateParameterConverter +{ + protected override string? PropertyName => null; // TextParameter reads from root, not a sub-property + + protected override TextParameter ReadConcrete(JsonElement element) + { + var text = element.GetProperty("text").GetString() ?? throw new JsonException("Missing 'text' for TextParameter."); + string? parameterName = null; + if (element.TryGetProperty("parameter_name", out var nameElem)) + { + parameterName = nameElem.GetString(); + } + return new TextParameter(text, parameterName); + } +} + +/// +/// Converter for CurrencyParameter. +/// +public class CurrencyParameterConverter : TemplateParameterConverter +{ + protected override string PropertyName => "currency"; + + protected override CurrencyParameter ReadConcrete(JsonElement currencyElement) + { + var fallbackValue = currencyElement.GetProperty("fallback_value").GetString() ?? throw new JsonException("Missing 'fallback_value' for CurrencyParameter."); + var code = currencyElement.GetProperty("code").GetString() ?? throw new JsonException("Missing 'code' for CurrencyParameter."); + var amount1000 = currencyElement.GetProperty("amount_1000").GetInt32(); + return new CurrencyParameter(fallbackValue, code, amount1000); + } +} + +/// +/// Converter for DateTimeParameter. +/// +public class DateTimeParameterConverter : TemplateParameterConverter +{ + protected override string PropertyName => "date_time"; + + protected override DateTimeParameter ReadConcrete(JsonElement dateTimeElement) + { + var fallbackValue = dateTimeElement.GetProperty("fallback_value").GetString() ?? throw new JsonException("Missing 'fallback_value' for DateTimeParameter."); + return new DateTimeParameter(fallbackValue); + } +} + +/// +/// Base converter for media parameters that handles shared link/id parsing logic. +/// +/// The specific MediaTemplateParameter type to convert +public abstract class MediaParameterConverter : TemplateParameterConverter where T : MediaTemplateParameter +{ + /// + /// Creates an instance from a media ID. + /// + /// The media ID + /// The media parameter instance + protected abstract T CreateFromId(string id); + + /// + /// Creates an instance from a media URL. + /// + /// The media URL + /// The media parameter instance + protected abstract T CreateFromLink(Uri link); + + protected override T ReadConcrete(JsonElement mediaElement) + { + if (mediaElement.TryGetProperty("link", out var linkElem) && linkElem.GetString() is { } link) + { + return CreateFromLink(new Uri(link)); + } + else if (mediaElement.TryGetProperty("id", out var idElem) && idElem.GetString() is { } id) + { + return CreateFromId(id); + } + else + { + throw new JsonException($"{typeof(T).Name} requires either 'link' or 'id'."); + } + } +} + +/// +/// Converter for ImageParameter. +/// +public class ImageParameterConverter : MediaParameterConverter +{ + protected override string PropertyName => "image"; + protected override ImageParameter CreateFromId(string id) => new(id); + protected override ImageParameter CreateFromLink(Uri link) => new(link); +} + +/// +/// Converter for VideoParameter. +/// +public class VideoParameterConverter : MediaParameterConverter +{ + protected override string PropertyName => "video"; + protected override VideoParameter CreateFromId(string id) => new(id); + protected override VideoParameter CreateFromLink(Uri link) => new(link); +} + +/// +/// Converter for DocumentParameter. +/// +public class DocumentParameterConverter : MediaParameterConverter +{ + protected override string PropertyName => "document"; + protected override DocumentParameter CreateFromId(string id) => new(id); + protected override DocumentParameter CreateFromLink(Uri link) => new(link); +} + +/// +/// Converter for LocationParameter. +/// +public class LocationParameterConverter : TemplateParameterConverter +{ + protected override string PropertyName => "location"; + + protected override LocationParameter ReadConcrete(JsonElement locationElement) + { + var latitude = locationElement.GetProperty("latitude").GetDouble(); + var longitude = locationElement.GetProperty("longitude").GetDouble(); + var name = locationElement.GetProperty("name").GetString() ?? throw new JsonException("Missing 'name' for LocationParameter."); + var address = locationElement.GetProperty("address").GetString() ?? throw new JsonException("Missing 'address' for LocationParameter."); + return new LocationParameter(latitude, longitude, name, address); + } +} diff --git a/src/WhatsApp/TemplateResponse.cs b/src/WhatsApp/TemplateResponse.cs index 1ec7030..a63bfb5 100644 --- a/src/WhatsApp/TemplateResponse.cs +++ b/src/WhatsApp/TemplateResponse.cs @@ -9,14 +9,11 @@ /// The identifier of the service handling the message. /// The phone number of the recipient in international format. /// The unique identifier of the message to which the reaction is being sent. -/// The template name -/// The template language code (i.e. 'es_AR') -/// -/// -public record TemplateResponse(string ServiceId, string UserNumber, string Context, object Template) : Response(ServiceId, UserNumber, Context) +/// The message template, components and parameters. +public record TemplateResponse(string ServiceId, string UserNumber, string Context, MessageTemplate Template) : Response(ServiceId, UserNumber, Context) { public TemplateResponse(string ServiceId, string UserNumber, string Context, string Name, string Language) - : this(UserNumber, ServiceId, Context, new { name = Name, language = new { code = Language } }) + : this(UserNumber, ServiceId, Context, new MessageTemplate(Name, Language)) { } diff --git a/src/WhatsApp/WhatsAppClientExtensions.cs b/src/WhatsApp/WhatsAppClientExtensions.cs index aaed61c..d3f4c4f 100644 --- a/src/WhatsApp/WhatsAppClientExtensions.cs +++ b/src/WhatsApp/WhatsAppClientExtensions.cs @@ -89,7 +89,7 @@ public static Task ReactAsync(this IWhatsAppClient client, string serviceId, str /// /// /// - public static Task SendTemplateAsync(this IWhatsAppClient client, string serviceId, string userNumber, object template, CancellationToken cancellation = default) + public static Task SendTemplateAsync(this IWhatsAppClient client, string serviceId, string userNumber, MessageTemplate template, CancellationToken cancellation = default) => client.SendAsync(serviceId, new { messaging_product = "whatsapp",