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
68 changes: 67 additions & 1 deletion readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,34 @@ if (message is ContentMessage content)
This allows the handler to remain decoupled from the actual sending of messages, making it
easier to unit test.

There's no limitation on the number of responses you can yield during processing, but
sending an intermediate reply will cause the typing indicator sent by default by the
webhook to dissapear. To signal ongoing processing to the user, you can send the typing
status response as follows:

```csharp
yield return content.Reply("Spinning my digital neurons...");
yield return content.Typing();

// simulate some hard work at hand, like doing some LLM-stuff :)
await Task.Delay(2000);

yield return content.Reply("That was tough, but here's your reponse: ... ");
}
```

In this case, the typing status will be restored right after the initial reply.

This is how the initial typing status looks like when the webhook gets the message:

![](https://raw.githubusercontent.com/devlooped/WhatsApp/main/assets/img/progress1.png)

And after we send the "Spinning..." message, the restored typing status would
look like the following:

![](https://raw.githubusercontent.com/devlooped/WhatsApp/main/assets/img/progress2.png)


If sending messages outside the handler pipeline is needed, you can use the provided
`IWhatsAppClient`, which is a very thin abstraction allowing you to send arbitrary payloads
to WhatsApp for Business:
Expand All @@ -117,10 +145,12 @@ if (message is ContentMessage content)
}
```

The above code would render as follows in WhatsApp:
Regardless of the approach used (handler-generated reponse async enumerable or direct
client calls), the above examples would render as follows in WhatsApp:

![](https://raw.githubusercontent.com/devlooped/WhatsApp/main/assets/img/whatsapp.png)


## Conversations

WhatsApp does not provide a way to keep track of conversations, at most providing the
Expand Down Expand Up @@ -201,6 +231,42 @@ corresponding access token for it. To get a permanent access token for
use, you'd need to create a [system user](https://business.facebook.com/latest/settings/system_users)
with full control permissions to the WhatsApp Business API (app).

You can also configure how the WhatsApp webhook and processing pipeline behaves by passing
in an additional delegate to the `AddWhatsApp` method via the `configure` parameter:

```csharp
builder.Services
.AddWhatsApp<ProcessHandler>(configure: options =>
{
options.ReactOnMessage = "🌐";
options.ReactOnProcess = "⚙️";
options.ReactOnConversation = "💭";
})
```

The `WhatsAppOptions` passed in can also be set in configuration, which will be read
automatically when the `AddWhatsApp` method is called, so the following configuration
is equivalent to the above:

```json
{
"WhatsApp": {
"ReactOnMessage": "🌐",
"ReactOnProcess": "⚙️",
"ReactOnConversation": "💭"
}
}
```

By default, the library will mark messages read on webhook invocation by WhatsApp,
and send the typing status to the user:

![](https://raw.githubusercontent.com/devlooped/WhatsApp/main/assets/img/typing.png)

You can modify this behavior through the `WhatsAppOptions` as well, with the
`TypingOnMessage` and `TypingOnProcess` properties. Sending the typing status implies
marking the message as read too.

## Functionality pipelines

`IWhatsAppHandler` instances can be layered to form a pipeline of components, each
Expand Down
3 changes: 3 additions & 0 deletions src/SampleApp/Sample/ProcessHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ public async IAsyncEnumerable<Response> HandleAsync(IEnumerable<IMessage> messag
{
yield return content.React("🧠");

yield return content.Reply("Spinning my digital neurons...");
yield return content.Typing();

// simulate some hard work at hand, like doing some LLM-stuff :)
await Task.Delay(2000);

Expand Down
20 changes: 19 additions & 1 deletion src/SampleApp/Sample/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Azure.Messaging.EventGrid;
using Azure.Storage.Queues;
using Devlooped;
using Devlooped.WhatsApp;
using Microsoft.Azure.Functions.Worker.Builder;
Expand Down Expand Up @@ -67,7 +68,24 @@
new Uri(topic), new Azure.AzureKeyCredential(key)));
}

builder.Build().Run();
var app = builder.Build();

#region DebugInit
#if DEBUG
async Task InitAsync(QueueClient queue)
{
await queue.CreateIfNotExistsAsync();
await queue.ClearMessagesAsync();
}
// Create and clear queues locally so we don't get constant warnings in the logs
var queues = app.Services.GetRequiredService<QueueServiceClient>();
await InitAsync(queues.GetQueueClient("whatsappwebhook"));
await InitAsync(queues.GetQueueClient("whatsappmessages"));
await InitAsync(queues.GetQueueClient("whatsappmemory"));
#endif
#endregion

app.Run();

static async IAsyncEnumerable<Response> EchoAndHandle(IEnumerable<IMessage> messages, IWhatsAppHandler inner, [EnumeratorCancellation] CancellationToken cancellation)
{
Expand Down
2 changes: 1 addition & 1 deletion src/Tests/PipelineTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ public async Task ConversationRestored()
.Build();

var storage = new MemoryConversationStorage();
var response = AsyncEnum<Response>([new TextResponse(user.Number, service.Id, "1234", null, "Bye")]);
var response = AsyncEnum<Response>([new TextResponse(service.Id, user.Number, "1234", null, "Bye")]);
var messages = new List<IMessage[]>();

var handler = new Mock<IWhatsAppHandler>();
Expand Down
5 changes: 4 additions & 1 deletion src/WhatsApp/ConversationHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,10 @@ public override async IAsyncEnumerable<Response> HandleAsync(IEnumerable<IMessag

await foreach (var response in base.HandleAsync(conversation, cancellation))
{
await storage.SaveAsync(response, cancellation);
// We don't care about typing status messages for conversation storage
if (response is not TypingResponse)
await storage.SaveAsync(response, cancellation);

yield return response;
}
}
Expand Down
1 change: 1 addition & 0 deletions src/WhatsApp/IMessage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ namespace Devlooped.WhatsApp;
[JsonDerivedType(typeof(TextResponse), "response/text")]
[JsonDerivedType(typeof(TemplateResponse), "response/template")]
[JsonDerivedType(typeof(ReactionResponse), "response/reaction")]
[JsonDerivedType(typeof(TypingResponse), "response/typing")]
public interface IMessage
{
/// <summary>Gets or sets any additional properties associated with the message.</summary>
Expand Down
25 changes: 20 additions & 5 deletions src/WhatsApp/MessageExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ public static partial class MessageExtensions
{
extension(IMessage message)
{
/// <summary>
/// Gets or sets whether the message was sent from the WhatsApp CLI.
/// </summary>
public bool FromConsole
{
get => (message.AdditionalProperties ??= []).TryGetValue("FromConsole", out var value) ? value as bool? ?? default : default;
Expand All @@ -20,13 +23,13 @@ public bool FromConsole
/// Creates a reaction response for the user message.
/// </summary>
public static ReactionResponse React(this IMessage message, string emoji)
=> new(message.UserNumber, message.ServiceId, message.Id, message.ConversationId, emoji);
=> new(message.ServiceId, message.UserNumber, message.Id, message.ConversationId, emoji);

/// <summary>
/// Creates a simple template response for the message.
/// </summary>
public static TemplateResponse Template(this IMessage message, string name, string language)
=> new(message.UserNumber, message.Id, message.Id, message.ConversationId, name, language);
=> new(message.ServiceId, message.UserNumber, message.Id, message.ConversationId, name, language);

/// <summary>
/// Creates a complex template response for the message.
Expand All @@ -37,19 +40,31 @@ public static TemplateResponse Template(this IMessage message, string name, stri
/// <see cref="https://developers.facebook.com/docs/whatsapp/cloud-api/reference/messages/#template-object"/>
/// <see cref="https://developers.facebook.com/docs/whatsapp/cloud-api/reference/messages/#components-object"/>
public static TemplateResponse Template(this IMessage message, object template)
=> new(message.UserNumber, message.ServiceId, message.Id, message.ConversationId, template);
=> new(message.ServiceId, message.UserNumber, message.Id, message.ConversationId, template);

/// <summary>
/// Sends a typing indicator status to signal that there is an ongoing response to the user message.
/// </summary>
public static TypingResponse Typing(this UserMessage message)
=> new(message.Service.Id, message.User.Number, message.Id, message.ConversationId);

/// <summary>
/// Creates a text response for the message.
/// </summary>
public static TextResponse Reply(this IMessage message, string text)
=> new(message.UserNumber, message.ServiceId, message.Id, message.ConversationId, text);
=> new(message.ServiceId, message.UserNumber, message.Id, message.ConversationId, text);

/// <summary>
/// Creates a text response with buttons for the message.
/// </summary>
public static TextResponse Reply(this IMessage message, string text, Button button1, Button? button2 = default)
=> new(message.UserNumber, message.ServiceId, message.Id, message.ConversationId, text, button1, button2);
=> new(message.ServiceId, message.UserNumber, message.Id, message.ConversationId, text, button1, button2);

/// <summary>
/// Creates a reaction response for the user message.
/// </summary>
public static TypingResponse Typing(this UserMessage message, string emoji)
=> new(message.Service.Id, message.User.Number, message.Id, message.ConversationId);

/// <summary>
/// Attempts to retrieve a single message from the specified collection.
Expand Down
6 changes: 3 additions & 3 deletions src/WhatsApp/ReactionResponse.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@
/// </summary>
/// <remarks>This response is used to react to a message by sending an emoji. The reaction is associated with a
/// specific message identified by the <see cref="Context"/> property in the context of a conversation.</remarks>
/// <param name="Number">The phone number of the recipient in international format.</param>
/// <param name="Service">The identifier of the service handling the message.</param>
/// <param name="ServiceId">The identifier of the service handling the message.</param>
/// <param name="UserNumber">The phone number of the recipient in international format.</param>
/// <param name="Context">The unique identifier of the message to which the reaction is being sent.</param>
/// <param name="Emoji">The emoji representing the reaction to the message.</param>
public record ReactionResponse(string Number, string Service, string Context, string? ConversationId, string Emoji) : Response(Number, Service, Context, ConversationId)
public record ReactionResponse(string ServiceId, string UserNumber, string Context, string? ConversationId, string Emoji) : Response(ServiceId, UserNumber, Context, ConversationId)
{
/// <inheritdoc/>
protected override async Task<string?> SendCoreAsync(IWhatsAppClient client, CancellationToken cancellationToken = default)
Expand Down
10 changes: 4 additions & 6 deletions src/WhatsApp/Response.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,12 @@ namespace Devlooped.WhatsApp;
/// Represents a response message or command that can be sent using a WhatsApp client.
/// </summary>
/// <remarks>This abstract record serves as a base type for defining specific response messages or commands that
/// can be sent to a WhatsApp client. It provides common properties such as <see cref="UserNumber"/>, <see cref="ServiceId"/>,
/// <see cref="Context"/>, and <see cref="ConversationId"/>, as well as methods for sending the response
/// asynchronously.</remarks>
/// <param name="Number">The phone number of the recipient in international format.</param>
/// <param name="ServiceId">The identifier of the service handling the message.</param>
/// can be sent through WhatsApp client.</remarks>
/// <param name="ServiceId">The identifier of the service to use to send the response through.</param>
/// <param name="UserNumber">The phone number of the recipient in international format.</param>
/// <param name="Context">The unique identifier of the message to which the reaction is being sent.</param>
/// <param name="ConversationId">The conversation id where this response was generated</param>
public abstract partial record Response(string UserNumber, string ServiceId, string Context, string? ConversationId) : IMessage
public abstract partial record Response(string ServiceId, string UserNumber, string Context, string? ConversationId) : IMessage
{
/// <inheritdoc/>
[JsonConverter(typeof(AdditionalPropertiesDictionaryConverter))]
Expand Down
10 changes: 5 additions & 5 deletions src/WhatsApp/TemplateResponse.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,17 @@
/// <remarks>This response is used to send a template message to a recipient's number using the specified service.
/// The template is identified by its name and code. The <see cref="SendCoreAsync"/> method handles the actual sending
/// of the template message.</remarks>
/// <param name="Number">The phone number of the recipient in international format.</param>
/// <param name="Service">The identifier of the service handling the message.</param>
/// <param name="ServiceId">The identifier of the service handling the message.</param>
/// <param name="UserNumber">The phone number of the recipient in international format.</param>
/// <param name="Context">The unique identifier of the message to which the reaction is being sent.</param>
/// <param name="Name">The template name</param>
/// <param name="Language">The template language code (i.e. 'es_AR')</param>
/// <see cref="https://developers.facebook.com/docs/whatsapp/api/messages/message-templates#supported-languages"/>
/// <see cref="https://developers.facebook.com/docs/whatsapp/cloud-api/reference/messages/#template-object"/>
public record TemplateResponse(string Number, string Service, string Context, string? ConversationId, object Template) : Response(Number, Service, Context, ConversationId)
public record TemplateResponse(string ServiceId, string UserNumber, string Context, string? ConversationId, object Template) : Response(ServiceId, UserNumber, Context, ConversationId)
{
public TemplateResponse(string Number, string Service, string Context, string? ConversationId, string Name, string Language)
: this(Number, Service, Context, ConversationId, new { name = Name, language = new { code = Language } })
public TemplateResponse(string ServiceId, string UserNumber, string Context, string? ConversationId, string Name, string Language)
: this(UserNumber, ServiceId, Context, ConversationId, new { name = Name, language = new { code = Language } })
{
}

Expand Down
6 changes: 3 additions & 3 deletions src/WhatsApp/TextResponse.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@
/// </summary>
/// <remarks>This response type allows sending a text message with up to two optional buttons for user
/// interaction. If no buttons are provided, the response will consist of only the text message.</remarks>
/// <param name="Number">The phone number of the recipient in international format.</param>
/// <param name="Service">The identifier of the service handling the message.</param>
/// <param name="ServiceId">The identifier of the service handling the message.</param>
/// <param name="UserNumber">The phone number of the recipient in international format.</param>
/// <param name="Context">The unique identifier of the message to which this response is a reply to .</param>
/// <param name="Text">The text content of the response message.</param>
/// <param name="Button1">An optional button to include in the response for user interaction.</param>
/// <param name="Button2">An optional second button to include in the response for user interaction.</param>
public record TextResponse(string Number, string Service, string Context, string? ConversationId, string Text, Button? Button1 = default, Button? Button2 = default) : Response(Number, Service, Context, ConversationId)
public record TextResponse(string ServiceId, string UserNumber, string Context, string? ConversationId, string Text, Button? Button1 = default, Button? Button2 = default) : Response(ServiceId, UserNumber, Context, ConversationId)
{
/// <inheritdoc/>
protected override Task<string?> SendCoreAsync(IWhatsAppClient client, CancellationToken cancellationToken = default)
Expand Down
16 changes: 16 additions & 0 deletions src/WhatsApp/TypingResponse.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@

namespace Devlooped.WhatsApp;

/// <summary>
/// Represents a typing status update that can be sent in response to a user message.
/// </summary>
/// <see cref="https://developers.facebook.com/docs/whatsapp/cloud-api/typing-indicators"/>
public record TypingResponse(string ServiceId, string UserNumber, string Context, string? ConversationId) : Response(ServiceId, UserNumber, Context, ConversationId)
{
protected override async Task<string?> SendCoreAsync(IWhatsAppClient client, CancellationToken cancellation = default)
{
await client.SendTyping(ServiceId, Context, cancellation);
// These types of messages don't actually have an ID.
return Ulid.NewUlid().ToString();
}
}
11 changes: 1 addition & 10 deletions src/WhatsApp/UserMessageExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,7 @@ public static async Task SendProgress(this UserMessage message, IWhatsAppClient
{
if (sendTyping is true)
{
await client.SendAsync(message.Service.Id, new
{
messaging_product = "whatsapp",
status = "read",
message_id = message.Id,
typing_indicator = new
{
type = "text"
}
}).Ignore();
await client.SendTyping(message).Ignore();
}
else if (markRead)
{
Expand Down
30 changes: 30 additions & 0 deletions src/WhatsApp/WhatsAppClientExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,36 @@ public static Task SendTemplateAsync(this IWhatsAppClient client, string service
}
}, cancellation);

/// <summary>
/// Sends a typing indicator in response to a user message, marking it as read too.
/// </summary>
/// <param name="client">The WhatsApp client.</param>
/// <param name="message">The message to mark as read and send typing indicator for.</param>
/// <param name="cancellation">The cancellation token.</param>
/// <see cref="https://developers.facebook.com/docs/whatsapp/cloud-api/typing-indicators"/>
public static Task SendTyping(this IWhatsAppClient client, UserMessage message, CancellationToken cancellation = default)
=> SendTyping(client, message.Service.Id, message.Id, cancellation);

/// <summary>
/// Sends a typing indicator in response to a user message, marking it as read too.
/// </summary>
/// <param name="client">The WhatsApp client.</param>
/// <param name="serviceId">The service number to send the typing indicator through.</param>
/// <param name="messageId">The identifier of the message to mark as read and send typing indicator for.</param>
/// <param name="cancellation">The cancellation token.</param>
/// <see cref="https://developers.facebook.com/docs/whatsapp/cloud-api/typing-indicators"/>
public static Task SendTyping(this IWhatsAppClient client, string serviceId, string messageId, CancellationToken cancellation = default)
=> client.SendAsync(serviceId, new
{
messaging_product = "whatsapp",
status = "read",
message_id = messageId,
typing_indicator = new
{
type = "text"
}
}, cancellation);

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.
Expand Down