From 161602d213c7c6ffabd83cc0afb5e06da9f31ecb Mon Sep 17 00:00:00 2001 From: Adrian Alonso Date: Thu, 29 May 2025 12:52:57 -0300 Subject: [PATCH 1/8] Added capability for storing incoming/outgoing messages --- src/SampleApp/Sample/Program.cs | 10 +++- src/WhatsApp/AzureFunctions.cs | 8 ++-- src/WhatsApp/IStorageService.cs | 44 ++++++++++++++++++ src/WhatsApp/Message.cs | 1 + src/WhatsApp/MessageStorageHandler.cs | 29 ++++++++++++ src/WhatsApp/MessageType.cs | 2 +- src/WhatsApp/ReactionResponse.cs | 2 +- src/WhatsApp/Response.cs | 40 +++++++++++++--- src/WhatsApp/ResponseContentMessage.cs | 10 ++++ src/WhatsApp/ResponseStorageHandler.cs | 27 +++++++++++ src/WhatsApp/SendResponsesHandler.cs | 24 ++++++++++ src/WhatsApp/StorageHandlerExtensions.cs | 19 ++++++++ src/WhatsApp/StorageService.cs | 46 +++++++++++++++++++ src/WhatsApp/TemplateResponse.cs | 18 ++++++-- src/WhatsApp/TextResponse.cs | 28 +++++++---- src/WhatsApp/UserMessage.cs | 2 +- src/WhatsApp/WhatsApp.csproj | 7 ++- src/WhatsApp/WhatsAppHandlerBuilder.cs | 10 +++- .../WhatsAppServiceCollectionExtensions.cs | 35 +++++++++++++- 19 files changed, 328 insertions(+), 34 deletions(-) create mode 100644 src/WhatsApp/IStorageService.cs create mode 100644 src/WhatsApp/MessageStorageHandler.cs create mode 100644 src/WhatsApp/ResponseContentMessage.cs create mode 100644 src/WhatsApp/ResponseStorageHandler.cs create mode 100644 src/WhatsApp/SendResponsesHandler.cs create mode 100644 src/WhatsApp/StorageHandlerExtensions.cs create mode 100644 src/WhatsApp/StorageService.cs diff --git a/src/SampleApp/Sample/Program.cs b/src/SampleApp/Sample/Program.cs index 8694e6d..6c5d9c1 100644 --- a/src/SampleApp/Sample/Program.cs +++ b/src/SampleApp/Sample/Program.cs @@ -1,6 +1,7 @@ using System.Runtime.CompilerServices; using System.Text.Json; using System.Text.Json.Serialization; +using Devlooped; using Devlooped.WhatsApp; using Microsoft.Azure.Functions.Worker.Builder; using Microsoft.Extensions.Configuration; @@ -34,11 +35,18 @@ WriteIndented = true }); +builder.Services.AddSingleton(services => builder.Environment.IsDevelopment() ? + CloudStorageAccount.DevelopmentStorageAccount : + CloudStorageAccount.TryParse(builder.Configuration["App:Storage"] ?? "", out var storage) ? + storage : + throw new InvalidOperationException("Missing required App:Storage connection string.")); + builder.Services .AddWhatsApp, JsonSerializerOptions>(ProcessMessagesAsync) // Matches what we use in ConfigureOpenTelemetry .UseOpenTelemetry(builder.Environment.ApplicationName) - .UseLogging(); + .UseLogging() + .UseStorage(); builder.Build().Run(); diff --git a/src/WhatsApp/AzureFunctions.cs b/src/WhatsApp/AzureFunctions.cs index ff80f78..91ae907 100644 --- a/src/WhatsApp/AzureFunctions.cs +++ b/src/WhatsApp/AzureFunctions.cs @@ -90,11 +90,9 @@ public async Task Process([QueueTrigger("whatsapp", Connection = "AzureWebJobsSt return; } - // Send responses - await foreach (var response in handler.HandleAsync([message])) - { - await response.SendAsync(whatsapp); - } + // Await all responses + // No action needed, just make sure all items are processed + await handler.HandleAsync([message]).ToArrayAsync(); await table.UpsertEntityAsync(new TableEntity(message.From.Number, message.Id)); logger.LogInformation($"Completed work item: {message.Id}"); diff --git a/src/WhatsApp/IStorageService.cs b/src/WhatsApp/IStorageService.cs new file mode 100644 index 0000000..c117adb --- /dev/null +++ b/src/WhatsApp/IStorageService.cs @@ -0,0 +1,44 @@ +namespace Devlooped.WhatsApp; + +/// +/// Defines methods for storing and retrieving messages in an asynchronous manner. +/// +/// This interface provides functionality to retrieve messages associated with a specific identifier and +/// to save messages or responses to the storage. Implementations of this interface should ensure thread safety and +/// proper handling of cancellation tokens for asynchronous operations. +interface IStorageService +{ + /// + /// Retrieves a stream of messages associated with the specified phone number. + /// + /// This method uses asynchronous streaming to retrieve messages, allowing the caller to process + /// messages as they are received. Ensure proper handling of the by using `await + /// foreach` or equivalent constructs. + /// The phone number for which to retrieve messages. This must be a valid phone number in the expected format. + /// A token to monitor for cancellation requests. The operation will terminate early if the token is canceled. + /// An asynchronous stream of objects representing the messages associated with the specified + /// phone number. The stream will be empty if no messages are found. + IAsyncEnumerable GetMessagesAsync(string number, CancellationToken cancellationToken = default); + + /// + /// Asynchronously saves a collection of messages to the underlying storage. + /// + /// If the operation is canceled via the , the returned task + /// will be in a canceled state. + /// The collection of objects to be saved. Cannot be null or empty. + /// A that can be used to cancel the save operation. The default value is . + /// A that represents the asynchronous save operation. + Task SaveAsync(IEnumerable messages, CancellationToken cancellationToken = default); + + /// + /// Asynchronously saves the specified response to the underlying storage. + /// + /// This method performs an asynchronous operation to persist the provided response. If the + /// operation is canceled via the , the returned task will be in a canceled + /// state. + /// The response object to be saved. Cannot be null. + /// A token to monitor for cancellation requests. The default value is . + /// A task that represents the asynchronous save operation. + Task SaveAsync(Response response, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/WhatsApp/Message.cs b/src/WhatsApp/Message.cs index 7e9739a..3caf80d 100644 --- a/src/WhatsApp/Message.cs +++ b/src/WhatsApp/Message.cs @@ -17,6 +17,7 @@ namespace Devlooped.WhatsApp; [JsonDerivedType(typeof(ReactionMessage), "reaction")] [JsonDerivedType(typeof(StatusMessage), "status")] [JsonDerivedType(typeof(UnsupportedMessage), "unsupported")] +[JsonDerivedType(typeof(ResponseContentMessage), "response")] public abstract partial record Message(string Id, Service To, User From, long Timestamp) { /// diff --git a/src/WhatsApp/MessageStorageHandler.cs b/src/WhatsApp/MessageStorageHandler.cs new file mode 100644 index 0000000..16daa05 --- /dev/null +++ b/src/WhatsApp/MessageStorageHandler.cs @@ -0,0 +1,29 @@ +using System.Runtime.CompilerServices; + +namespace Devlooped.WhatsApp; + +/// +/// Handles incoming messages by saving user messages to storage and delegating further processing to an inner handler. +/// +class MessageStorageHandler : DelegatingWhatsAppHandler +{ + readonly StorageService storageService; + + public MessageStorageHandler(IWhatsAppHandler innerHandler, StorageService storageService) + : base(innerHandler) + { + this.storageService = storageService; + } + + public override async IAsyncEnumerable HandleAsync(IEnumerable messages, [EnumeratorCancellation] CancellationToken cancellation = default) + { + // Save the incoming user messages only. Avoid system messages, etc + // TODO: Fire and forget? Do we really need to wait for the messages to be fully saved here? + await storageService.SaveAsync(messages.OfType(), cancellation); + + await foreach (var response in base.HandleAsync(messages, cancellation)) + { + yield return response; + } + } +} \ No newline at end of file diff --git a/src/WhatsApp/MessageType.cs b/src/WhatsApp/MessageType.cs index e780d6e..d8ddad8 100644 --- a/src/WhatsApp/MessageType.cs +++ b/src/WhatsApp/MessageType.cs @@ -28,5 +28,5 @@ public enum MessageType /// /// Message type is not supported by the WhatsApp for Business service. /// - Unsupported, + Unsupported } \ No newline at end of file diff --git a/src/WhatsApp/ReactionResponse.cs b/src/WhatsApp/ReactionResponse.cs index 2d2f3db..f918b0e 100644 --- a/src/WhatsApp/ReactionResponse.cs +++ b/src/WhatsApp/ReactionResponse.cs @@ -5,7 +5,7 @@ /// /// The message this reaction applies to. /// The emoji of the reaction. -public record ReactionResponse(UserMessage UserMessage, string Emoji) : Response +public record ReactionResponse(UserMessage UserMessage, string Emoji) : Response(UserMessage) { /// internal override Task SendAsync(IWhatsAppClient client, CancellationToken cancellationToken = default) diff --git a/src/WhatsApp/Response.cs b/src/WhatsApp/Response.cs index 1786636..5096480 100644 --- a/src/WhatsApp/Response.cs +++ b/src/WhatsApp/Response.cs @@ -1,12 +1,40 @@ -using System.Text.Json.Serialization; -using static System.Runtime.InteropServices.JavaScript.JSType; - -namespace Devlooped.WhatsApp; +namespace Devlooped.WhatsApp; /// -/// Base class for responses. +/// Represents a response sent via WhatsApp, containing the associated message and response metadata. /// -public abstract partial record Response +/// This abstract record serves as a base type for specific response implementations. It encapsulates the +/// message being sent and provides functionality for sending the response asynchronously using a WhatsApp +/// client. +/// +public abstract partial record Response(Message Message) { + /// + /// Gets the unique identifier for this instance. + /// + public string? Id { get; set; } + + /// + /// Sends a request asynchronously using the specified WhatsApp client. + /// + /// This method is abstract and must be implemented by a derived class to define the specific + /// behavior for sending a request. + /// The instance used to send the request. This parameter cannot be . + /// An optional to observe while waiting for the task to complete. Defaults to . + /// A that represents the asynchronous operation. internal abstract Task SendAsync(IWhatsAppClient client, CancellationToken cancellation = default); + + /// + /// Converts the current response content into a object. + /// + public ResponseContentMessage? AsMessage() => Id != null ? new ResponseContentMessage(Id, Message.To, Message.From, new TextContent(GetResponseText())) : null; + + /// + /// Retrieves the response text associated with the current response. + /// + /// This method can be overridden in a derived class to provide a custom response text. + /// A containing the response text. Returns an empty string if no response text is available. + protected virtual string GetResponseText() => string.Empty; } \ No newline at end of file diff --git a/src/WhatsApp/ResponseContentMessage.cs b/src/WhatsApp/ResponseContentMessage.cs new file mode 100644 index 0000000..c59549a --- /dev/null +++ b/src/WhatsApp/ResponseContentMessage.cs @@ -0,0 +1,10 @@ +using System.Text.Json.Serialization; + +namespace Devlooped.WhatsApp; + +public record ResponseContentMessage(string Id, Service To, User From, Content Content) : Message(Id, To, From, int.MinValue) +{ + /// + [JsonIgnore] + public override MessageType Type => MessageType.Content; +} \ No newline at end of file diff --git a/src/WhatsApp/ResponseStorageHandler.cs b/src/WhatsApp/ResponseStorageHandler.cs new file mode 100644 index 0000000..5ddc570 --- /dev/null +++ b/src/WhatsApp/ResponseStorageHandler.cs @@ -0,0 +1,27 @@ +using System.Runtime.CompilerServices; + +namespace Devlooped.WhatsApp; + +/// +/// A handler that processes WhatsApp messages and stores the generated responses using a storage service. +/// +class ResponseStorageHandler : DelegatingWhatsAppHandler +{ + readonly StorageService storageService; + + public ResponseStorageHandler(IWhatsAppHandler innerHandler, StorageService storageService) + : base(innerHandler) + { + this.storageService = storageService; + } + + public async override IAsyncEnumerable HandleAsync(IEnumerable messages, [EnumeratorCancellation] CancellationToken cancellation = default) + { + await foreach (var response in InnerHandler.HandleAsync(messages, cancellation)) + { + await storageService.SaveAsync(response, cancellation); + + yield return response; + } + } +} \ No newline at end of file diff --git a/src/WhatsApp/SendResponsesHandler.cs b/src/WhatsApp/SendResponsesHandler.cs new file mode 100644 index 0000000..e65aa12 --- /dev/null +++ b/src/WhatsApp/SendResponsesHandler.cs @@ -0,0 +1,24 @@ +using System.Runtime.CompilerServices; + +namespace Devlooped.WhatsApp; + +class SendResponsesHandler : DelegatingWhatsAppHandler +{ + readonly IWhatsAppClient whatsapp; + + public SendResponsesHandler(IWhatsAppHandler innerHandler, IWhatsAppClient whatsapp) + : base(innerHandler) + { + this.whatsapp = whatsapp; + } + + public async override IAsyncEnumerable HandleAsync(IEnumerable messages, [EnumeratorCancellation] CancellationToken cancellation = default) + { + await foreach (var response in InnerHandler.HandleAsync(messages)) + { + await response.SendAsync(whatsapp); + + yield return response; + } + } +} \ No newline at end of file diff --git a/src/WhatsApp/StorageHandlerExtensions.cs b/src/WhatsApp/StorageHandlerExtensions.cs new file mode 100644 index 0000000..d5f3409 --- /dev/null +++ b/src/WhatsApp/StorageHandlerExtensions.cs @@ -0,0 +1,19 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace Devlooped.WhatsApp; + +/// +/// Provides extensions for configuring instances. +/// +public static class StorageHandlerExtensions +{ + public static WhatsAppHandlerBuilder UseStorage(this WhatsAppHandlerBuilder builder) + { + _ = Throw.IfNull(builder); + + // By adding the storage service, the incoming and outgoing handlers will be automatically added to the pipeline + builder.Services.AddSingleton(services => new StorageService(services.GetRequiredService())); + + return builder; + } +} \ No newline at end of file diff --git a/src/WhatsApp/StorageService.cs b/src/WhatsApp/StorageService.cs new file mode 100644 index 0000000..b18bfa9 --- /dev/null +++ b/src/WhatsApp/StorageService.cs @@ -0,0 +1,46 @@ +namespace Devlooped.WhatsApp; + +class StorageService(CloudStorageAccount storage) : IStorageService +{ + const string MessagesTableName = "messages"; + + IDocumentRepository? messagesRepository; + + /// + public async Task SaveAsync(IEnumerable messages, CancellationToken cancellationToken = default) + { + var repository = EnsureMessagesRepository(); + + foreach (var message in messages) + { + await repository.PutAsync(message, cancellationToken); + } + } + + /// + public async Task SaveAsync(Response response, CancellationToken cancellationToken = default) + { + if (response.AsMessage() is Message responseMessage) + { + await EnsureMessagesRepository().PutAsync(responseMessage, cancellationToken); + } + } + + /// + public IAsyncEnumerable GetMessagesAsync(string number, CancellationToken cancellationToken = default) + => EnsureMessagesRepository().EnumerateAsync(number, cancellationToken); + + /// + /// Ensures that the repository for storing and retrieving objects is initialized. + /// + IDocumentRepository EnsureMessagesRepository() + { + messagesRepository ??= DocumentRepository.Create( + storage, + MessagesTableName, + x => x.From.Number, + x => x.Id); + + return messagesRepository; + } +} \ No newline at end of file diff --git a/src/WhatsApp/TemplateResponse.cs b/src/WhatsApp/TemplateResponse.cs index 42a3e7f..92cdde2 100644 --- a/src/WhatsApp/TemplateResponse.cs +++ b/src/WhatsApp/TemplateResponse.cs @@ -1,12 +1,22 @@ -namespace Devlooped.WhatsApp; +using static System.Net.Mime.MediaTypeNames; + +namespace Devlooped.WhatsApp; /// -/// A template response to a user message. +/// Represents a response containing a template message to be sent via a WhatsApp client. /// -/// The message this reaction applies to. -public record TemplateResponse(Message Message, string Name, string Code) : Response +/// This response encapsulates the details required to send a template message, including the recipient, +/// sender, template name, and template code. It is used in conjunction with a WhatsApp client to facilitate the +/// delivery of template-based messages. +/// The message details, including sender and recipient information. +/// The name of the template to be sent. This must match a pre-configured template in the WhatsApp system. +/// The code associated with the template, used to identify the specific template version or configuration. +public record TemplateResponse(Message Message, string Name, string Code) : Response(Message) { /// internal override Task SendAsync(IWhatsAppClient client, CancellationToken cancellationToken = default) => client.SendTemplateAsync(Message.To.Id, Message.From.Number, Name, Code, cancellationToken); + + /// + protected override string GetResponseText() => "Template: " + Name; } \ No newline at end of file diff --git a/src/WhatsApp/TextResponse.cs b/src/WhatsApp/TextResponse.cs index 2fd4545..de1f4f4 100644 --- a/src/WhatsApp/TextResponse.cs +++ b/src/WhatsApp/TextResponse.cs @@ -1,23 +1,31 @@ namespace Devlooped.WhatsApp; /// -/// A simple text response to a user message. +/// Represents a response containing text and optional interactive buttons, which can be sent as a reply to a message. /// -/// The message this reaction applies to. -/// The text of the response. -public record TextResponse(Message Message, string Text, Button? Button1 = default, Button? Button2 = default) : Response +/// 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 message to which this response is a reply. +/// 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(Message Message, string Text, Button? Button1 = default, Button? Button2 = default) : Response(Message) { /// - internal override Task SendAsync(IWhatsAppClient client, CancellationToken cancellationToken = default) + internal async override Task SendAsync(IWhatsAppClient client, CancellationToken cancellationToken = default) { if (Button1 != null) { - return Button2 == null ? + Id = await (Button2 == null ? client.ReplyAsync(Message, Text, Button1) : - client.ReplyAsync(Message, Text, Button1, Button2); - + client.ReplyAsync(Message, Text, Button1, Button2)); + } + else + { + Id = await client.ReplyAsync(Message, Text); } - - return client.ReplyAsync(Message, Text); } + + /// + protected override string GetResponseText() => Text; } \ No newline at end of file diff --git a/src/WhatsApp/UserMessage.cs b/src/WhatsApp/UserMessage.cs index 6c1c850..3be7184 100644 --- a/src/WhatsApp/UserMessage.cs +++ b/src/WhatsApp/UserMessage.cs @@ -1,4 +1,4 @@ -namespace Devlooped.WhatsApp; + namespace Devlooped.WhatsApp; /// /// Base message class for messages the user can interact with. diff --git a/src/WhatsApp/WhatsApp.csproj b/src/WhatsApp/WhatsApp.csproj index 34baff6..8f80774 100644 --- a/src/WhatsApp/WhatsApp.csproj +++ b/src/WhatsApp/WhatsApp.csproj @@ -9,7 +9,6 @@ - @@ -19,8 +18,12 @@ - + + + + + diff --git a/src/WhatsApp/WhatsAppHandlerBuilder.cs b/src/WhatsApp/WhatsAppHandlerBuilder.cs index e70e233..dc172a3 100644 --- a/src/WhatsApp/WhatsAppHandlerBuilder.cs +++ b/src/WhatsApp/WhatsAppHandlerBuilder.cs @@ -1,4 +1,6 @@ -namespace Devlooped.WhatsApp; +using Microsoft.Extensions.DependencyInjection; + +namespace Devlooped.WhatsApp; /// /// Creates the handler pipeline using the given @@ -14,12 +16,16 @@ public WhatsAppHandlerBuilder() : this(_ => WhatsAppHandler.Empty) { } - public WhatsAppHandlerBuilder(Func handlerFactory) + public WhatsAppHandlerBuilder(Func handlerFactory, IServiceCollection? serviceCollection = default) { Throw.IfNull(handlerFactory); this.handlerFactory = handlerFactory; + + Services = serviceCollection ?? new ServiceCollection(); } + public IServiceCollection Services { get; } + public IWhatsAppHandler Build(IServiceProvider? services = default) { services ??= ServiceProvider.Empty; diff --git a/src/WhatsApp/WhatsAppServiceCollectionExtensions.cs b/src/WhatsApp/WhatsAppServiceCollectionExtensions.cs index 745cecb..2a045e4 100644 --- a/src/WhatsApp/WhatsAppServiceCollectionExtensions.cs +++ b/src/WhatsApp/WhatsAppServiceCollectionExtensions.cs @@ -44,7 +44,40 @@ public static WhatsAppHandlerBuilder AddWhatsApp( _ = Throw.IfNull(collection); _ = Throw.IfNull(handlerFactory); - return ConfigureServices(collection, new WhatsAppHandlerBuilder(handlerFactory), lifetime); + // Create builder + var builder = new WhatsAppHandlerBuilder(handlerFactory, collection); + + // Configure default services + ConfigureServices(collection, builder, lifetime); + + // Add storage handler for response messages (it needs to be added before the send handler to get the generated id) + builder.Use((inner, services) => + { + // Check if the storage capability was enabled by getting the storage service + if (services.GetService() is StorageService storageService) + { + return new ResponseStorageHandler(inner, storageService); + } + + return WhatsAppHandler.Empty; + }); + + // Add the handler for sending responses + builder.Use((inner, services) => new SendResponsesHandler(inner, services.GetRequiredService())); + + // Add storage handler for incoming messages + builder.Use((inner, services) => + { + // Check if the storage capability was enabled by getting the storage service + if (services.GetService() is StorageService storageService) + { + return new MessageStorageHandler(inner, storageService); + } + + return WhatsAppHandler.Empty; + }); + + return builder; } /// From 1fad5564a6be0664aa6293869c3610bd78814015 Mon Sep 17 00:00:00 2001 From: Adrian Alonso Date: Fri, 30 May 2025 13:25:38 -0300 Subject: [PATCH 2/8] Fixed interface usage --- src/WhatsApp/MessageStorageHandler.cs | 4 ++-- src/WhatsApp/ResponseStorageHandler.cs | 4 ++-- src/WhatsApp/StorageHandlerExtensions.cs | 2 +- src/WhatsApp/WhatsAppServiceCollectionExtensions.cs | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/WhatsApp/MessageStorageHandler.cs b/src/WhatsApp/MessageStorageHandler.cs index 16daa05..7958a26 100644 --- a/src/WhatsApp/MessageStorageHandler.cs +++ b/src/WhatsApp/MessageStorageHandler.cs @@ -7,9 +7,9 @@ namespace Devlooped.WhatsApp; /// class MessageStorageHandler : DelegatingWhatsAppHandler { - readonly StorageService storageService; + readonly IStorageService storageService; - public MessageStorageHandler(IWhatsAppHandler innerHandler, StorageService storageService) + public MessageStorageHandler(IWhatsAppHandler innerHandler, IStorageService storageService) : base(innerHandler) { this.storageService = storageService; diff --git a/src/WhatsApp/ResponseStorageHandler.cs b/src/WhatsApp/ResponseStorageHandler.cs index 5ddc570..acff755 100644 --- a/src/WhatsApp/ResponseStorageHandler.cs +++ b/src/WhatsApp/ResponseStorageHandler.cs @@ -7,9 +7,9 @@ namespace Devlooped.WhatsApp; /// class ResponseStorageHandler : DelegatingWhatsAppHandler { - readonly StorageService storageService; + readonly IStorageService storageService; - public ResponseStorageHandler(IWhatsAppHandler innerHandler, StorageService storageService) + public ResponseStorageHandler(IWhatsAppHandler innerHandler, IStorageService storageService) : base(innerHandler) { this.storageService = storageService; diff --git a/src/WhatsApp/StorageHandlerExtensions.cs b/src/WhatsApp/StorageHandlerExtensions.cs index d5f3409..5648350 100644 --- a/src/WhatsApp/StorageHandlerExtensions.cs +++ b/src/WhatsApp/StorageHandlerExtensions.cs @@ -12,7 +12,7 @@ public static WhatsAppHandlerBuilder UseStorage(this WhatsAppHandlerBuilder buil _ = Throw.IfNull(builder); // By adding the storage service, the incoming and outgoing handlers will be automatically added to the pipeline - builder.Services.AddSingleton(services => new StorageService(services.GetRequiredService())); + builder.Services.AddSingleton(services => new StorageService(services.GetRequiredService())); return builder; } diff --git a/src/WhatsApp/WhatsAppServiceCollectionExtensions.cs b/src/WhatsApp/WhatsAppServiceCollectionExtensions.cs index 2a045e4..3228232 100644 --- a/src/WhatsApp/WhatsAppServiceCollectionExtensions.cs +++ b/src/WhatsApp/WhatsAppServiceCollectionExtensions.cs @@ -54,7 +54,7 @@ public static WhatsAppHandlerBuilder AddWhatsApp( builder.Use((inner, services) => { // Check if the storage capability was enabled by getting the storage service - if (services.GetService() is StorageService storageService) + if (services.GetService() is IStorageService storageService) { return new ResponseStorageHandler(inner, storageService); } @@ -69,7 +69,7 @@ public static WhatsAppHandlerBuilder AddWhatsApp( builder.Use((inner, services) => { // Check if the storage capability was enabled by getting the storage service - if (services.GetService() is StorageService storageService) + if (services.GetService() is IStorageService storageService) { return new MessageStorageHandler(inner, storageService); } From 89b51737f2b04afdb7f83d854ab8bcb5fccf2d8a Mon Sep 17 00:00:00 2001 From: Adrian Alonso Date: Fri, 30 May 2025 16:09:17 -0300 Subject: [PATCH 3/8] Refactored ResponseContentMessage into IMessage --- src/WhatsApp/IMessage.cs | 33 ++++++++++++++++++++++++++ src/WhatsApp/IStorageService.cs | 15 ++---------- src/WhatsApp/Message.cs | 8 ++++--- src/WhatsApp/Response.cs | 23 +++++------------- src/WhatsApp/ResponseContentMessage.cs | 10 -------- src/WhatsApp/ResponseStorageHandler.cs | 2 +- src/WhatsApp/StorageService.cs | 23 ++++++------------ src/WhatsApp/TemplateResponse.cs | 7 +----- src/WhatsApp/TextResponse.cs | 7 ++---- 9 files changed, 57 insertions(+), 71 deletions(-) create mode 100644 src/WhatsApp/IMessage.cs delete mode 100644 src/WhatsApp/ResponseContentMessage.cs diff --git a/src/WhatsApp/IMessage.cs b/src/WhatsApp/IMessage.cs new file mode 100644 index 0000000..0966d34 --- /dev/null +++ b/src/WhatsApp/IMessage.cs @@ -0,0 +1,33 @@ +using System.Text.Json.Serialization; + +namespace Devlooped.WhatsApp; + +/// +/// Represents a message exchanged in a communication system, serving as a base type for various message types. +/// +/// This interface is designed to support polymorphic serialization and deserialization of different +/// message types. Derived types are identified using JSON type discrimination, as specified by the and annotations. Examples of derived types include +/// content messages, error messages, and interactive messages. +[JsonPolymorphic] +[JsonDerivedType(typeof(ContentMessage), "content")] +[JsonDerivedType(typeof(ErrorMessage), "error")] +[JsonDerivedType(typeof(InteractiveMessage), "interactive")] +[JsonDerivedType(typeof(ReactionMessage), "reaction")] +[JsonDerivedType(typeof(StatusMessage), "status")] +[JsonDerivedType(typeof(UnsupportedMessage), "unsupported")] +[JsonDerivedType(typeof(TextResponse), "response/text")] +[JsonDerivedType(typeof(TemplateResponse), "response/template")] +[JsonDerivedType(typeof(ReactionResponse), "response/reaction")] +public interface IMessage +{ + /// + /// Gets the phone number associated with the message sender. + /// + string Number { get; } + + /// + /// Gets the message id. + /// + string Id { get; } +} \ No newline at end of file diff --git a/src/WhatsApp/IStorageService.cs b/src/WhatsApp/IStorageService.cs index c117adb..e6f9639 100644 --- a/src/WhatsApp/IStorageService.cs +++ b/src/WhatsApp/IStorageService.cs @@ -18,7 +18,7 @@ interface IStorageService /// A token to monitor for cancellation requests. The operation will terminate early if the token is canceled. /// An asynchronous stream of objects representing the messages associated with the specified /// phone number. The stream will be empty if no messages are found. - IAsyncEnumerable GetMessagesAsync(string number, CancellationToken cancellationToken = default); + IAsyncEnumerable GetMessagesAsync(string number, CancellationToken cancellationToken = default); /// /// Asynchronously saves a collection of messages to the underlying storage. @@ -29,16 +29,5 @@ interface IStorageService /// A that can be used to cancel the save operation. The default value is . /// A that represents the asynchronous save operation. - Task SaveAsync(IEnumerable messages, CancellationToken cancellationToken = default); - - /// - /// Asynchronously saves the specified response to the underlying storage. - /// - /// This method performs an asynchronous operation to persist the provided response. If the - /// operation is canceled via the , the returned task will be in a canceled - /// state. - /// The response object to be saved. Cannot be null. - /// A token to monitor for cancellation requests. The default value is . - /// A task that represents the asynchronous save operation. - Task SaveAsync(Response response, CancellationToken cancellationToken = default); + Task SaveAsync(IEnumerable messages, CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/src/WhatsApp/Message.cs b/src/WhatsApp/Message.cs index 3caf80d..b89ee19 100644 --- a/src/WhatsApp/Message.cs +++ b/src/WhatsApp/Message.cs @@ -17,8 +17,7 @@ namespace Devlooped.WhatsApp; [JsonDerivedType(typeof(ReactionMessage), "reaction")] [JsonDerivedType(typeof(StatusMessage), "status")] [JsonDerivedType(typeof(UnsupportedMessage), "unsupported")] -[JsonDerivedType(typeof(ResponseContentMessage), "response")] -public abstract partial record Message(string Id, Service To, User From, long Timestamp) +public abstract partial record Message(string Id, Service To, User From, long Timestamp) : IMessage { /// /// Optional related message identifier, such as message being replied @@ -238,4 +237,7 @@ .value.statuses[0] as $status | /// [JsonIgnore] public abstract MessageType Type { get; } -} + + /// + public string Number => From.Number; +} \ No newline at end of file diff --git a/src/WhatsApp/Response.cs b/src/WhatsApp/Response.cs index 5096480..5d82768 100644 --- a/src/WhatsApp/Response.cs +++ b/src/WhatsApp/Response.cs @@ -7,12 +7,13 @@ /// message being sent and provides functionality for sending the response asynchronously using a WhatsApp /// client. /// -public abstract partial record Response(Message Message) +public abstract partial record Response(Message Message) : IMessage { - /// - /// Gets the unique identifier for this instance. - /// - public string? Id { get; set; } + /// + public string Id { get; set; } = string.Empty; + + /// + public string Number => Message.From.Number; /// /// Sends a request asynchronously using the specified WhatsApp client. @@ -25,16 +26,4 @@ public abstract partial record Response(Message Message) /// cref="CancellationToken.None"/>. /// A that represents the asynchronous operation. internal abstract Task SendAsync(IWhatsAppClient client, CancellationToken cancellation = default); - - /// - /// Converts the current response content into a object. - /// - public ResponseContentMessage? AsMessage() => Id != null ? new ResponseContentMessage(Id, Message.To, Message.From, new TextContent(GetResponseText())) : null; - - /// - /// Retrieves the response text associated with the current response. - /// - /// This method can be overridden in a derived class to provide a custom response text. - /// A containing the response text. Returns an empty string if no response text is available. - protected virtual string GetResponseText() => string.Empty; } \ No newline at end of file diff --git a/src/WhatsApp/ResponseContentMessage.cs b/src/WhatsApp/ResponseContentMessage.cs deleted file mode 100644 index c59549a..0000000 --- a/src/WhatsApp/ResponseContentMessage.cs +++ /dev/null @@ -1,10 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Devlooped.WhatsApp; - -public record ResponseContentMessage(string Id, Service To, User From, Content Content) : Message(Id, To, From, int.MinValue) -{ - /// - [JsonIgnore] - public override MessageType Type => MessageType.Content; -} \ No newline at end of file diff --git a/src/WhatsApp/ResponseStorageHandler.cs b/src/WhatsApp/ResponseStorageHandler.cs index acff755..c5117a3 100644 --- a/src/WhatsApp/ResponseStorageHandler.cs +++ b/src/WhatsApp/ResponseStorageHandler.cs @@ -19,7 +19,7 @@ public async override IAsyncEnumerable HandleAsync(IEnumerable? messagesRepository; + IDocumentRepository? messagesRepository; /// - public async Task SaveAsync(IEnumerable messages, CancellationToken cancellationToken = default) + public async Task SaveAsync(IEnumerable messages, CancellationToken cancellationToken = default) { var repository = EnsureMessagesRepository(); - foreach (var message in messages) + foreach (var message in messages.Where(x => !string.IsNullOrEmpty(x.Id))) { await repository.PutAsync(message, cancellationToken); } } /// - public async Task SaveAsync(Response response, CancellationToken cancellationToken = default) - { - if (response.AsMessage() is Message responseMessage) - { - await EnsureMessagesRepository().PutAsync(responseMessage, cancellationToken); - } - } - - /// - public IAsyncEnumerable GetMessagesAsync(string number, CancellationToken cancellationToken = default) + public IAsyncEnumerable GetMessagesAsync(string number, CancellationToken cancellationToken = default) => EnsureMessagesRepository().EnumerateAsync(number, cancellationToken); /// /// Ensures that the repository for storing and retrieving objects is initialized. /// - IDocumentRepository EnsureMessagesRepository() + IDocumentRepository EnsureMessagesRepository() { - messagesRepository ??= DocumentRepository.Create( + messagesRepository ??= DocumentRepository.Create( storage, MessagesTableName, - x => x.From.Number, + x => x.Number, x => x.Id); return messagesRepository; diff --git a/src/WhatsApp/TemplateResponse.cs b/src/WhatsApp/TemplateResponse.cs index 92cdde2..a11d634 100644 --- a/src/WhatsApp/TemplateResponse.cs +++ b/src/WhatsApp/TemplateResponse.cs @@ -1,6 +1,4 @@ -using static System.Net.Mime.MediaTypeNames; - -namespace Devlooped.WhatsApp; +namespace Devlooped.WhatsApp; /// /// Represents a response containing a template message to be sent via a WhatsApp client. @@ -16,7 +14,4 @@ public record TemplateResponse(Message Message, string Name, string Code) : Resp /// internal override Task SendAsync(IWhatsAppClient client, CancellationToken cancellationToken = default) => client.SendTemplateAsync(Message.To.Id, Message.From.Number, Name, Code, cancellationToken); - - /// - protected override string GetResponseText() => "Template: " + Name; } \ No newline at end of file diff --git a/src/WhatsApp/TextResponse.cs b/src/WhatsApp/TextResponse.cs index de1f4f4..62a9a41 100644 --- a/src/WhatsApp/TextResponse.cs +++ b/src/WhatsApp/TextResponse.cs @@ -18,14 +18,11 @@ internal async override Task SendAsync(IWhatsAppClient client, CancellationToken { Id = await (Button2 == null ? client.ReplyAsync(Message, Text, Button1) : - client.ReplyAsync(Message, Text, Button1, Button2)); + client.ReplyAsync(Message, Text, Button1, Button2)) ?? string.Empty; } else { - Id = await client.ReplyAsync(Message, Text); + Id = await client.ReplyAsync(Message, Text) ?? string.Empty; } } - - /// - protected override string GetResponseText() => Text; } \ No newline at end of file From 13988fa7de9bedce9021a780f3a993b9ba33cafd Mon Sep 17 00:00:00 2001 From: Adrian Alonso Date: Fri, 30 May 2025 17:10:44 -0300 Subject: [PATCH 4/8] Added some more comments and fixed formatting --- src/WhatsApp/MessageType.cs | 2 +- src/WhatsApp/SendResponsesHandler.cs | 13 ++++++++----- src/WhatsApp/UserMessage.cs | 2 +- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/WhatsApp/MessageType.cs b/src/WhatsApp/MessageType.cs index d8ddad8..e780d6e 100644 --- a/src/WhatsApp/MessageType.cs +++ b/src/WhatsApp/MessageType.cs @@ -28,5 +28,5 @@ public enum MessageType /// /// Message type is not supported by the WhatsApp for Business service. /// - Unsupported + Unsupported, } \ No newline at end of file diff --git a/src/WhatsApp/SendResponsesHandler.cs b/src/WhatsApp/SendResponsesHandler.cs index e65aa12..2ab0d43 100644 --- a/src/WhatsApp/SendResponsesHandler.cs +++ b/src/WhatsApp/SendResponsesHandler.cs @@ -1,22 +1,25 @@ using System.Runtime.CompilerServices; - namespace Devlooped.WhatsApp; +/// +/// Handles the processing of messages by delegating to an inner handler and sending the resulting responses using the +/// specified WhatsApp client. +/// class SendResponsesHandler : DelegatingWhatsAppHandler { - readonly IWhatsAppClient whatsapp; + readonly IWhatsAppClient client; - public SendResponsesHandler(IWhatsAppHandler innerHandler, IWhatsAppClient whatsapp) + public SendResponsesHandler(IWhatsAppHandler innerHandler, IWhatsAppClient client) : base(innerHandler) { - this.whatsapp = whatsapp; + this.client = client; } public async override IAsyncEnumerable HandleAsync(IEnumerable messages, [EnumeratorCancellation] CancellationToken cancellation = default) { await foreach (var response in InnerHandler.HandleAsync(messages)) { - await response.SendAsync(whatsapp); + await response.SendAsync(client); yield return response; } diff --git a/src/WhatsApp/UserMessage.cs b/src/WhatsApp/UserMessage.cs index 3be7184..6c1c850 100644 --- a/src/WhatsApp/UserMessage.cs +++ b/src/WhatsApp/UserMessage.cs @@ -1,4 +1,4 @@ - namespace Devlooped.WhatsApp; +namespace Devlooped.WhatsApp; /// /// Base message class for messages the user can interact with. From ae68a086c4c70da18030da17fb9f764f48521f6c Mon Sep 17 00:00:00 2001 From: Adrian Alonso Date: Fri, 30 May 2025 17:17:54 -0300 Subject: [PATCH 5/8] Making IStorageService public --- src/WhatsApp/IStorageService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/WhatsApp/IStorageService.cs b/src/WhatsApp/IStorageService.cs index e6f9639..f647b92 100644 --- a/src/WhatsApp/IStorageService.cs +++ b/src/WhatsApp/IStorageService.cs @@ -6,7 +6,7 @@ /// This interface provides functionality to retrieve messages associated with a specific identifier and /// to save messages or responses to the storage. Implementations of this interface should ensure thread safety and /// proper handling of cancellation tokens for asynchronous operations. -interface IStorageService +public interface IStorageService { /// /// Retrieves a stream of messages associated with the specified phone number. From 80e0f0d85880f34e458f1027f391eccb5ba444da Mon Sep 17 00:00:00 2001 From: Adrian Alonso Date: Fri, 30 May 2025 17:23:38 -0300 Subject: [PATCH 6/8] More nits --- src/WhatsApp/Response.cs | 2 +- src/WhatsApp/SendResponsesHandler.cs | 2 +- src/WhatsApp/StorageService.cs | 25 ++++++++----------------- 3 files changed, 10 insertions(+), 19 deletions(-) diff --git a/src/WhatsApp/Response.cs b/src/WhatsApp/Response.cs index 5d82768..9e25ed4 100644 --- a/src/WhatsApp/Response.cs +++ b/src/WhatsApp/Response.cs @@ -6,7 +6,7 @@ /// This abstract record serves as a base type for specific response implementations. It encapsulates the /// message being sent and provides functionality for sending the response asynchronously using a WhatsApp /// client. -/// +/// The message this response is created for public abstract partial record Response(Message Message) : IMessage { /// diff --git a/src/WhatsApp/SendResponsesHandler.cs b/src/WhatsApp/SendResponsesHandler.cs index 2ab0d43..49fca0e 100644 --- a/src/WhatsApp/SendResponsesHandler.cs +++ b/src/WhatsApp/SendResponsesHandler.cs @@ -19,7 +19,7 @@ public async override IAsyncEnumerable HandleAsync(IEnumerable? messagesRepository; + Lazy> messagesRepository = new(() => + DocumentRepository.Create( + storage, + MessagesTableName, + x => x.Number, + x => x.Id)); /// public async Task SaveAsync(IEnumerable messages, CancellationToken cancellationToken = default) { - var repository = EnsureMessagesRepository(); + var repository = messagesRepository.Value; foreach (var message in messages.Where(x => !string.IsNullOrEmpty(x.Id))) { @@ -19,19 +24,5 @@ public async Task SaveAsync(IEnumerable messages, CancellationToken ca /// public IAsyncEnumerable GetMessagesAsync(string number, CancellationToken cancellationToken = default) - => EnsureMessagesRepository().EnumerateAsync(number, cancellationToken); - - /// - /// Ensures that the repository for storing and retrieving objects is initialized. - /// - IDocumentRepository EnsureMessagesRepository() - { - messagesRepository ??= DocumentRepository.Create( - storage, - MessagesTableName, - x => x.Number, - x => x.Id); - - return messagesRepository; - } + => messagesRepository.Value.EnumerateAsync(number, cancellationToken); } \ No newline at end of file From 1a172e5e1b886eb641b8f453836780d8c95099c1 Mon Sep 17 00:00:00 2001 From: Adrian Alonso Date: Fri, 30 May 2025 17:24:41 -0300 Subject: [PATCH 7/8] Saving all messages --- src/WhatsApp/MessageStorageHandler.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/WhatsApp/MessageStorageHandler.cs b/src/WhatsApp/MessageStorageHandler.cs index 7958a26..399e272 100644 --- a/src/WhatsApp/MessageStorageHandler.cs +++ b/src/WhatsApp/MessageStorageHandler.cs @@ -19,7 +19,7 @@ public override async IAsyncEnumerable HandleAsync(IEnumerable(), cancellation); + await storageService.SaveAsync(messages, cancellation); await foreach (var response in base.HandleAsync(messages, cancellation)) { From d6c24ca6cdf575f053936ad23cdfe5f3a63d3891 Mon Sep 17 00:00:00 2001 From: Adrian Alonso Date: Fri, 30 May 2025 17:25:50 -0300 Subject: [PATCH 8/8] Passing missed ct --- src/WhatsApp/SendResponsesHandler.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/WhatsApp/SendResponsesHandler.cs b/src/WhatsApp/SendResponsesHandler.cs index 49fca0e..24c6dad 100644 --- a/src/WhatsApp/SendResponsesHandler.cs +++ b/src/WhatsApp/SendResponsesHandler.cs @@ -17,7 +17,7 @@ public SendResponsesHandler(IWhatsAppHandler innerHandler, IWhatsAppClient clien public async override IAsyncEnumerable HandleAsync(IEnumerable messages, [EnumeratorCancellation] CancellationToken cancellation = default) { - await foreach (var response in InnerHandler.HandleAsync(messages)) + await foreach (var response in InnerHandler.HandleAsync(messages, cancellation)) { await response.SendAsync(client, cancellation);