diff --git a/src/Directory.props b/src/Directory.props index e9b97b2..54f645f 100644 --- a/src/Directory.props +++ b/src/Directory.props @@ -4,6 +4,7 @@ Devlooped.WhatsApp 41fc668e-a410-48d4-9884-c2937478d9e1 AGPL-3.0-or-later WITH Universal-FOSS-exception-1.0 + true \ No newline at end of file diff --git a/src/Sample/Program.cs b/src/Sample/Program.cs index a86cbc3..1ef0c88 100644 --- a/src/Sample/Program.cs +++ b/src/Sample/Program.cs @@ -3,24 +3,29 @@ using Devlooped.WhatsApp; using Microsoft.Azure.Functions.Worker.Builder; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; var builder = FunctionsApplication.CreateBuilder(args); -var options = new JsonSerializerOptions(JsonSerializerDefaults.General) +builder.ConfigureFunctionsWebApplication(); + +#if DEBUG +builder.Environment.EnvironmentName = "Development"; +builder.Configuration.AddUserSecrets(); +#endif +builder.Services.AddSingleton(new JsonSerializerOptions(JsonSerializerDefaults.General) { Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping, Converters = { new JsonStringEnumConverter() }, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, WriteIndented = true -}; - -builder.ConfigureFunctionsWebApplication(); -builder.Configuration.AddUserSecrets(); +}); -builder.UseWhatsApp>(async (client, logger, message) => +builder.UseWhatsApp, JsonSerializerOptions>(async (client, logger, options, message) => { logger.LogInformation("💬 Received message: {Message}", message); @@ -52,24 +57,31 @@ } else if (message is InteractiveMessage interactive) { - logger.LogWarning("👤 User chose button {Button} ({Title})", interactive.Button.Id, interactive.Button.Title); + logger.LogWarning("👤 chose {Button} ({Title})", interactive.Button.Id, interactive.Button.Title); + await client.ReplyAsync(interactive, $"👤 chose: {interactive.Button.Title} ({interactive.Button.Id})"); + return; + } + else if (message is ReactionMessage reaction) + { + logger.LogInformation("👤 reaction: {Reaction}", reaction.Emoji); + await client.ReplyAsync(reaction, $"👤 reaction: {reaction.Emoji}"); return; } else if (message is StatusMessage status) { - logger.LogInformation("☑️ New message status: {Status}", status.Status); + logger.LogInformation("☑️ status: {Status}", status.Status); return; } else if (message is ContentMessage content) { - await client.ReactAsync(message, "🧠"); + await client.ReactAsync(content, "🧠"); // simulate some hard work at hand, like doing some LLM-stuff :) //await Task.Delay(2000); - await client.ReplyAsync(message, $"☑️ Got your {content.Content.Type}:\r\n{JsonSerializer.Serialize(content, options)}"); + await client.ReplyAsync(content, $"☑️ Got your {content.Content.Type}:\r\n{JsonSerializer.Serialize(content, options)}"); } else if (message is UnsupportedMessage unsupported) { - await client.ReactAsync(message, "⚠️"); + logger.LogWarning("⚠️ {Message}", unsupported); return; } }); diff --git a/src/Sample/Sample.csproj b/src/Sample/Sample.csproj index ca8f922..8eb4c94 100644 --- a/src/Sample/Sample.csproj +++ b/src/Sample/Sample.csproj @@ -3,8 +3,6 @@ net8.0 v4 Exe - enable - enable diff --git a/src/Tests/Content/WhatsApp/Reaction.json b/src/Tests/Content/WhatsApp/Reaction.json new file mode 100644 index 0000000..f57b45c --- /dev/null +++ b/src/Tests/Content/WhatsApp/Reaction.json @@ -0,0 +1,38 @@ +{ + "object": "whatsapp_business_account", + "entry": [ + { + "id": "123456789012345", + "changes": [ + { + "value": { + "messaging_product": "whatsapp", + "metadata": { + "display_phone_number": "5491234567890", + "phone_number_id": "987654321098765" + }, + "contacts": [ + { + "profile": { "name": "RandomName" }, + "wa_id": "5499876543210" + } + ], + "messages": [ + { + "from": "5499876543210", + "id": "wamid.HBgNMTIzNDU2Nzg5MDEyMzQ1MhUCABEYEkY5QzQxNDNBQjgyRkVENEIzMQA=", + "timestamp": "1744229999", + "type": "reaction", + "reaction": { + "message_id": "wamid.HBgNMTIzNDU2Nzg5MDEyMzQ1MhUCABEYEkY5QzQxNDNBQjgyRkVENEIzMQA=", + "emoji": "😊" + } + } + ] + }, + "field": "messages" + } + ] + } + ] +} \ No newline at end of file diff --git a/src/Tests/Content/WhatsApp/StatusDelivered.json b/src/Tests/Content/WhatsApp/StatusDelivered.json new file mode 100644 index 0000000..231b10b --- /dev/null +++ b/src/Tests/Content/WhatsApp/StatusDelivered.json @@ -0,0 +1,37 @@ +{ + "object": "whatsapp_business_account", + "entry": [ + { + "id": "918273645102347", + "changes": [ + { + "value": { + "messaging_product": "whatsapp", + "metadata": { + "display_phone_number": "1209384756109", + "phone_number_id": "739102584617293" + }, + "statuses": [ + { + "id": "wamid.HBgMMTIwMzQ1Njc4OTAxNxUCABEYEjZFNzI5QzFDNkE5RDg3MjBBNwA=", + "status": "sent", + "timestamp": "1829471036", + "recipient_id": "1203456789012", + "conversation": { + "id": "4a7b9c2d8e5f0136pqr890xy12mn3op", + "origin": { "type": "utility" } + }, + "pricing": { + "billable": false, + "pricing_model": "NBP", + "category": "utility" + } + } + ] + }, + "field": "messages" + } + ] + } + ] +} \ No newline at end of file diff --git a/src/Tests/Content/WhatsApp/Text.json b/src/Tests/Content/WhatsApp/Text.json index 6ddb4f9..fee9f4b 100644 --- a/src/Tests/Content/WhatsApp/Text.json +++ b/src/Tests/Content/WhatsApp/Text.json @@ -19,6 +19,10 @@ ], "messages": [ { + "context": { + "from": "12025550123", + "id": "wamid.HBgNNTQ5MTE1OTL4ODI4MhUCBBEYEjUxNDI3NkMzRkI1ODVCRTgwOAA=" + }, "from": "12029874563", "id": "wamid.HBgNMTIwMjk4NzQ1NjM1NhUCABIYFjQ5RjE4QzJEMzU2ODk3QTJFMUY3RDEyMjNBNkI5QwA==", "timestamp": "1678902345", diff --git a/src/Tests/WhatsAppClientTests.cs b/src/Tests/WhatsAppClientTests.cs index e6a7d68..ab78aaf 100644 --- a/src/Tests/WhatsAppClientTests.cs +++ b/src/Tests/WhatsAppClientTests.cs @@ -31,7 +31,7 @@ public async Task SendsMessageAsync() { var (configuration, client) = Initialize(); - await client.SendAync(configuration["SendFrom"]!, configuration["SendTo"]!, "Hi there!"); + await client.SendAsync(configuration["SendFrom"]!, configuration["SendTo"]!, "Hi there!"); } [SecretsFact("Meta:VerifyToken", "SendFrom", "SendTo")] diff --git a/src/Tests/WhatsAppModelTests.cs b/src/Tests/WhatsAppModelTests.cs index 18f1144..3084340 100644 --- a/src/Tests/WhatsAppModelTests.cs +++ b/src/Tests/WhatsAppModelTests.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Reflection.Metadata; using System.Text; using System.Text.Json; using System.Threading.Tasks; @@ -13,48 +14,28 @@ namespace Devlooped.WhatsApp; public class WhatsAppModelTests(ITestOutputHelper output) { [Theory] - [InlineData( - """ - { - "object": "whatsapp_business_account", - "entry": [ - { - "id": "554372691093163", - "changes": [ - { - "value": { - "messaging_product": "whatsapp", - "metadata": { - "display_phone_number": "5491123960774", - "phone_number_id": "524718744066632" - }, - "contacts": [ - { - "profile": { "name": "Kzu" }, - "wa_id": "5491159278282" - } - ], - "messages": [ - { - "from": "5491159278282", - "id": "wamid.HBgNNTQ5MTE1OTI3ODI4MhUCABIYFjNFQjBFOEFGODAzMEI4RTI3NzczNjkA", - "timestamp": "1744062742", - "text": { "body": "hello!" }, - "type": "text" - } - ] - }, - "field": "messages" - } - ] - } - ] - } - """)] - public async Task DeserializePayload(string json) + [InlineData(nameof(ContentType.Audio), "927483105672819", "wamid.XYZRandomString123ABC456DEF789GHI==")] + [InlineData(nameof(ContentType.Contact), "927481035162874", "wamid.HBgNNDcyODkwMTIzNDU2NhUCABIYFjE4QTlDMzU2MkJDOTg3RUY2NDg5RTFEMTIzQzVFRAA==")] + [InlineData(nameof(ContentType.Document), "813947205126374", "wamid.HBgNMTIwMjU1NTk4NzY1NhUCABIYFjE4QTlDMzU2MkJDOTg3RUY2NDg5RTFEMTIzQzVFRAA==")] + [InlineData(nameof(ContentType.Image), "813927405162784", "wamid.HBgNMTIwMjU1NTk4NzY1NhUCABIYFjE4QTlDMzU2MkJDOTg3RUY2NDg5RTFEMTIzQzVFRAA==")] + [InlineData(nameof(ContentType.Location), "813920475601234", "wamid.HBgNMTIwMjk4NzQ1NjM1NhUCABIYFjE5RDhGMzQ2NEJDOTg3RUY2NDg5RTFEMTIzQzVFRAA==")] + [InlineData(nameof(ContentType.Text), "813920475102346", "wamid.HBgNMTIwMjk4NzQ1NjM1NhUCABIYFjQ5RjE4QzJEMzU2ODk3QTJFMUY3RDEyMjNBNkI5QwA==", "wamid.HBgNNTQ5MTE1OTL4ODI4MhUCBBEYEjUxNDI3NkMzRkI1ODVCRTgwOAA=")] + [InlineData(nameof(ContentType.Video), "813927405162374", "wamid.HBgNMTIwMjU1NTk4NzY1NhUCABIYFjE4QTlDMzU2MkJDOTg3RUY2NDg5RTFEMTIzQzVFRAA==")] + [InlineData(nameof(MessageType.Unsupported), "837625914708254", "wamid.HBgNNTQ5MzcyNjEwNDg1OVUCABIYFjJCRDM5RTg0QkY3OEQxMjM2RkE0QjcA")] + [InlineData(nameof(MessageType.Error), "729104583621947", "wamid.XYZgMDEyMzQ1Njc4OTA5MRUCABEYEjU5NkM3ODlFQjAxMjM0NTY7OA==")] + [InlineData(nameof(MessageType.Interactive), "123456789012345", "wamid.RandomMessageID", "wamid.RandomContextID")] + [InlineData(nameof(MessageType.Reaction), "123456789012345", "wamid.HBgNMTIzNDU2Nzg5MDEyMzQ1MhUCABEYEkY5QzQxNDNBQjgyRkVENEIzMQA=", "wamid.HBgNMTIzNDU2Nzg5MDEyMzQ1MhUCABEYEkY5QzQxNDNBQjgyRkVENEIzMQA=")] + // For consistency, status message ID == status context ID. + [InlineData(nameof(MessageType.Status), "987654321098765", "wamid.HBgNNTQ5OTg3NjU0MzIxMDlUCABEYEkLMNVzNDU2Nzg5MAA=", "wamid.HBgNNTQ5OTg3NjU0MzIxMDlUCABEYEkLMNVzNDU2Nzg5MAA=")] + public async Task DeserializeMessage(string type, string notification, string id, string? context = default) { + var json = await File.ReadAllTextAsync($"Content/WhatsApp/{type}.json"); var message = await Message.DeserializeAsync(json); + Assert.NotNull(message); + Assert.Equal(notification, message.NotificationId); + Assert.Equal(id, message.Id); + Assert.Equal(context, message.Context); Assert.NotNull(message.To); Assert.NotNull(message.From); } @@ -67,7 +48,7 @@ public async Task DeserializePayload(string json) [InlineData(ContentType.Location)] [InlineData(ContentType.Text)] [InlineData(ContentType.Video)] - public async Task DeserializePolymorphic(ContentType type) + public async Task DeserializeContent(ContentType type) { var json = await File.ReadAllTextAsync($"Content/WhatsApp/{type}.json"); var message = await Message.DeserializeAsync(json); @@ -75,6 +56,7 @@ public async Task DeserializePolymorphic(ContentType type) var content = Assert.IsType(message); Assert.NotNull(message); + Assert.NotNull(message.NotificationId); Assert.NotNull(message.To); Assert.NotNull(message.From); Assert.NotNull(content.Content); @@ -90,6 +72,7 @@ public async Task DeserializeErrorStatus() var error = Assert.IsType(message); Assert.NotNull(message); + Assert.NotNull(message.NotificationId); Assert.NotNull(message.To); Assert.NotNull(message.From); Assert.NotNull(error.Error); @@ -105,6 +88,7 @@ public async Task DeserializeStatus() var status = Assert.IsType(message); Assert.NotNull(message); + Assert.NotNull(message.NotificationId); Assert.NotNull(message.To); Assert.NotNull(message.From); Assert.Equal(Status.Delivered, status.Status); @@ -119,6 +103,7 @@ public async Task DeserializeInteractive() var interactive = Assert.IsType(message); Assert.NotNull(message); + Assert.NotNull(message.NotificationId); Assert.NotNull(message.To); Assert.NotNull(message.From); Assert.Equal("btn_yes", interactive.Button.Id); @@ -134,7 +119,23 @@ public async Task DeserializeUnsupported() var unsupported = Assert.IsType(message); Assert.NotNull(message); + Assert.NotNull(message.NotificationId); + Assert.NotNull(message.To); + Assert.NotNull(message.From); + } + + [Fact] + public async Task DeserializeReaction() + { + var json = await File.ReadAllTextAsync($"Content/WhatsApp/Reaction.json"); + var message = await Message.DeserializeAsync(json); + + var reaction = Assert.IsType(message); + + Assert.NotNull(message); + Assert.NotNull(message.NotificationId); Assert.NotNull(message.To); Assert.NotNull(message.From); + Assert.Equal("😊", reaction.Emoji); } } diff --git a/src/WhatsApp/AzureFunctions.cs b/src/WhatsApp/AzureFunctions.cs index 559eb41..4b72ae3 100644 --- a/src/WhatsApp/AzureFunctions.cs +++ b/src/WhatsApp/AzureFunctions.cs @@ -1,7 +1,4 @@ -using System.IO; -using System.Net.Http; -using System.Text; -using System.Threading.Tasks; +using System.Text; using Azure.Data.Tables; using Azure.Storage.Queues; using Microsoft.AspNetCore.Http; @@ -27,27 +24,6 @@ public class AzureFunctions( IOptions options, ILogger logger) { - [Function("whatsapp_register")] - public HttpResponseMessage Register([HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "whatsapp")] HttpRequest req) - { - if (req.Query.TryGetValue("hub.mode", out var mode) && mode == "subscribe" && - req.Query.TryGetValue("hub.verify_token", out var token) && token == options.Value.VerifyToken && - req.Query.TryGetValue("hub.challenge", out var values) && - values.ToString() is { } challenge) - { - logger.LogInformation("Registering webhook callback."); - return new HttpResponseMessage(System.Net.HttpStatusCode.OK) - { - Content = new StringContent(challenge, Encoding.UTF8, "text/plain") - }; - } - - return new HttpResponseMessage(System.Net.HttpStatusCode.BadRequest) - { - Content = new StringContent("Received verification token doesn't match the configured one.", Encoding.UTF8, "text/plain") - }; - } - [Function("whatsapp_message")] public async Task Message([HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "whatsapp")] HttpRequest req) { @@ -60,7 +36,7 @@ public async Task Message([HttpTrigger(AuthorizationLevel.A // Ensure idempotent processing var table = tableClient.GetTableClient("whatsapp"); await table.CreateIfNotExistsAsync(); - if (await table.GetEntityIfExistsAsync(message.From.Number, message.Id) is { HasValue: true } existing) + if (await table.GetEntityIfExistsAsync(message.From.Number, message.NotificationId) is { HasValue: true } existing) { logger.LogInformation("Skipping already handled message {Id}", message.Id); return new HttpResponseMessage(System.Net.HttpStatusCode.OK); @@ -70,7 +46,10 @@ public async Task Message([HttpTrigger(AuthorizationLevel.A var queue = queueClient.GetQueueClient("whatsapp"); await queue.CreateIfNotExistsAsync(); await queue.SendMessageAsync(json); - if (message.Type == MessageType.Content) + + // Mark read these two types of messages we want to explicitly acknowledge from users. + if (message.Type == MessageType.Content || + message.Type == MessageType.Interactive) { try { @@ -104,7 +83,7 @@ public async Task Process([QueueTrigger("whatsapp", Connection = "AzureWebJobsSt // happening (and therefore we didn't save the entity yet). var table = tableClient.GetTableClient("whatsapp"); await table.CreateIfNotExistsAsync(); - if (await table.GetEntityIfExistsAsync(message.From.Number, message.Id) is { HasValue: true } existing) + if (await table.GetEntityIfExistsAsync(message.From.Number, message.NotificationId) is { HasValue: true } existing) { logger.LogInformation("Skipping already handled message {Id}", message.Id); return; @@ -119,4 +98,25 @@ public async Task Process([QueueTrigger("whatsapp", Connection = "AzureWebJobsSt logger.LogWarning("Failed to deserialize message."); } } + + [Function("whatsapp_register")] + public HttpResponseMessage Register([HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "whatsapp")] HttpRequest req) + { + if (req.Query.TryGetValue("hub.mode", out var mode) && mode == "subscribe" && + req.Query.TryGetValue("hub.verify_token", out var token) && token == options.Value.VerifyToken && + req.Query.TryGetValue("hub.challenge", out var values) && + values.ToString() is { } challenge) + { + logger.LogInformation("Registering webhook callback."); + return new HttpResponseMessage(System.Net.HttpStatusCode.OK) + { + Content = new StringContent(challenge, Encoding.UTF8, "text/plain") + }; + } + + return new HttpResponseMessage(System.Net.HttpStatusCode.BadRequest) + { + Content = new StringContent("Received verification token doesn't match the configured one.", Encoding.UTF8, "text/plain") + }; + } } diff --git a/src/WhatsApp/ContentMessage.cs b/src/WhatsApp/ContentMessage.cs index bc3d5ef..b357476 100644 --- a/src/WhatsApp/ContentMessage.cs +++ b/src/WhatsApp/ContentMessage.cs @@ -10,7 +10,7 @@ namespace Devlooped.WhatsApp; /// The user that sent the message. /// Timestamp of the message. /// Message content. -public record ContentMessage(string Id, Service To, User From, long Timestamp, Content Content) : Message(Id, To, From, Timestamp) +public record ContentMessage(string Id, Service To, User From, long Timestamp, Content Content) : UserMessage(Id, To, From, Timestamp) { /// [JsonIgnore] diff --git a/src/WhatsApp/ErrorMessage.cs b/src/WhatsApp/ErrorMessage.cs index 1bd6b4c..b5e8ec3 100644 --- a/src/WhatsApp/ErrorMessage.cs +++ b/src/WhatsApp/ErrorMessage.cs @@ -10,7 +10,7 @@ namespace Devlooped.WhatsApp; /// The user that sent the message. /// Timestamp of the message. /// The error. -public record ErrorMessage(string Id, Service To, User From, long Timestamp, Error Error) : Message(Id, To, From, Timestamp) +public record ErrorMessage(string Id, Service To, User From, long Timestamp, Error Error) : SystemMessage(Id, To, From, Timestamp) { /// [JsonIgnore] diff --git a/src/WhatsApp/InteractiveMessage.cs b/src/WhatsApp/InteractiveMessage.cs index 31d5d2b..254badc 100644 --- a/src/WhatsApp/InteractiveMessage.cs +++ b/src/WhatsApp/InteractiveMessage.cs @@ -10,7 +10,7 @@ namespace Devlooped.WhatsApp; /// The user that sent the message. /// Timestamp of the message. /// The button selected by the user. -public record InteractiveMessage(string Id, Service To, User From, long Timestamp, Button Button) : Message(Id, To, From, Timestamp) +public record InteractiveMessage(string Id, Service To, User From, long Timestamp, Button Button) : UserMessage(Id, To, From, Timestamp) { /// [JsonIgnore] diff --git a/src/WhatsApp/Message.cs b/src/WhatsApp/Message.cs index 71bc867..addc0a8 100644 --- a/src/WhatsApp/Message.cs +++ b/src/WhatsApp/Message.cs @@ -1,6 +1,5 @@ using System.Text.Json; using System.Text.Json.Serialization; -using System.Threading.Tasks; namespace Devlooped.WhatsApp; @@ -15,109 +14,156 @@ namespace Devlooped.WhatsApp; [JsonDerivedType(typeof(ContentMessage), "content")] [JsonDerivedType(typeof(ErrorMessage), "error")] [JsonDerivedType(typeof(InteractiveMessage), "interactive")] +[JsonDerivedType(typeof(ReactionMessage), "reaction")] [JsonDerivedType(typeof(StatusMessage), "status")] [JsonDerivedType(typeof(UnsupportedMessage), "unsupported")] public abstract partial record Message(string Id, Service To, User From, long Timestamp) { + /// + /// Optional related message identifier, such as message being replied + /// or reacted to, or a status message refers to, or the interactive + /// selection is a response to. + /// + /// + /// In a , the context equals the status ID which + /// in turn equals the message ID the status refers to. + /// + public string? Context { get; init; } + + [JsonInclude] + [JsonPropertyName("notification")] + internal string? NotificationId { get; init; } + const string JQ = """ ( - .entry[].changes[] | + .entry[] | + .id as $notification | + .changes[] | select(.value.messages != null) | (.value.metadata as $phone | .value.contacts[0] as $user | .value.messages[0] as $msg | select($msg != null) | - if $msg.type == "interactive" then - { - "$type": "interactive", - "id": $msg.id, - "timestamp": $msg.timestamp | tonumber, - "to": { - "id": $phone.phone_number_id, - "number": $phone.display_phone_number - }, - "from": { - "name": ($user.profile.name // "Unknown"), - "number": $msg.from - }, - "button": $msg.interactive.button_reply - } - elif $msg.type == "document" or $msg.type == "contacts" or $msg.type == "text" or $msg.type == "location" or $msg.type == "image" or $msg.type == "video" or $msg.type == "audio" then - { - "$type": "content", - "id": $msg.id, - "timestamp": $msg.timestamp | tonumber, - "to": { - "id": $phone.phone_number_id, - "number": $phone.display_phone_number - }, - "from": { - "name": ($user.profile.name // "Unknown"), - "number": $msg.from - }, - "content": ( - if $msg.type == "document" then - { - "$type": "document", - "id": $msg.document.id, - "name": $msg.document.filename, - "mime": $msg.document.mime_type, - "sha256": $msg.document.sha256 - } - elif $msg.type == "contacts" then - { - "$type": "contacts", - "name": $msg.contacts[0].name.first_name, - "surname": $msg.contacts[0].name.last_name, - "numbers": [$msg.contacts[0].phones[] | select(.wa_id? != null) | .wa_id] - } - elif $msg.type == "text" then - { - "$type": "text", - "text": $msg.text.body - } - elif $msg.type == "location" then - { - "$type": "location", - "location": { - "latitude": $msg.location.latitude, - "longitude": $msg.location.longitude - }, - "address": $msg.location.address, - "name": $msg.location.name, - "url": $msg.location.url - } - elif $msg.type == "image" or $msg.type == "video" or $msg.type == "audio" then - { - "$type": $msg.type, - "id": $msg[$msg.type].id, - "mime": $msg[$msg.type].mime_type, - "sha256": $msg[$msg.type].sha256 - } - end - ) - } - else - { - "$type": "unsupported", - "id": $msg.id, - "timestamp": $msg.timestamp | tonumber, - "to": { - "id": $phone.phone_number_id, - "number": $phone.display_phone_number - }, - "from": { - "name": ($user.profile.name // "Unknown"), - "number": $msg.from - }, - "raw": $msg - } - end + ($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 + { + "$type": "interactive", + "notification": $notification, + "id": $msg.id, + "context": $context, + "timestamp": $msg.timestamp | tonumber, + "to": { + "id": $phone.phone_number_id, + "number": $phone.display_phone_number + }, + "from": { + "name": ($user.profile.name // "Unknown"), + "number": $msg.from + }, + "button": $msg.interactive.button_reply + } + elif $msgType == "reaction" then + { + "$type": "reaction", + "notification": $notification, + "id": $msg.id, + "context": $context, + "timestamp": $msg.timestamp | tonumber, + "to": { + "id": $phone.phone_number_id, + "number": $phone.display_phone_number + }, + "from": { + "name": ($user.profile.name // "Unknown"), + "number": $msg.from + }, + "emoji": $msg.reaction.emoji + } + elif $msgType == "document" or $msgType == "contacts" or $msgType == "text" or $msgType == "location" or $msgType == "image" or $msgType == "video" or $msgType == "audio" then + { + "$type": "content", + "notification": $notification, + "id": $msg.id, + "context": $context, + "timestamp": $msg.timestamp | tonumber, + "to": { + "id": $phone.phone_number_id, + "number": $phone.display_phone_number + }, + "from": { + "name": ($user.profile.name // "Unknown"), + "number": $msg.from + }, + "content": ( + if $msgType == "document" then + { + "$type": "document", + "id": $msg.document.id, + "name": $msg.document.filename, + "mime": $msg.document.mime_type, + "sha256": $msg.document.sha256 + } + elif $msgType == "contacts" then + { + "$type": "contacts", + "name": $msg.contacts[0].name.first_name, + "surname": $msg.contacts[0].name.last_name, + "numbers": [$msg.contacts[0].phones[] | select(.wa_id? != null) | .wa_id] + } + elif $msgType == "text" then + { + "$type": "text", + "text": $msg.text.body + } + elif $msgType == "location" then + { + "$type": "location", + "location": { + "latitude": $msg.location.latitude, + "longitude": $msg.location.longitude + }, + "address": $msg.location.address, + "name": $msg.location.name, + "url": $msg.location.url + } + elif $msgType == "image" or $msgType == "video" or $msgType == "audio" then + { + "$type": $msgType, + "id": $msg[$msgType].id, + "mime": $msg[$msgType].mime_type, + "sha256": $msg[$msgType].sha256 + } + end + ) + } + else + { + "$type": "unsupported", + "notification": $notification, + "id": $msg.id, + "context": $context, + "timestamp": $msg.timestamp | tonumber, + "to": { + "id": $phone.phone_number_id, + "number": $phone.display_phone_number + }, + "from": { + "name": ($user.profile.name // "Unknown"), + "number": $msg.from + }, + "raw": $msg + } + end + ) ) ), ( - .entry[].changes[] | + .entry[] | + .id as $notification | + .changes[] | select(.value.statuses != null) | (.value.metadata as $phone | .value.statuses[0] as $status | @@ -126,6 +172,7 @@ .value.statuses[0] as $status | $status.errors[] | { "$type": "error", + "notification": $notification, "id": $status.id, "timestamp": $status.timestamp | tonumber, "to": { @@ -144,7 +191,9 @@ .value.statuses[0] as $status | else { "$type": "status", + "notification": $notification, "id": $status.id, + "context": $status.id, "timestamp": $status.timestamp | tonumber, "to": { "id": $phone.phone_number_id, @@ -189,11 +238,17 @@ .value.statuses[0] as $status | [JsonIgnore] public abstract MessageType Type { get; } - [JsonSourceGenerationOptions(JsonSerializerDefaults.Web, WriteIndented = true, UseStringEnumConverter = true)] + [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/MessageType.cs b/src/WhatsApp/MessageType.cs index 1a4e838..e780d6e 100644 --- a/src/WhatsApp/MessageType.cs +++ b/src/WhatsApp/MessageType.cs @@ -18,6 +18,10 @@ public enum MessageType /// Interactive, /// + /// Message contains a reaction to a message. + /// + Reaction, + /// /// Message contains a status update. /// Status, diff --git a/src/WhatsApp/ReactionMessage.cs b/src/WhatsApp/ReactionMessage.cs new file mode 100644 index 0000000..a195df0 --- /dev/null +++ b/src/WhatsApp/ReactionMessage.cs @@ -0,0 +1,18 @@ +using System.Text.Json.Serialization; + +namespace Devlooped.WhatsApp; + +/// +/// A reaction to a message. +/// +/// The identifier of the message this reaction applies to. +/// The service that received the message from the Cloud API. +/// The user that sent the message. +/// Timestamp of the message. +/// The emoji of the reaction. +public record ReactionMessage(string Id, Service To, User From, long Timestamp, string Emoji) : SystemMessage(Id, To, From, Timestamp) +{ + /// + [JsonIgnore] + public override MessageType Type => MessageType.Reaction; +} \ No newline at end of file diff --git a/src/WhatsApp/StatusMessage.cs b/src/WhatsApp/StatusMessage.cs index a00f2f1..b2af753 100644 --- a/src/WhatsApp/StatusMessage.cs +++ b/src/WhatsApp/StatusMessage.cs @@ -3,23 +3,35 @@ namespace Devlooped.WhatsApp; /// -/// A containing a status update. +/// A status update about a message. /// -/// The message identifier. +/// The identifier of the message this status update relates to. /// The service that received the message from the Cloud API. /// The user that sent the message. /// Timestamp of the message. /// The message status. -public record StatusMessage(string Id, Service To, User From, long Timestamp, Status Status) : Message(Id, To, From, Timestamp) +public record StatusMessage(string Id, Service To, User From, long Timestamp, Status Status) : SystemMessage(Id, To, From, Timestamp) { /// [JsonIgnore] public override MessageType Type => MessageType.Status; } +/// +/// Known statuses for a message. +/// public enum Status { + /// + /// The message was sent. + /// Sent, + /// + /// The message was delivered. + /// Delivered, + /// + /// The message was read. + /// Read, } diff --git a/src/WhatsApp/SystemMessage.cs b/src/WhatsApp/SystemMessage.cs new file mode 100644 index 0000000..571eb82 --- /dev/null +++ b/src/WhatsApp/SystemMessage.cs @@ -0,0 +1,10 @@ +namespace Devlooped.WhatsApp; + +/// +/// Base message class for messages that cannot be interacted with by the user. +/// +/// The message identifier. +/// The service that received the message from the Cloud API. +/// The user that sent the message. +/// Timestamp of the message. +public abstract record SystemMessage(string Id, Service To, User From, long Timestamp) : Message(Id, To, From, Timestamp); diff --git a/src/WhatsApp/UnsupportedMessage.cs b/src/WhatsApp/UnsupportedMessage.cs index d1d49ec..afe8a15 100644 --- a/src/WhatsApp/UnsupportedMessage.cs +++ b/src/WhatsApp/UnsupportedMessage.cs @@ -12,7 +12,7 @@ namespace Devlooped.WhatsApp; /// The user that sent the message. /// Timestamp of the message. /// JSON data. -public record UnsupportedMessage(string Id, Service To, User From, long Timestamp, JsonElement Raw) : Message(Id, To, From, Timestamp) +public record UnsupportedMessage(string Id, Service To, User From, long Timestamp, JsonElement Raw) : SystemMessage(Id, To, From, Timestamp) { /// [JsonIgnore] diff --git a/src/WhatsApp/UserMessage.cs b/src/WhatsApp/UserMessage.cs new file mode 100644 index 0000000..6c1c850 --- /dev/null +++ b/src/WhatsApp/UserMessage.cs @@ -0,0 +1,10 @@ +namespace Devlooped.WhatsApp; + +/// +/// Base message class for messages the user can interact with. +/// +/// The message identifier. +/// The service that received the message from the Cloud API. +/// The user that sent the message. +/// Timestamp of the message. +public abstract record UserMessage(string Id, Service To, User From, long Timestamp) : Message(Id, To, From, Timestamp); \ No newline at end of file diff --git a/src/WhatsApp/WhatsApp.csproj b/src/WhatsApp/WhatsApp.csproj index c487ccb..dd529dd 100644 --- a/src/WhatsApp/WhatsApp.csproj +++ b/src/WhatsApp/WhatsApp.csproj @@ -25,4 +25,8 @@ + + + + diff --git a/src/WhatsApp/WhatsAppClientExtensions.cs b/src/WhatsApp/WhatsAppClientExtensions.cs index 5c573e5..9a0233b 100644 --- a/src/WhatsApp/WhatsAppClientExtensions.cs +++ b/src/WhatsApp/WhatsAppClientExtensions.cs @@ -1,7 +1,4 @@ -using System; -using System.Threading.Tasks; - -namespace Devlooped.WhatsApp; +namespace Devlooped.WhatsApp; /// /// Usability extensions for common messaging scenarios for WhatsApp. @@ -12,7 +9,7 @@ public static class WhatsAppClientExtensions /// Marks the message as read. Happens automatically when the /// webhook endpoint is invoked with a message. /// - public static Task MarkReadAsync(this IWhatsAppClient client, Message message) + public static Task MarkReadAsync(this IWhatsAppClient client, UserMessage message) => MarkReadAsync(client, message.To.Id, message.Id); /// @@ -32,9 +29,9 @@ public static Task MarkReadAsync(this IWhatsAppClient client, string from, strin /// /// The WhatsApp client. /// The message to react to. - /// The reaction emoji. - public static Task ReactAsync(this IWhatsAppClient client, Message message, string reaction) - => ReactAsync(client, message.To.Id, message.From.Number, message.Id, reaction); + /// The reaction emoji. + public static Task ReactAsync(this IWhatsAppClient client, UserMessage message, string emoji) + => ReactAsync(client, message.To.Id, message.From.Number, message.Id, emoji); /// /// Reacts to a message. @@ -43,8 +40,8 @@ public static Task ReactAsync(this IWhatsAppClient client, Message message, stri /// The service number to send the reaction through. /// The user phone number to send the reaction to. /// The message identifier to react to. - /// The reaction emoji. - public static Task ReactAsync(this IWhatsAppClient client, string from, string to, string messageId, string reaction) + /// The reaction emoji. + public static Task ReactAsync(this IWhatsAppClient client, string from, string to, string messageId, string emoji) => client.SendAsync(from, new { messaging_product = "whatsapp", @@ -54,7 +51,7 @@ public static Task ReactAsync(this IWhatsAppClient client, string from, string t reaction = new { message_id = messageId, - emoji = reaction + emoji } }); @@ -64,7 +61,7 @@ public static Task ReactAsync(this IWhatsAppClient client, string from, string t /// The WhatsApp client. /// The message to reply to. /// The text message to respond with. - public static Task ReplyAsync(this IWhatsAppClient client, Message message, string reply) + public static Task ReplyAsync(this IWhatsAppClient client, UserMessage message, string reply) => client.SendAsync(message.To.Id, new { messaging_product = "whatsapp", @@ -82,12 +79,36 @@ public static Task ReplyAsync(this IWhatsAppClient client, Message message, stri } }); + /// + /// Replies to the message a user reacted to. + /// + /// The WhatsApp client. + /// The reaction from the user. + /// The text message to respond with. + public static Task ReplyAsync(this IWhatsAppClient client, ReactionMessage message, string reply) + => client.SendAsync(message.To.Id, new + { + messaging_product = "whatsapp", + preview_url = false, + recipient_type = "individual", + to = NormalizeNumber(message.From.Number), + type = "text", + context = new + { + message_id = message.Context + }, + text = new + { + body = reply + } + }); + /// /// Sends a text message a user given his incoming message, without making it a reply. /// /// The WhatsApp client. - public static Task SendAync(this IWhatsAppClient client, Message message, string text) - => SendAync(client, message.To.Id, message.From.Number, text); + public static Task SendAsync(this IWhatsAppClient client, Message message, string text) + => SendAsync(client, message.To.Id, message.From.Number, text); /// /// Sends a text message a user. @@ -96,7 +117,7 @@ public static Task SendAync(this IWhatsAppClient client, Message message, string /// The service number to send the message through. /// The user phone number to send the message to. /// The text message to send. - public static Task SendAync(this IWhatsAppClient client, string from, string to, string message) + public static Task SendAsync(this IWhatsAppClient client, string from, string to, string message) => client.SendAsync(from, new { messaging_product = "whatsapp", @@ -113,6 +134,5 @@ public static Task SendAync(this IWhatsAppClient client, string from, string to, static string NormalizeNumber(string number) => // On the web, we don't get the 9 after 54 \o/ // so for Argentina numbers, we need to remove the 9. - number.StartsWith("549", StringComparison.Ordinal) ? - "54" + number[3..] : number; + number.StartsWith("549", StringComparison.Ordinal) ? "54" + number[3..] : number; } \ No newline at end of file