Skip to content

Commit 5100223

Browse files
committed
Add reaction message support, improve type safety
We were not surfacing reaction messages separately. This now allows processing user reactions separately from other message types. Also, type safety when doing Reply and MarkRead wasn't great since users could attempt either operation on a message that doesn't support that kind of interaction (such as status or error messages). So we create two intermediate types, UserMessage (the ones users can interact with, like content and interactive messages), and SystemMessage (reaction, status, error and unsupported). We also fix the sample to properly report the environment which for some reason is not Development despite envvars being set properly by the Azure Functions tools on local run.
1 parent 9108cb0 commit 5100223

21 files changed

+418
-192
lines changed

src/Directory.props

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
<RootNamespace>Devlooped.WhatsApp</RootNamespace>
55
<UserSecretsId>41fc668e-a410-48d4-9884-c2937478d9e1</UserSecretsId>
66
<PackageLicenseExpression>AGPL-3.0-or-later WITH Universal-FOSS-exception-1.0</PackageLicenseExpression>
7+
<ImplicitUsings>true</ImplicitUsings>
78
</PropertyGroup>
89

910
</Project>

src/Sample/Program.cs

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,18 @@
33
using Devlooped.WhatsApp;
44
using Microsoft.Azure.Functions.Worker.Builder;
55
using Microsoft.Extensions.Configuration;
6+
using Microsoft.Extensions.DependencyInjection;
67
using Microsoft.Extensions.Hosting;
78
using Microsoft.Extensions.Logging;
89

