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/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 new file mode 100644 index 0000000..f647b92 --- /dev/null +++ b/src/WhatsApp/IStorageService.cs @@ -0,0 +1,33 @@ +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. +public 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); +} \ No newline at end of file diff --git a/src/WhatsApp/Message.cs b/src/WhatsApp/Message.cs index 7e9739a..b89ee19 100644 --- a/src/WhatsApp/Message.cs +++ b/src/WhatsApp/Message.cs @@ -17,7 +17,7 @@ namespace Devlooped.WhatsApp; [JsonDerivedType(typeof(ReactionMessage), "reaction")] [JsonDerivedType(typeof(StatusMessage), "status")] [JsonDerivedType(typeof(UnsupportedMessage), "unsupported")] -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 @@ -237,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/MessageStorageHandler.cs b/src/WhatsApp/MessageStorageHandler.cs new file mode 100644 index 0000000..399e272 --- /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 IStorageService storageService; + + public MessageStorageHandler(IWhatsAppHandler innerHandler, IStorageService 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, cancellation); + + await foreach (var response in base.HandleAsync(messages, cancellation)) + { + yield return response; + } + } +} \ 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..9e25ed4 100644 --- a/src/WhatsApp/Response.cs +++ b/src/WhatsApp/Response.cs @@ -1,12 +1,29 @@ -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. +/// The message this response is created for +public abstract partial record Response(Message Message) : IMessage { + /// + public string Id { get; set; } = string.Empty; + + /// + public string Number => Message.From.Number; + + /// + /// 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); } \ No newline at end of file diff --git a/src/WhatsApp/ResponseStorageHandler.cs b/src/WhatsApp/ResponseStorageHandler.cs new file mode 100644 index 0000000..c5117a3 --- /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 IStorageService storageService; + + public ResponseStorageHandler(IWhatsAppHandler innerHandler, IStorageService 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..24c6dad --- /dev/null +++ b/src/WhatsApp/SendResponsesHandler.cs @@ -0,0 +1,27 @@ +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 client; + + public SendResponsesHandler(IWhatsAppHandler innerHandler, IWhatsAppClient client) + : base(innerHandler) + { + this.client = client; + } + + public async override IAsyncEnumerable HandleAsync(IEnumerable messages, [EnumeratorCancellation] CancellationToken cancellation = default) + { + await foreach (var response in InnerHandler.HandleAsync(messages, cancellation)) + { + await response.SendAsync(client, cancellation); + + 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..5648350 --- /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..8919dde --- /dev/null +++ b/src/WhatsApp/StorageService.cs @@ -0,0 +1,28 @@ +namespace Devlooped.WhatsApp; + +class StorageService(CloudStorageAccount storage) : IStorageService +{ + const string MessagesTableName = "messages"; + + Lazy> messagesRepository = new(() => + DocumentRepository.Create( + storage, + MessagesTableName, + x => x.Number, + x => x.Id)); + + /// + public async Task SaveAsync(IEnumerable messages, CancellationToken cancellationToken = default) + { + var repository = messagesRepository.Value; + + foreach (var message in messages.Where(x => !string.IsNullOrEmpty(x.Id))) + { + await repository.PutAsync(message, cancellationToken); + } + } + + /// + public IAsyncEnumerable GetMessagesAsync(string number, CancellationToken cancellationToken = default) + => messagesRepository.Value.EnumerateAsync(number, cancellationToken); +} \ No newline at end of file diff --git a/src/WhatsApp/TemplateResponse.cs b/src/WhatsApp/TemplateResponse.cs index 42a3e7f..a11d634 100644 --- a/src/WhatsApp/TemplateResponse.cs +++ b/src/WhatsApp/TemplateResponse.cs @@ -1,10 +1,15 @@ 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) diff --git a/src/WhatsApp/TextResponse.cs b/src/WhatsApp/TextResponse.cs index 2fd4545..62a9a41 100644 --- a/src/WhatsApp/TextResponse.cs +++ b/src/WhatsApp/TextResponse.cs @@ -1,23 +1,28 @@ 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)) ?? string.Empty; + } + else + { + Id = await client.ReplyAsync(Message, Text) ?? string.Empty; } - - return client.ReplyAsync(Message, Text); } } \ No newline at end of file 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..3228232 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 IStorageService 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 IStorageService storageService) + { + return new MessageStorageHandler(inner, storageService); + } + + return WhatsAppHandler.Empty; + }); + + return builder; } ///