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