910
var builder = FunctionsApplication.CreateBuilder(args);
10-
var options = new JsonSerializerOptions(JsonSerializerDefaults.General)
11+
builder.ConfigureFunctionsWebApplication();
12+
13+
#if DEBUG
14+
builder.Environment.EnvironmentName = "Development";
15+
builder.Configuration.AddUserSecrets<Program>();
16+
#endif
17+
builder.Services.AddSingleton(new JsonSerializerOptions(JsonSerializerDefaults.General)
1118
{
1219
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
1320
Converters =
@@ -20,7 +27,7 @@
2027
builder.ConfigureFunctionsWebApplication();
2128
builder.Configuration.AddUserSecrets<Program>();
2229

23-
builder.UseWhatsApp<IWhatsAppClient, ILogger<Program>>(async (client, logger, message) =>
30+
builder.UseWhatsApp<IWhatsAppClient, ILogger<Program>, JsonSerializerOptions>(async (client, logger, options, message) =>
2431
{
2532
logger.LogInformation("💬 Received message: {Message}", message);
2633

@@ -52,24 +59,31 @@
5259
}
5360
else if (message is InteractiveMessage interactive)
5461
{
55-
logger.LogWarning("👤 User chose button {Button} ({Title})", interactive.Button.Id, interactive.Button.Title);
62+
logger.LogWarning("👤 chose {Button} ({Title})", interactive.Button.Id, interactive.Button.Title);
63+
await client.ReplyAsync(interactive, $"👤 chose: {interactive.Button.Title} ({interactive.Button.Id})");
64+
return;
65+
}
66+
else if (message is ReactionMessage reaction)
67+
{
68+
logger.LogInformation("👤 reaction: {Reaction}", reaction.Emoji);
69+
await client.ReplyAsync(reaction, $"👤 reaction: {reaction.Emoji}");
5670
return;
5771
}
5872
else if (message is StatusMessage status)
5973
{
60-
logger.LogInformation("☑️ New message status: {Status}", status.Status);
74+
logger.LogInformation("☑️ status: {Status}", status.Status);
6175
return;
6276
}
6377
else if (message is ContentMessage content)
6478
{
65-
await client.ReactAsync(message, "🧠");
79+
await client.ReactAsync(content, "🧠");
6680
// simulate some hard work at hand, like doing some LLM-stuff :)
6781
//await Task.Delay(2000);
68-
await client.ReplyAsync(message, $"☑️ Got your {content.Content.Type}:\r\n{JsonSerializer.Serialize(content, options)}");
82+
await client.ReplyAsync(content, $"☑️ Got your {content.Content.Type}:\r\n{JsonSerializer.Serialize(content, options)}");
6983
}
7084
else if (message is UnsupportedMessage unsupported)
7185
{
72-
await client.ReactAsync(message, "⚠️");
86+
logger.LogWarning("⚠️ {Message}", unsupported);
7387
return;
7488
}
7589
});

src/Sample/Sample.csproj

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,6 @@
33
<TargetFramework>net8.0</TargetFramework>
44
<AzureFunctionsVersion>v4</AzureFunctionsVersion>
55
<OutputType>Exe</OutputType>
6-
<ImplicitUsings>enable</ImplicitUsings>
7-
<Nullable>enable</Nullable>
86
</PropertyGroup>
97
<ItemGroup>
108
<FrameworkReference Include="Microsoft.AspNetCore.App" />
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
{
2+
"object": "whatsapp_business_account",
3+
"entry": [
4+
{
5+
"id": "123456789012345",
6+
"changes": [
7+
{
8+
"value": {
9+
"messaging_product": "whatsapp",
10+
"metadata": {
11+
"display_phone_number": "5491234567890",
12+
"phone_number_id": "987654321098765"
13+
},
14+
"contacts": [
15+
{
16+
"profile": { "name": "RandomName" },
17+
"wa_id": "5499876543210"
18+
}
19+
],
20+
"messages": [
21+
{
22+
"from": "5499876543210",
23+
"id": "wamid.HBgNMTIzNDU2Nzg5MDEyMzQ1MhUCABEYEkY5QzQxNDNBQjgyRkVENEIzMQA=",
24+
"timestamp": "1744229999",
25+
"type": "reaction",
26+
"reaction": {
27+
"message_id": "wamid.HBgNMTIzNDU2Nzg5MDEyMzQ1MhUCABEYEkY5QzQxNDNBQjgyRkVENEIzMQA=",
28+
"emoji": "😊"
29+
}
30+
}
31+
]
32+
},
33+
"field": "messages"
34+
}
35+
]
36+
}
37+
]
38+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
{
2+
"object": "whatsapp_business_account",
3+
"entry": [
4+
{
5+
"id": "918273645102347",
6+
"changes": [
7+
{
8+
"value": {
9+
"messaging_product": "whatsapp",
10+
"metadata": {
11+
"display_phone_number": "1209384756109",
12+
"phone_number_id": "739102584617293"
13+
},
14+
"statuses": [
15+
{
16+
"id": "wamid.HBgMMTIwMzQ1Njc4OTAxNxUCABEYEjZFNzI5QzFDNkE5RDg3MjBBNwA=",
17+
"status": "sent",
18+
"timestamp": "1829471036",
19+
"recipient_id": "1203456789012",
20+
"conversation": {
21+
"id": "4a7b9c2d8e5f0136pqr890xy12mn3op",
22+
"origin": { "type": "utility" }
23+
},
24+
"pricing": {
25+
"billable": false,
26+
"pricing_model": "NBP",
27+
"category": "utility"
28+
}
29+
}
30+
]
31+
},
32+
"field": "messages"
33+
}
34+
]
35+
}
36+
]
37+
}

src/Tests/Content/WhatsApp/Text.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@
1919
],
2020
"messages": [
2121
{
22+
"context": {
23+
"from": "12025550123",
24+
"id": "wamid.HBgNNTQ5MTE1OTL4ODI4MhUCBBEYEjUxNDI3NkMzRkI1ODVCRTgwOAA="
25+
},
2226
"from": "12029874563",
2327
"id": "wamid.HBgNMTIwMjk4NzQ1NjM1NhUCABIYFjQ5RjE4QzJEMzU2ODk3QTJFMUY3RDEyMjNBNkI5QwA==",
2428
"timestamp": "1678902345",

src/Tests/WhatsAppClientTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ public async Task SendsMessageAsync()
3131
{
3232
var (configuration, client) = Initialize();
3333

34-
await client.SendAync(configuration["SendFrom"]!, configuration["SendTo"]!, "Hi there!");
34+
await client.SendAsync(configuration["SendFrom"]!, configuration["SendTo"]!, "Hi there!");
3535
}
3636

3737
[SecretsFact("Meta:VerifyToken", "SendFrom", "SendTo")]

src/Tests/WhatsAppModelTests.cs

Lines changed: 41 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using System;
22
using System.Collections.Generic;
33
using System.Linq;
4+
using System.Reflection.Metadata;
45
using System.Text;
56
using System.Text.Json;
67
using System.Threading.Tasks;
@@ -13,48 +14,28 @@ namespace Devlooped.WhatsApp;
1314
public class WhatsAppModelTests(ITestOutputHelper output)
1415
{
1516
[Theory]
16-
[InlineData(
17-
"""
18-
{
19-
"object": "whatsapp_business_account",
20-
"entry": [
21-
{
22-
"id": "554372691093163",
23-
"changes": [
24-
{
25-
"value": {
26-
"messaging_product": "whatsapp",
27-
"metadata": {
28-
"display_phone_number": "5491123960774",
29-
"phone_number_id": "524718744066632"
30-
},
31-
"contacts": [
32-
{
33-
"profile": { "name": "Kzu" },
34-
"wa_id": "5491159278282"
35-
}
36-
],
37-
"messages": [
38-
{
39-
"from": "5491159278282",
40-
"id": "wamid.HBgNNTQ5MTE1OTI3ODI4MhUCABIYFjNFQjBFOEFGODAzMEI4RTI3NzczNjkA",
41-
"timestamp": "1744062742",
42-
"text": { "body": "hello!" },
43-
"type": "text"
44-
}
45-
]
46-
},
47-
"field": "messages"
48-
}
49-
]
50-
}
51-
]
52-
}
53-
""")]
54-
public async Task DeserializePayload(string json)
17+
[InlineData(nameof(ContentType.Audio), "927483105672819", "wamid.XYZRandomString123ABC456DEF789GHI==")]
18+
[InlineData(nameof(ContentType.Contact), "927481035162874", "wamid.HBgNNDcyODkwMTIzNDU2NhUCABIYFjE4QTlDMzU2MkJDOTg3RUY2NDg5RTFEMTIzQzVFRAA==")]
19+
[InlineData(nameof(ContentType.Document), "813947205126374", "wamid.HBgNMTIwMjU1NTk4NzY1NhUCABIYFjE4QTlDMzU2MkJDOTg3RUY2NDg5RTFEMTIzQzVFRAA==")]
20+
[InlineData(nameof(ContentType.Image), "813927405162784", "wamid.HBgNMTIwMjU1NTk4NzY1NhUCABIYFjE4QTlDMzU2MkJDOTg3RUY2NDg5RTFEMTIzQzVFRAA==")]
21+
[InlineData(nameof(ContentType.Location), "813920475601234", "wamid.HBgNMTIwMjk4NzQ1NjM1NhUCABIYFjE5RDhGMzQ2NEJDOTg3RUY2NDg5RTFEMTIzQzVFRAA==")]
22+
[InlineData(nameof(ContentType.Text), "813920475102346", "wamid.HBgNMTIwMjk4NzQ1NjM1NhUCABIYFjQ5RjE4QzJEMzU2ODk3QTJFMUY3RDEyMjNBNkI5QwA==", "wamid.HBgNNTQ5MTE1OTL4ODI4MhUCBBEYEjUxNDI3NkMzRkI1ODVCRTgwOAA=")]
23+
[InlineData(nameof(ContentType.Video), "813927405162374", "wamid.HBgNMTIwMjU1NTk4NzY1NhUCABIYFjE4QTlDMzU2MkJDOTg3RUY2NDg5RTFEMTIzQzVFRAA==")]
24+
[InlineData(nameof(MessageType.Unsupported), "837625914708254", "wamid.HBgNNTQ5MzcyNjEwNDg1OVUCABIYFjJCRDM5RTg0QkY3OEQxMjM2RkE0QjcA")]
25+
[InlineData(nameof(MessageType.Error), "729104583621947", "wamid.XYZgMDEyMzQ1Njc4OTA5MRUCABEYEjU5NkM3ODlFQjAxMjM0NTY7OA==")]
26+
[InlineData(nameof(MessageType.Interactive), "123456789012345", "wamid.RandomMessageID", "wamid.RandomContextID")]
27+
[InlineData(nameof(MessageType.Reaction), "123456789012345", "wamid.HBgNMTIzNDU2Nzg5MDEyMzQ1MhUCABEYEkY5QzQxNDNBQjgyRkVENEIzMQA=", "wamid.HBgNMTIzNDU2Nzg5MDEyMzQ1MhUCABEYEkY5QzQxNDNBQjgyRkVENEIzMQA=")]
28+
// For consistency, status message ID == status context ID.
29+
[InlineData(nameof(MessageType.Status), "987654321098765", "wamid.HBgNNTQ5OTg3NjU0MzIxMDlUCABEYEkLMNVzNDU2Nzg5MAA=", "wamid.HBgNNTQ5OTg3NjU0MzIxMDlUCABEYEkLMNVzNDU2Nzg5MAA=")]
30+
public async Task DeserializeMessage(string type, string notification, string id, string? context = default)
5531
{
32+
var json = await File.ReadAllTextAsync($"Content/WhatsApp/{type}.json");
5633
var message = await Message.DeserializeAsync(json);
34+
5735
Assert.NotNull(message);
36+
Assert.Equal(notification, message.NotificationId);
37+
Assert.Equal(id, message.Id);
38+
Assert.Equal(context, message.Context);
5839
Assert.NotNull(message.To);
5940
Assert.NotNull(message.From);
6041
}
@@ -67,14 +48,15 @@ public async Task DeserializePayload(string json)
6748
[InlineData(ContentType.Location)]
6849
[InlineData(ContentType.Text)]
6950
[InlineData(ContentType.Video)]
70-
public async Task DeserializePolymorphic(ContentType type)
51+
public async Task DeserializeContent(ContentType type)
7152
{
7253
var json = await File.ReadAllTextAsync($"Content/WhatsApp/{type}.json");
7354
var message = await Message.DeserializeAsync(json);
7455

7556
var content = Assert.IsType<ContentMessage>(message);
7657

7758
Assert.NotNull(message);
59+
Assert.NotNull(message.NotificationId);
7860
Assert.NotNull(message.To);
7961
Assert.NotNull(message.From);
8062
Assert.NotNull(content.Content);
@@ -90,6 +72,7 @@ public async Task DeserializeErrorStatus()
9072
var error = Assert.IsType<ErrorMessage>(message);
9173

9274
Assert.NotNull(message);
75+
Assert.NotNull(message.NotificationId);
9376
Assert.NotNull(message.To);
9477
Assert.NotNull(message.From);
9578
Assert.NotNull(error.Error);
@@ -105,6 +88,7 @@ public async Task DeserializeStatus()
10588
var status = Assert.IsType<StatusMessage>(message);
10689

10790
Assert.NotNull(message);
91+
Assert.NotNull(message.NotificationId);
10892
Assert.NotNull(message.To);
10993
Assert.NotNull(message.From);
11094
Assert.Equal(Status.Delivered, status.Status);
@@ -119,6 +103,7 @@ public async Task DeserializeInteractive()
119103
var interactive = Assert.IsType<InteractiveMessage>(message);
120104

121105
Assert.NotNull(message);
106+
Assert.NotNull(message.NotificationId);
122107
Assert.NotNull(message.To);
123108
Assert.NotNull(message.From);
124109
Assert.Equal("btn_yes", interactive.Button.Id);
@@ -134,7 +119,23 @@ public async Task DeserializeUnsupported()
134119
var unsupported = Assert.IsType<UnsupportedMessage>(message);
135120

136121
Assert.NotNull(message);
122+
Assert.NotNull(message.NotificationId);
123+
Assert.NotNull(message.To);
124+
Assert.NotNull(message.From);
125+
}
126+
127+
[Fact]
128+
public async Task DeserializeReaction()
129+
{
130+
var json = await File.ReadAllTextAsync($"Content/WhatsApp/Reaction.json");
131+
var message = await Message.DeserializeAsync(json);
132+
133+
var reaction = Assert.IsType<ReactionMessage>(message);
134+
135+
Assert.NotNull(message);
136+
Assert.NotNull(message.NotificationId);
137137
Assert.NotNull(message.To);
138138
Assert.NotNull(message.From);
139+
Assert.Equal("😊", reaction.Emoji);
139140
}
140141
}

0 commit comments

Comments
 (0)