diff --git a/readme.md b/readme.md index 64752e1..162d773 100644 --- a/readme.md +++ b/readme.md @@ -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: @@ -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 @@ -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(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 diff --git a/src/SampleApp/Sample/ProcessHandler.cs b/src/SampleApp/Sample/ProcessHandler.cs index 885db76..09896e4 100644 --- a/src/SampleApp/Sample/ProcessHandler.cs +++ b/src/SampleApp/Sample/ProcessHandler.cs @@ -44,6 +44,9 @@ public async IAsyncEnumerable HandleAsync(IEnumerable 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); diff --git a/src/SampleApp/Sample/Program.cs b/src/SampleApp/Sample/Program.cs index 3ae1381..d5b84cc 100644 --- a/src/SampleApp/Sample/Program.cs +++ b/src/SampleApp/Sample/Program.cs @@ -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; @@ -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(); +await InitAsync(queues.GetQueueClient("whatsappwebhook")); +await InitAsync(queues.GetQueueClient("whatsappmessages")); +await InitAsync(queues.GetQueueClient("whatsappmemory")); +#endif +#endregion + +app.Run(); static async IAsyncEnumerable EchoAndHandle(IEnumerable messages, IWhatsAppHandler inner, [EnumeratorCancellation] CancellationToken cancellation) { diff --git a/src/Tests/PipelineTests.cs b/src/Tests/PipelineTests.cs index 7bf76ab..c9c99d5 100644 --- a/src/Tests/PipelineTests.cs +++ b/src/Tests/PipelineTests.cs @@ -161,7 +161,7 @@ public async Task ConversationRestored() .Build(); var storage = new MemoryConversationStorage(); - var response = AsyncEnum([new TextResponse(user.Number, service.Id, "1234", null, "Bye")]); + var response = AsyncEnum([new TextResponse(service.Id, user.Number, "1234", null, "Bye")]); var messages = new List(); var handler = new Mock(); diff --git a/src/WhatsApp/ConversationHandler.cs b/src/WhatsApp/ConversationHandler.cs index 7db858c..2fc279c 100644 --- a/src/WhatsApp/ConversationHandler.cs +++ b/src/WhatsApp/ConversationHandler.cs @@ -36,7 +36,10 @@ public override async IAsyncEnumerable HandleAsync(IEnumerableGets or sets any additional properties associated with the message. diff --git a/src/WhatsApp/MessageExtensions.cs b/src/WhatsApp/MessageExtensions.cs index c9b5d97..fbf24cd 100644 --- a/src/WhatsApp/MessageExtensions.cs +++ b/src/WhatsApp/MessageExtensions.cs @@ -9,6 +9,9 @@ public static partial class MessageExtensions { extension(IMessage message) { + /// + /// Gets or sets whether the message was sent from the WhatsApp CLI. + /// public bool FromConsole { get => (message.AdditionalProperties ??= []).TryGetValue("FromConsole", out var value) ? value as bool? ?? default : default; @@ -20,13 +23,13 @@ public bool FromConsole /// Creates a reaction response for the user message. /// 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); /// /// Creates a simple template response for the message. /// 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); /// /// Creates a complex template response for the message. @@ -37,19 +40,31 @@ public static TemplateResponse Template(this IMessage message, string name, stri /// /// 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); + + /// + /// Sends a typing indicator status to signal that there is an ongoing response to the user message. + /// + public static TypingResponse Typing(this UserMessage message) + => new(message.Service.Id, message.User.Number, message.Id, message.ConversationId); /// /// Creates a text response for the message. /// 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); /// /// Creates a text response with buttons for the message. /// 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); + + /// + /// Creates a reaction response for the user message. + /// + public static TypingResponse Typing(this UserMessage message, string emoji) + => new(message.Service.Id, message.User.Number, message.Id, message.ConversationId); /// /// Attempts to retrieve a single message from the specified collection. diff --git a/src/WhatsApp/ReactionResponse.cs b/src/WhatsApp/ReactionResponse.cs index 800d239..5c20f37 100644 --- a/src/WhatsApp/ReactionResponse.cs +++ b/src/WhatsApp/ReactionResponse.cs @@ -5,11 +5,11 @@ /// /// This response is used to react to a message by sending an emoji. The reaction is associated with a /// specific message identified by the property in the context of a conversation. -/// The phone number of the recipient in international format. -/// The identifier of the service handling the message. +/// The identifier of the service handling the message. +/// The phone number of the recipient in international format. /// The unique identifier of the message to which the reaction is being sent. /// The emoji representing the reaction to the message. -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) { /// protected override async Task SendCoreAsync(IWhatsAppClient client, CancellationToken cancellationToken = default) diff --git a/src/WhatsApp/Response.cs b/src/WhatsApp/Response.cs index 8ea745a..5ed1529 100644 --- a/src/WhatsApp/Response.cs +++ b/src/WhatsApp/Response.cs @@ -7,14 +7,12 @@ namespace Devlooped.WhatsApp; /// Represents a response message or command that can be sent using a WhatsApp client. /// /// 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 , , -/// , and , as well as methods for sending the response -/// asynchronously. -/// The phone number of the recipient in international format. -/// The identifier of the service handling the message. +/// can be sent through WhatsApp client. +/// The identifier of the service to use to send the response through. +/// The phone number of the recipient in international format. /// The unique identifier of the message to which the reaction is being sent. /// The conversation id where this response was generated -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 { /// [JsonConverter(typeof(AdditionalPropertiesDictionaryConverter))] diff --git a/src/WhatsApp/TemplateResponse.cs b/src/WhatsApp/TemplateResponse.cs index b5035e7..e9e19e1 100644 --- a/src/WhatsApp/TemplateResponse.cs +++ b/src/WhatsApp/TemplateResponse.cs @@ -6,17 +6,17 @@ /// 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 method handles the actual sending /// of the template message. -/// The phone number of the recipient in international format. -/// The identifier of the service handling the message. +/// The identifier of the service handling the message. +/// The phone number of the recipient in international format. /// The unique identifier of the message to which the reaction is being sent. /// The template name /// The template language code (i.e. 'es_AR') /// /// -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 } }) { } diff --git a/src/WhatsApp/TextResponse.cs b/src/WhatsApp/TextResponse.cs index 7282342..f68088c 100644 --- a/src/WhatsApp/TextResponse.cs +++ b/src/WhatsApp/TextResponse.cs @@ -5,13 +5,13 @@ /// /// 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. -/// The phone number of the recipient in international format. -/// The identifier of the service handling the message. +/// The identifier of the service handling the message. +/// The phone number of the recipient in international format. /// The unique identifier of the message to which this response is a reply to . /// The text content of the response message. /// An optional button to include in the response for user interaction. /// An optional second button to include in the response for user interaction. -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) { /// protected override Task SendCoreAsync(IWhatsAppClient client, CancellationToken cancellationToken = default) diff --git a/src/WhatsApp/TypingResponse.cs b/src/WhatsApp/TypingResponse.cs new file mode 100644 index 0000000..8db9efb --- /dev/null +++ b/src/WhatsApp/TypingResponse.cs @@ -0,0 +1,16 @@ + +namespace Devlooped.WhatsApp; + +/// +/// Represents a typing status update that can be sent in response to a user message. +/// +/// +public record TypingResponse(string ServiceId, string UserNumber, string Context, string? ConversationId) : Response(ServiceId, UserNumber, Context, ConversationId) +{ + protected override async Task 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(); + } +} diff --git a/src/WhatsApp/UserMessageExtensions.cs b/src/WhatsApp/UserMessageExtensions.cs index 8efd4c7..6655358 100644 --- a/src/WhatsApp/UserMessageExtensions.cs +++ b/src/WhatsApp/UserMessageExtensions.cs @@ -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) { diff --git a/src/WhatsApp/WhatsAppClientExtensions.cs b/src/WhatsApp/WhatsAppClientExtensions.cs index 604d795..8ebbca3 100644 --- a/src/WhatsApp/WhatsAppClientExtensions.cs +++ b/src/WhatsApp/WhatsAppClientExtensions.cs @@ -472,6 +472,36 @@ public static Task SendTemplateAsync(this IWhatsAppClient client, string service } }, cancellation); + /// + /// Sends a typing indicator in response to a user message, marking it as read too. + /// + /// The WhatsApp client. + /// The message to mark as read and send typing indicator for. + /// The cancellation token. + /// + public static Task SendTyping(this IWhatsAppClient client, UserMessage message, CancellationToken cancellation = default) + => SendTyping(client, message.Service.Id, message.Id, cancellation); + + /// + /// Sends a typing indicator in response to a user message, marking it as read too. + /// + /// The WhatsApp client. + /// The service number to send the typing indicator through. + /// The identifier of the message to mark as read and send typing indicator for. + /// The cancellation token. + /// + 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.