diff --git a/src/Sample/Program.cs b/src/Sample/Program.cs index e058e40..a86cbc3 100644 --- a/src/Sample/Program.cs +++ b/src/Sample/Program.cs @@ -67,6 +67,11 @@ //await Task.Delay(2000); await client.ReplyAsync(message, $"☑️ Got your {content.Content.Type}:\r\n{JsonSerializer.Serialize(content, options)}"); } + else if (message is UnsupportedMessage unsupported) + { + await client.ReactAsync(message, "⚠️"); + return; + } }); builder.Build().Run(); diff --git a/src/Tests/Content/WhatsApp/Unsupported.json b/src/Tests/Content/WhatsApp/Unsupported.json new file mode 100644 index 0000000..785e649 --- /dev/null +++ b/src/Tests/Content/WhatsApp/Unsupported.json @@ -0,0 +1,42 @@ +{ + "object": "whatsapp_business_account", + "entry": [ + { + "id": "837625914708254", + "changes": [ + { + "value": { + "messaging_product": "whatsapp", + "metadata": { + "display_phone_number": "5492847619038", + "phone_number_id": "781364925037481" + }, + "contacts": [ + { + "profile": { "name": "User745" }, + "wa_id": "5493726104859" + } + ], + "messages": [ + { + "from": "5493726104859", + "id": "wamid.HBgNNTQ5MzcyNjEwNDg1OVUCABIYFjJCRDM5RTg0QkY3OEQxMjM2RkE0QjcA", + "timestamp": "1678945321", + "errors": [ + { + "code": 131051, + "title": "Message type unknown", + "message": "Message type unknown", + "error_data": { "details": "Message type is currently not supported." } + } + ], + "type": "unsupported" + } + ] + }, + "field": "messages" + } + ] + } + ] +} \ No newline at end of file diff --git a/src/Tests/WhatsAppModelTests.cs b/src/Tests/WhatsAppModelTests.cs index 2845e39..18f1144 100644 --- a/src/Tests/WhatsAppModelTests.cs +++ b/src/Tests/WhatsAppModelTests.cs @@ -124,4 +124,17 @@ public async Task DeserializeInteractive() Assert.Equal("btn_yes", interactive.Button.Id); Assert.Equal("Yes", interactive.Button.Title); } + + [Fact] + public async Task DeserializeUnsupported() + { + var json = await File.ReadAllTextAsync($"Content/WhatsApp/Unsupported.json"); + var message = await Message.DeserializeAsync(json); + + var unsupported = Assert.IsType(message); + + Assert.NotNull(message); + Assert.NotNull(message.To); + Assert.NotNull(message.From); + } } diff --git a/src/WhatsApp/Content.cs b/src/WhatsApp/Content.cs index ca65c9b..240b8f1 100644 --- a/src/WhatsApp/Content.cs +++ b/src/WhatsApp/Content.cs @@ -34,6 +34,7 @@ public abstract record Content public record DocumentContent(string Id, string Name, string Mime, string Sha256) : Content { /// + [JsonIgnore] public override ContentType Type => ContentType.Document; } @@ -46,6 +47,7 @@ public record DocumentContent(string Id, string Name, string Mime, string Sha256 public record ContactContent(string Name, string Surname, string[] Numbers) : Content { /// + [JsonIgnore] public override ContentType Type => ContentType.Contact; } @@ -56,6 +58,7 @@ public record ContactContent(string Name, string Surname, string[] Numbers) : Co public record TextContent(string Text) : Content { /// + [JsonIgnore] public override ContentType Type => ContentType.Text; } @@ -76,6 +79,7 @@ public record Location(double Latitude, double Longitude); public record LocationContent(Location Location, string? Address, string? Name, string? Url) : Content { /// + [JsonIgnore] public override ContentType Type => ContentType.Location; } @@ -96,6 +100,7 @@ public abstract record MediaContent(string Id, string Mime, string Sha256) : Con public record AudioContent(string Id, string Mime, string Sha256) : MediaContent(Id, Mime, Sha256) { /// + [JsonIgnore] public override ContentType Type => ContentType.Audio; } @@ -108,6 +113,7 @@ public record AudioContent(string Id, string Mime, string Sha256) : MediaContent public record ImageContent(string Id, string Mime, string Sha256) : MediaContent(Id, Mime, Sha256) { /// + [JsonIgnore] public override ContentType Type => ContentType.Image; } @@ -120,6 +126,7 @@ public record ImageContent(string Id, string Mime, string Sha256) : MediaContent public record VideoContent(string Id, string Mime, string Sha256) : MediaContent(Id, Mime, Sha256) { /// + [JsonIgnore] public override ContentType Type => ContentType.Video; } @@ -130,5 +137,6 @@ public record VideoContent(string Id, string Mime, string Sha256) : MediaContent public record UnknownContent(JsonElement Raw) : Content { /// + [JsonIgnore] public override ContentType Type => ContentType.Unknown; } diff --git a/src/WhatsApp/ContentMessage.cs b/src/WhatsApp/ContentMessage.cs index 03c197d..bc3d5ef 100644 --- a/src/WhatsApp/ContentMessage.cs +++ b/src/WhatsApp/ContentMessage.cs @@ -1,4 +1,6 @@ -namespace Devlooped.WhatsApp; +using System.Text.Json.Serialization; + +namespace Devlooped.WhatsApp; /// /// A containing . @@ -10,72 +12,7 @@ /// Message content. public record ContentMessage(string Id, Service To, User From, long Timestamp, Content Content) : Message(Id, To, From, Timestamp) { - /// - /// A JQ query that transforms WhatsApp Cloud API JSON into polymorphic JSON for - /// C# deserialization of a . - /// - public const string JQ = - """ - .entry[].changes[].value.metadata as $phone | - .entry[].changes[].value.contacts[]? as $user | - .entry[].changes[].value.messages[]? | - select(. != null and .type != "interactive") | - .type as $type | - { - id: .id, - timestamp: .timestamp | tonumber, - to: { - id: $phone.phone_number_id, - number: $phone.display_phone_number - }, - from: { - name: $user.profile.name, - number: $user.wa_id - }, - content: ( - if $type == "document" then { - "$type": $type, - id: .document.id, - name: .document.filename, - mime: .document.mime_type, - sha256: .document.sha256 - } - elif $type == "contacts" then { - "$type": $type, - name: .contacts[].name.first_name, - surname: .contacts[].name.last_name, - numbers: [.contacts[].phones[] | - select(.wa_id? != null) | .wa_id] - } - elif $type == "text" then { - "$type": $type, - text: .text.body - } - elif $type == "location" then { - "$type": $type, - location: { - latitude: .location.latitude, - longitude: .location.longitude - }, - address: .location.address, - name: .location.name, - url: .location.url - } - elif $type == "image" or $type == "video" or $type == "audio" then { - "$type": $type, - id: .[$type].id, - mime: .[$type].mime_type, - sha256: .[$type].sha256 - } - else { - "$type": "unknown", - raw: . - } - end - ) - } - """; - /// + [JsonIgnore] public override MessageType Type => MessageType.Content; } diff --git a/src/WhatsApp/ErrorMessage.cs b/src/WhatsApp/ErrorMessage.cs index fe00bb5..1bd6b4c 100644 --- a/src/WhatsApp/ErrorMessage.cs +++ b/src/WhatsApp/ErrorMessage.cs @@ -1,4 +1,6 @@ -namespace Devlooped.WhatsApp; +using System.Text.Json.Serialization; + +namespace Devlooped.WhatsApp; /// /// A containing an . @@ -10,34 +12,8 @@ /// The error. public record ErrorMessage(string Id, Service To, User From, long Timestamp, Error Error) : Message(Id, To, From, Timestamp) { - /// - /// A JQ query that transforms WhatsApp Cloud API JSON into the serialization - /// expected by . - /// - public const string JQ = - """ - .entry[].changes[].value.metadata as $phone | - .entry[].changes[].value.statuses[]? | - select(. != null) | - { - id: .id, - timestamp: .timestamp | tonumber, - to: { - id: $phone.phone_number_id, - number: $phone.display_phone_number - }, - from: { - name: .recipient_id, - number: .recipient_id - }, - error: .errors[]? | { - code: .code, - message: (.error_data.details // .message), - } - } - """; - /// + [JsonIgnore] public override MessageType Type => MessageType.Error; } diff --git a/src/WhatsApp/InteractiveMessage.cs b/src/WhatsApp/InteractiveMessage.cs index d50d197..31d5d2b 100644 --- a/src/WhatsApp/InteractiveMessage.cs +++ b/src/WhatsApp/InteractiveMessage.cs @@ -1,4 +1,6 @@ -namespace Devlooped.WhatsApp; +using System.Text.Json.Serialization; + +namespace Devlooped.WhatsApp; /// /// A containing an interactive button reply. @@ -10,32 +12,8 @@ /// The button selected by the user. public record InteractiveMessage(string Id, Service To, User From, long Timestamp, Button Button) : Message(Id, To, From, Timestamp) { - /// - /// A JQ query that transforms WhatsApp Cloud API JSON into the serialization - /// expected by . - /// - public const string JQ = - """ - .entry[].changes[].value.metadata as $phone | - .entry[].changes[].value.contacts[]? as $user | - .entry[].changes[].value.messages[]? | - select(. != null and .type == "interactive") | - { - id: .id, - timestamp: .timestamp | tonumber, - to: { - id: $phone.phone_number_id, - number: $phone.display_phone_number - }, - from: { - name: $user.profile.name, - number: $user.wa_id - }, - button: .interactive.button_reply - } - """; - /// + [JsonIgnore] public override MessageType Type => MessageType.Interactive; } diff --git a/src/WhatsApp/Message.cs b/src/WhatsApp/Message.cs index 402b126..71bc867 100644 --- a/src/WhatsApp/Message.cs +++ b/src/WhatsApp/Message.cs @@ -1,5 +1,4 @@ -using System; -using System.Text.Json; +using System.Text.Json; using System.Text.Json.Serialization; using System.Threading.Tasks; @@ -12,8 +11,156 @@ namespace Devlooped.WhatsApp; /// The service that received the message from the Cloud API. /// The user that sent the message. /// Timestamp of the message. +[JsonPolymorphic] +[JsonDerivedType(typeof(ContentMessage), "content")] +[JsonDerivedType(typeof(ErrorMessage), "error")] +[JsonDerivedType(typeof(InteractiveMessage), "interactive")] +[JsonDerivedType(typeof(StatusMessage), "status")] +[JsonDerivedType(typeof(UnsupportedMessage), "unsupported")] public abstract partial record Message(string Id, Service To, User From, long Timestamp) { + const string JQ = + """ + ( + .entry[].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 + ) + ), + ( + .entry[].changes[] | + select(.value.statuses != null) | + (.value.metadata as $phone | + .value.statuses[0] as $status | + select($status != null) | + if $status.errors? then + $status.errors[] | + { + "$type": "error", + "id": $status.id, + "timestamp": $status.timestamp | tonumber, + "to": { + "id": $phone.phone_number_id, + "number": $phone.display_phone_number + }, + "from": { + "name": $status.recipient_id, + "number": $status.recipient_id + }, + "error": { + "code": .code, + "message": (.error_data.details // .message) + } + } + else + { + "$type": "status", + "id": $status.id, + "timestamp": $status.timestamp | tonumber, + "to": { + "id": $phone.phone_number_id, + "number": $phone.display_phone_number + }, + "from": { + "name": $status.recipient_id, + "number": $status.recipient_id + }, + "status": $status.status + } + end + ) + ) + """; + /// /// Deserializes the given JSON string into a instance. /// @@ -28,21 +175,9 @@ public abstract partial record Message(string Id, Service To, User From, long Ti // NOTE: if we got a JQ-transformed payload, deserialization MUST work, or we have a bug. // So we don't try..catch things in that code path. - var jq = await JQ.ExecuteAsync(json, ContentMessage.JQ); - if (!string.IsNullOrEmpty(jq)) - return JsonSerializer.Deserialize(jq, MessageSerializerContext.Default.ContentMessage); - - jq = await JQ.ExecuteAsync(json, InteractiveMessage.JQ); - if (!string.IsNullOrEmpty(jq)) - return JsonSerializer.Deserialize(jq, MessageSerializerContext.Default.InteractiveMessage); - - jq = await JQ.ExecuteAsync(json, ErrorMessage.JQ); - if (!string.IsNullOrEmpty(jq)) - return JsonSerializer.Deserialize(jq, MessageSerializerContext.Default.ErrorMessage); - - jq = await JQ.ExecuteAsync(json, StatusMessage.JQ); + var jq = await Devlooped.JQ.ExecuteAsync(json, JQ); if (!string.IsNullOrEmpty(jq)) - return JsonSerializer.Deserialize(jq, MessageSerializerContext.Default.StatusMessage); + return JsonSerializer.Deserialize(jq, MessageSerializerContext.Default.Message); // NOTE: unsupported payloads would not generate a JQ output, so we can safely ignore them. return default; @@ -51,12 +186,15 @@ public abstract partial record Message(string Id, Service To, User From, long Ti /// /// Gets the type of message. /// + [JsonIgnore] public abstract MessageType Type { get; } [JsonSourceGenerationOptions(JsonSerializerDefaults.Web, WriteIndented = true, UseStringEnumConverter = true)] + [JsonSerializable(typeof(Message))] [JsonSerializable(typeof(ContentMessage))] [JsonSerializable(typeof(ErrorMessage))] [JsonSerializable(typeof(InteractiveMessage))] [JsonSerializable(typeof(StatusMessage))] + [JsonSerializable(typeof(UnsupportedMessage))] partial class MessageSerializerContext : JsonSerializerContext { } } diff --git a/src/WhatsApp/MessageType.cs b/src/WhatsApp/MessageType.cs index 9750a43..1a4e838 100644 --- a/src/WhatsApp/MessageType.cs +++ b/src/WhatsApp/MessageType.cs @@ -21,4 +21,8 @@ public enum MessageType /// Message contains a status update. /// Status, + /// + /// Message type is not supported by the WhatsApp for Business service. + /// + Unsupported, } \ No newline at end of file diff --git a/src/WhatsApp/StatusMessage.cs b/src/WhatsApp/StatusMessage.cs index f65294b..a00f2f1 100644 --- a/src/WhatsApp/StatusMessage.cs +++ b/src/WhatsApp/StatusMessage.cs @@ -1,4 +1,6 @@ -namespace Devlooped.WhatsApp; +using System.Text.Json.Serialization; + +namespace Devlooped.WhatsApp; /// /// A containing a status update. @@ -10,30 +12,8 @@ /// The message status. public record StatusMessage(string Id, Service To, User From, long Timestamp, Status Status) : Message(Id, To, From, Timestamp) { - /// - /// A JQ query that transforms WhatsApp Cloud API JSON into the serialization - /// expected by . - /// - public const string JQ = - """ - .entry[].changes[].value.metadata as $phone | - .entry[].changes[].value.statuses[] | - select(. != null) | { - id: .id, - timestamp: .timestamp | tonumber, - to: { - id: $phone.phone_number_id, - number: $phone.display_phone_number - }, - from: { - name: .recipient_id, - number: .recipient_id - }, - status: .status - } - """; - /// + [JsonIgnore] public override MessageType Type => MessageType.Status; } diff --git a/src/WhatsApp/UnsupportedMessage.cs b/src/WhatsApp/UnsupportedMessage.cs new file mode 100644 index 0000000..d1d49ec --- /dev/null +++ b/src/WhatsApp/UnsupportedMessage.cs @@ -0,0 +1,20 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Devlooped.WhatsApp; + +/// +/// An that notifies of an unsupported message received by +/// the WhatsApp for Business service. +/// +/// The message identifier. +/// The service that received the message from the Cloud API. +/// 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) +{ + /// + [JsonIgnore] + public override MessageType Type => MessageType.Unsupported; +} \ No newline at end of file