diff --git a/src/WhatsApp/AzureFunctions.cs b/src/WhatsApp/AzureFunctions.cs index 77fc04c..37f7a33 100644 --- a/src/WhatsApp/AzureFunctions.cs +++ b/src/WhatsApp/AzureFunctions.cs @@ -35,7 +35,7 @@ public async Task Message([HttpTrigger(AuthorizationLevel.Anonymo if (await WhatsApp.Message.DeserializeAsync(json) is { } message) { // Ensure idempotent processing - var table = tableClient.GetTableClient("whatsapp"); + var table = tableClient.GetTableClient("WhatsAppWebhook"); await table.CreateIfNotExistsAsync(); if (await table.GetEntityIfExistsAsync(message.User.Number, message.NotificationId) is { HasValue: true } existing) { @@ -44,7 +44,7 @@ public async Task Message([HttpTrigger(AuthorizationLevel.Anonymo } // Otherwise, queue the new message - var queue = queueClient.GetQueueClient("whatsapp"); + var queue = queueClient.GetQueueClient("whatsappwebhook"); await queue.CreateIfNotExistsAsync(); await queue.SendMessageAsync(json); @@ -73,7 +73,7 @@ public async Task Message([HttpTrigger(AuthorizationLevel.Anonymo } [Function("whatsapp_process")] - public async Task Process([QueueTrigger("whatsapp", Connection = "AzureWebJobsStorage")] string json) + public async Task Process([QueueTrigger("whatsappwebhook", Connection = "AzureWebJobsStorage")] string json) { logger.LogDebug("Processing WhatsApp message: {Message}", json); @@ -82,7 +82,7 @@ public async Task Process([QueueTrigger("whatsapp", Connection = "AzureWebJobsSt // Ensure idempotent processing at dequeue time, since we might have been called // multiple times for the same message by WhatsApp (Message method) while processing was still // happening (and therefore we didn't save the entity yet). - var table = tableClient.GetTableClient("whatsapp"); + var table = tableClient.GetTableClient("WhatsAppWebhook"); await table.CreateIfNotExistsAsync(); if (await table.GetEntityIfExistsAsync(message.User.Number, message.NotificationId) is { HasValue: true } existing) { diff --git a/src/WhatsApp/ConversationHandlerExtensions.cs b/src/WhatsApp/ConversationHandlerExtensions.cs index 1b0d37f..9afe541 100644 --- a/src/WhatsApp/ConversationHandlerExtensions.cs +++ b/src/WhatsApp/ConversationHandlerExtensions.cs @@ -16,7 +16,7 @@ public static WhatsAppHandlerBuilder UseConversation(this WhatsAppHandlerBuilder { // By adding the conversation service, the conversation handlers will be automatically added to the pipeline builder.Services.AddSingleton(services - => new ConversationService(services.GetRequiredService())); + => new ConversationService(services.GetRequiredService())); } return builder; diff --git a/src/WhatsApp/ConversationService.cs b/src/WhatsApp/ConversationService.cs index 71e5674..49e7ab0 100644 --- a/src/WhatsApp/ConversationService.cs +++ b/src/WhatsApp/ConversationService.cs @@ -12,7 +12,7 @@ public async IAsyncEnumerable GetConversationAsync(IMessage message, [ if (!string.IsNullOrEmpty(message.ConversationId)) { var conversation = storage - .GetMessagesAsync(message.Number, message.ConversationId, cancellationToken) + .GetMessagesAsync(message.UserNumber, message.ConversationId, cancellationToken) .OrderBy(x => x.Timestamp); await foreach (var conversationMessage in conversation) @@ -39,7 +39,7 @@ public async Task GetOrCreateConversationIdAsync(IMessage message, int s // Even if the timeout is expired if (!string.IsNullOrEmpty(message.Context)) { - var contextMsg = await storage.GetMessageAsync(message.Number, message.Context, cancellationToken); + var contextMsg = await storage.GetMessageAsync(message.UserNumber, message.Context, cancellationToken); if (contextMsg?.ConversationId is string contextConversationId && !string.IsNullOrEmpty(contextConversationId)) return contextConversationId; @@ -48,7 +48,7 @@ public async Task GetOrCreateConversationIdAsync(IMessage message, int s var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds() - seconds; // Use the conversation id for a message processed in the last ConversationWindowInSeconds seconds - var conversation = await storage.GetActiveConversationAsync(message.Number, cancellationToken); + var conversation = await storage.GetActiveConversationAsync(message.UserNumber, cancellationToken); var conversationId = conversation?.Id; if (conversationId == null || conversation?.Timestamp < timestamp) diff --git a/src/WhatsApp/IMessage.cs b/src/WhatsApp/IMessage.cs index ef8bd4d..654e67c 100644 --- a/src/WhatsApp/IMessage.cs +++ b/src/WhatsApp/IMessage.cs @@ -21,15 +21,20 @@ namespace Devlooped.WhatsApp; [JsonDerivedType(typeof(ReactionResponse), "response/reaction")] public interface IMessage { + /// + /// Gets the message id. + /// + string Id { get; } + /// /// Gets the phone number associated with the message sender. /// - string Number { get; } + string UserNumber { get; } /// - /// Gets the message id. + /// Gets the unique identifier for the service. /// - string Id { get; } + string ServiceId { get; } /// /// Gets the timestamp representing the number of milliseconds since the Unix epoch (January 1, 1970, 00:00:00 UTC). diff --git a/src/WhatsApp/Message.cs b/src/WhatsApp/Message.cs index 55e3ad6..b657b6a 100644 --- a/src/WhatsApp/Message.cs +++ b/src/WhatsApp/Message.cs @@ -252,5 +252,8 @@ .value.statuses[0] as $status | public abstract MessageType Type { get; } /// - public string Number => User.Number; + string IMessage.UserNumber => User.Number; + + /// + string IMessage.ServiceId => Service.Id; } \ No newline at end of file diff --git a/src/WhatsApp/MessageExtensions.cs b/src/WhatsApp/MessageExtensions.cs index fd23564..f3ff659 100644 --- a/src/WhatsApp/MessageExtensions.cs +++ b/src/WhatsApp/MessageExtensions.cs @@ -10,14 +10,14 @@ public static partial class MessageExtensions /// /// Creates a reaction response for the user message. /// - public static ReactionResponse React(this UserMessage message, string emoji) - => new(message.User.Number, message.Service.Id, message.Id, message.ConversationId, emoji); + public static ReactionResponse React(this IMessage message, string emoji) + => new(message.UserNumber, message.ServiceId, message.Id, message.ConversationId, emoji); /// /// Creates a simple template response for the message. /// - public static TemplateResponse Template(this Message message, string name, string language) - => new(message.User.Number, message.Service.Id, message.Id, message.ConversationId, name, language); + public static TemplateResponse Template(this IMessage message, string name, string language) + => new(message.UserNumber, message.Id, message.Id, message.ConversationId, name, language); /// /// Creates a complex template response for the message. @@ -27,20 +27,20 @@ public static TemplateResponse Template(this Message message, string name, strin /// /// /// - public static TemplateResponse Template(this Message message, object template) - => new(message.User.Number, message.Service.Id, message.Id, message.ConversationId, template); + public static TemplateResponse Template(this IMessage message, object template) + => new(message.UserNumber, message.ServiceId, message.Id, message.ConversationId, template); /// /// Creates a text response for the message. /// - public static TextResponse Reply(this Message message, string text) - => new(message.User.Number, message.Service.Id, message.Id, message.ConversationId, text); + public static TextResponse Reply(this IMessage message, string text) + => new(message.UserNumber, message.ServiceId, message.Id, message.ConversationId, text); /// /// Creates a text response with buttons for the message. /// - public static TextResponse Reply(this Message message, string text, Button button1, Button? button2 = default) - => new(message.User.Number, message.Service.Id, message.Id, message.ConversationId, text, button1, button2); + 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); /// /// Attempts to retrieve a single message from the specified collection. diff --git a/src/WhatsApp/ReactionResponse.cs b/src/WhatsApp/ReactionResponse.cs index cc50f54..800d239 100644 --- a/src/WhatsApp/ReactionResponse.cs +++ b/src/WhatsApp/ReactionResponse.cs @@ -14,7 +14,7 @@ public record ReactionResponse(string Number, string Service, string Context, st /// protected override async Task SendCoreAsync(IWhatsAppClient client, CancellationToken cancellationToken = default) { - await client.ReactAsync(Service, Number, Context, Emoji); + await client.ReactAsync(ServiceId, UserNumber, Context, Emoji); return Ulid.NewUlid().ToString(); } diff --git a/src/WhatsApp/Response.cs b/src/WhatsApp/Response.cs index 4aac335..1b920e6 100644 --- a/src/WhatsApp/Response.cs +++ b/src/WhatsApp/Response.cs @@ -4,14 +4,14 @@ /// 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 , , +/// 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. +/// The identifier of the service handling the message. /// 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 Number, string Service, string Context, string? ConversationId) : IMessage +public abstract partial record Response(string UserNumber, string ServiceId, string Context, string? ConversationId) : IMessage { /// public string Id { get; init; } = string.Empty; diff --git a/src/WhatsApp/RestoreConversationMessagesHandler.cs b/src/WhatsApp/RestoreConversationMessagesHandler.cs index b5869f9..34fc55c 100644 --- a/src/WhatsApp/RestoreConversationMessagesHandler.cs +++ b/src/WhatsApp/RestoreConversationMessagesHandler.cs @@ -2,30 +2,41 @@ namespace Devlooped.WhatsApp; -class RestoreConversationMessagesHandler(IWhatsAppHandler innerHandler, IConversationService conversationService) : DelegatingWhatsAppHandler(innerHandler) +/// +/// Represents configuration options for a conversation. +/// +/// This record is used to specify settings that control the behavior of a conversation. +/// A value indicating whether to restore previous messages in the conversation. to restore +/// messages; otherwise, . +public record ConversationOptions(bool RestoreMessages = true); + +class RestoreConversationMessagesHandler(IWhatsAppHandler innerHandler, IConversationService conversationService, ConversationOptions options) : DelegatingWhatsAppHandler(innerHandler) { public override async IAsyncEnumerable HandleAsync(IEnumerable messages, [EnumeratorCancellation] CancellationToken cancellation = default) { - IEnumerable conversation; + IEnumerable conversation = messages; - // Optimization to avoid creating the list when there is only 1 message to be processed - if (messages.TrySingle(out var single)) - { - conversation = await conversationService.GetConversationAsync(single, cancellation).ToArrayAsync(); - } - else + if (options.RestoreMessages) { - var conversationList = new List(); - - foreach (var message in messages) + // Optimization to avoid creating the list when there is only 1 message to be processed + if (messages.TrySingle(out var single)) { - await foreach (var conversationMessage in conversationService.GetConversationAsync(message, cancellation)) + conversation = await conversationService.GetConversationAsync(single, cancellation).ToArrayAsync(); + } + else + { + var conversationList = new List(); + + foreach (var message in messages) { - conversationList.Add(conversationMessage); + await foreach (var conversationMessage in conversationService.GetConversationAsync(message, cancellation)) + { + conversationList.Add(conversationMessage); + } } - } - conversation = conversationList; + conversation = conversationList; + } } await foreach (var response in base.HandleAsync(conversation, cancellation)) diff --git a/src/WhatsApp/StorageService.cs b/src/WhatsApp/StorageService.cs index 18a39bd..2cb0cfb 100644 --- a/src/WhatsApp/StorageService.cs +++ b/src/WhatsApp/StorageService.cs @@ -7,15 +7,14 @@ class StorageService(CloudStorageAccount storage, IFeatureManager featureManager { readonly List EmptyList = new(); - const string MessagesTableName = "messages"; - const string ConversationsTableName = "conversations"; - const string ActiveConversationTableName = "conversation"; + const string MessagesTableName = "WhatsAppMessages"; + const string ConversationsTableName = "WhatsAppConversations"; Lazy> messagesRepository = new(() => DocumentRepository.Create( storage, MessagesTableName, - x => x.Number, + x => x.UserNumber, x => x.Id)); Lazy> conversationsRepository = new(() => @@ -28,9 +27,11 @@ class StorageService(CloudStorageAccount storage, IFeatureManager featureManager Lazy> activeConversationRepository = new(() => DocumentRepository.Create( storage, - ActiveConversationTableName, + ConversationsTableName, x => x.Number, // We only have one active conversation by number + // NOTE: we can use the same table name since no conversation will + // ever have the ID 'active' x => "active")); /// @@ -38,8 +39,8 @@ public async Task SaveAsync(IMessage message, CancellationToken cancellationToke { if (!string.IsNullOrEmpty(message.ConversationId) && await featureManager.IsEnabledAsync(FeatureFlags.Conversation)) { - var conversation = await conversationsRepository.Value.GetAsync(message.Number, message.ConversationId, cancellationToken) ?? - new(message.Number, message.ConversationId, new(), message.Timestamp); + var conversation = await conversationsRepository.Value.GetAsync(message.UserNumber, message.ConversationId, cancellationToken) ?? + new(message.UserNumber, message.ConversationId, new(), message.Timestamp); conversation.Messages.Add(message); diff --git a/src/WhatsApp/TemplateResponse.cs b/src/WhatsApp/TemplateResponse.cs index 6c6f5b0..b5035e7 100644 --- a/src/WhatsApp/TemplateResponse.cs +++ b/src/WhatsApp/TemplateResponse.cs @@ -23,7 +23,7 @@ public TemplateResponse(string Number, string Service, string Context, string? C /// protected override async Task SendCoreAsync(IWhatsAppClient client, CancellationToken cancellationToken = default) { - await client.SendTemplateAsync(Service, Number, Template, cancellationToken); + await client.SendTemplateAsync(ServiceId, UserNumber, Template, cancellationToken); return Ulid.NewUlid().ToString(); } diff --git a/src/WhatsApp/TextResponse.cs b/src/WhatsApp/TextResponse.cs index f9f3108..4ba671a 100644 --- a/src/WhatsApp/TextResponse.cs +++ b/src/WhatsApp/TextResponse.cs @@ -19,12 +19,12 @@ public record TextResponse(string Number, string Service, string Context, string if (Button1 != null) { return (Button2 == null ? - client.ReplyAsync(Number, Service, Context, Text, Button1) : - client.ReplyAsync(Number, Service, Context, Text, Button1, Button2)); + client.ReplyAsync(UserNumber, ServiceId, Context, Text, Button1) : + client.ReplyAsync(UserNumber, ServiceId, Context, Text, Button1, Button2)); } else { - return client.ReplyAsync(Number, Service, Context, Text); + return client.ReplyAsync(UserNumber, ServiceId, Context, Text); } } } \ No newline at end of file diff --git a/src/WhatsApp/WhatsAppServiceCollectionExtensions.cs b/src/WhatsApp/WhatsAppServiceCollectionExtensions.cs index abee7fd..1e0256f 100644 --- a/src/WhatsApp/WhatsAppServiceCollectionExtensions.cs +++ b/src/WhatsApp/WhatsAppServiceCollectionExtensions.cs @@ -99,7 +99,7 @@ public static WhatsAppHandlerBuilder AddWhatsApp( // Check if the conversation capability was enabled by getting the conversation service if (services.GetService() is IConversationService conversationService) { - return new RestoreConversationMessagesHandler(inner, conversationService); + return new RestoreConversationMessagesHandler(inner, conversationService, services.GetService() ?? new()); } return new DelegatingWhatsAppHandler(inner);