Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 44 additions & 1 deletion readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ builder.Services.AddWhatsApp((messages, cancellation) =>
Console.WriteLine($"Error: {error.Error.Message} ({error.Error.Code})");
break;
case InteractiveMessage interactive:
Console.WriteLine($"Interactive: {interactive.Selection.Title} ({interactive.Selection.Id})");
Console.WriteLine($"Interactive: {interactive.Selection.Text} ({interactive.Selection.Value})");
break;
case StatusMessage status:
Console.WriteLine($"Status: {status.Status}");
Expand Down Expand Up @@ -348,6 +348,49 @@ builder.Services.AddWhatsApp<MyWhatsAppHandler>()
.UseConversation();
```

Finally, the pipeline handlers are encouraged to use the `Response` model
for sending messages, instead of invoking the `IWhatsAppClient` directly.
This allows the pipeline to automatically manage the sending and integrate
better with persistence or other cross-cutting concerns, while allowing for
flexible in-progress message generation (i.e. notify user that processing
is ongoing via reactions, etc.).

For example, to send the typing indicator when starting processing messages
in a handler:

```csharp
public override async IAsyncEnumerable<Response> HandleAsync(IEnumerable<IMessage> messages, [EnumeratorCancellation] CancellationToken cancellation = default)
{
foreach (var message in messages)
{
if (message is UserMessage user)
yield return user.Typing();

// Do some processing, send final response

yield return message.Reply("Processing complete!");
}
}
```

The response model supports all the common WhatsApp message types, including
plain text responses, interactive buttons, reactions and templates, such as:

```csharp
yield return message.Template(new MessageTemplate("order", "en")
{
Buttons =
[
// i.e. template button get tracking info for an order
ButtonComponent.Payload(orderId),
// i.e. a template url button to navigate to the order page
ButtonComponent.Url(orderId),
// i.e. a template catalog button to view the WhatsApp business catalog
ButtonComponent.Catalog()
]
});
```

### OpenTelemetry

The configurable built-in support for OpenTelemetry shown above allows tracking
Expand Down
4 changes: 2 additions & 2 deletions src/SampleApp/Sample/ProcessHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ public async IAsyncEnumerable<Response> HandleAsync(IEnumerable<IMessage> messag
}
else if (message is InteractiveMessage interactive)
{
logger.LogWarning("👤 chose {Id} ({Title})", interactive.Selection.Id, interactive.Selection.Title);
yield return interactive.Reply($"👤 chose: {interactive.Selection.Title} ({interactive.Selection.Id})");
logger.LogWarning("👤 chose {Id} ({Title})", interactive.Selection.Value, interactive.Selection.Text);
yield return interactive.Reply($"👤 chose: {interactive.Selection.Text} ({interactive.Selection.Value})");
}
else if (message is ReactionMessage reaction)
{
Expand Down
42 changes: 42 additions & 0 deletions src/Tests/Content/WhatsApp/TemplateButton.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
{
"object": "whatsapp_business_account",
"entry": [
{
"id": "123456789012345",
"changes": [
{
"value": {
"messaging_product": "whatsapp",
"metadata": {
"display_phone_number": "1234567890",
"phone_number_id": "987654321098765"
},
"contacts": [
{
"profile": { "name": "RandomName" },
"wa_id": "1234567890"
}
],
"messages": [
{
"context": {
"from": "1234567890",
"id": "wamid.RandomContextID"
},
"from": "1234567890",
"id": "wamid.RandomMessageID",
"timestamp": "1741288810",
"type": "button",
"button": {
"payload": "id1",
"text": "Track"
}
}
]
},
"field": "messages"
}
]
}
]
}
126 changes: 98 additions & 28 deletions src/Tests/WhatsAppClientTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -165,35 +165,87 @@ public async Task SendsTemplateAsync()
{
var (configuration, client) = Initialize();

await client.SendTemplateAsync(configuration["SendFrom"]!, configuration["SendTo"]!, new
await client.SendTemplateAsync(configuration["SendFrom"]!, configuration["SendTo"]!, new MessageTemplate("reminder", "es")
{
name = "reminder",
language = new
{
code = "es"
},
components = new[]
{
new
{
type = "body",
parameters = new[]
{
new
{
type = "text",
parameter_name = "reminder_text",
text = "Dentista"
},
new
{
type = "text",
parameter_name = "reminder_datetime",
text = "3pm"
}
}
}
}
Body = new BodyComponent([
new TextParameter("🦷", "emoji"),
new TextParameter("Dentista", "text"),
new TextParameter("3pm", "when")
])
});
}

[SecretsFact("Meta:VerifyToken", "SendFrom", "SendTo")]
public async Task SendsTemplateMeetingAsync()
{
var (configuration, client) = Initialize();

await client.SendTemplateAsync(configuration["SendFrom"]!, configuration["SendTo"]!, new MessageTemplate("meeting", "es")
{
Header = new HeaderComponent(new LocationParameter(37.483307, -122.148981, "Pablo Morales", "1 Hacker Way, Menlo Park, CA 94025")),
Body = new BodyComponent(
[
new TextParameter("kzu", "who"),
new TextParameter("office", "where"),
new TextParameter("15'", "when")
])
});
}

[SecretsFact("Meta:VerifyToken", "SendFrom", "SendTo")]
public async Task SendsTemplate2Async()
{
var (configuration, client) = Initialize();

await client.SendTemplateAsync(configuration["SendFrom"]!, configuration["SendTo"]!, new MessageTemplate("reminder2", "en")
{
Header = new HeaderComponent(new LocationParameter(37.483307, -122.148981, "Pablo Morales", "1 Hacker Way, Menlo Park, CA 94025")),
Body = new BodyComponent(
[
new TextParameter("🦷", "emoji"),
new TextParameter("Dentista", "text"),
new TextParameter("3pm", "when")
])
});
}

[SecretsFact("Meta:VerifyToken", "SendFrom", "SendTo")]
public async Task SendsTemplateUrlAsync()
{
var (configuration, client) = Initialize();

await client.SendTemplateAsync(configuration["SendFrom"]!, configuration["SendTo"]!, new MessageTemplate("variables", "en")
{
Header = new HeaderComponent(new TextParameter("kzu", "name")),
Body = new BodyComponent(
[
new TextParameter("dotnet", "tag"),
]),
Buttons =
[
ButtonComponent.Url("dotnet"),
ButtonComponent.Default
]
});
}

[SecretsFact("Meta:VerifyToken", "SendFrom", "SendTo")]
public async Task SendsTemplateButtonsAsync()
{
var (configuration, client) = Initialize();

await client.SendTemplateAsync(configuration["SendFrom"]!, configuration["SendTo"]!, new MessageTemplate("buttons", "en")
{
Buttons =
[
ButtonComponent.Payload("id1"),
//NOTE: we can omit the buttons if we don't need custom payloads for them.
// the webhook will get the payload == button text in that case.
//ButtonComponent.Text("id2"),
// Since we omitted the second button, we'll need to specify the index in this case.
// otherwise, it defaults to its index in the array.
ButtonComponent.Url("dotnet", index: 2),
]
});
}

Expand Down Expand Up @@ -267,6 +319,24 @@ await Assert.ThrowsAsync<NotSupportedException>(() => client.ResolveMediaAsync(
new UnknownContent(new System.Text.Json.JsonElement()))));
}

[SecretsFact("Meta:VerifyToken", "SendFrom", "SendTo")]
public async Task SendsTemplateWithMessageTemplateObjectAsync()
{
var (configuration, client) = Initialize();

// Using the new MessageTemplate object instead of anonymous object
var template = new MessageTemplate("reminder", "es")
{
Body = new BodyComponent([
new TextParameter("🦷", "emoji"),
new TextParameter("Dentista", "text"),
new TextParameter("3pm", "when")
])
};

await client.SendTemplateAsync(configuration["SendFrom"]!, configuration["SendTo"]!, template);
}

record Media(string Url, string MimeType, long FileSize);

(IConfiguration configuration, WhatsAppClient client) Initialize()
Expand Down
Loading