From d76d19fc7a3aeb5ba957bafe55d5003261595091 Mon Sep 17 00:00:00 2001 From: Daniel Cazzulino Date: Wed, 25 Jun 2025 16:04:35 -0300 Subject: [PATCH] Provide seamless continuity between CLI and WhatsApp When not running in production, the new `UseConsole` can be used to support scenarios where the CLI falls short, such as sharing contacts, image or other media. In these cases, if the `UseConsole` is added after the `UseConversation` handler, you can get responses for messages in both WhatsApp as well as the CLI, provided a previous conversation in the time window came from the console (to establish its service url). --- readme.md | 20 ++++++- src/SampleApp/Sample/Program.cs | 3 +- src/Tests/WhatsAppClientTests.cs | 2 +- src/WhatsApp/CompositeService.cs | 15 ++++++ src/WhatsApp/ConsoleHandlerExtensions.cs | 66 ++++++++++++++++++++++++ src/WhatsApp/MessageExtensions.cs | 14 +++-- src/WhatsApp/ReactionResponse.cs | 9 ++++ src/WhatsApp/TextResponse.cs | 25 +++++++-- src/WhatsApp/TypingResponse.cs | 13 ++++- 9 files changed, 153 insertions(+), 14 deletions(-) create mode 100644 src/WhatsApp/CompositeService.cs create mode 100644 src/WhatsApp/ConsoleHandlerExtensions.cs diff --git a/readme.md b/readme.md index 8c0d78b..1cc97fb 100644 --- a/readme.md +++ b/readme.md @@ -331,7 +331,6 @@ builder.Services.AddWhatsApp() .UseOpenTelemetry(builder.Environment.ApplicationName) .UseLogging() .UseIgnore() // 👈 Ignore status+unsupported messages. We do log them. - .UseStorage() .UseConversation(); ``` @@ -427,6 +426,25 @@ Options: to render the responses since it provides a more readable format than JSON. +For non-text messages, the CLI falls short since you cannot attach files or images. For these +cases, you can continue to send messages via WhatsApp, but get the responses also in the CLI. +This works by inspecting messages in the current conversation (so it depends on `UseConversation`) +and detecting if any messages were sent by the CLI. If that is the case, non-console messages +will generate responses for the CLI as well: + +```csharp +builder.Services.AddWhatsApp() + .UseOpenTelemetry(builder.Environment.ApplicationName) + .UseConversation() + .UseConsole() // 👈 Enable CLI support for WhatsApp-originated messages + +``` + +> [!IMPORTANT] +> `UseConsole` will only be added to the pipeline if the hosting environment is set to `Development`, +> so it's not necessary to check for that in your code. This is to ensure that the CLI behaviors +> never impact production environments. + ## Dogfooding diff --git a/src/SampleApp/Sample/Program.cs b/src/SampleApp/Sample/Program.cs index d5b84cc..54539d0 100644 --- a/src/SampleApp/Sample/Program.cs +++ b/src/SampleApp/Sample/Program.cs @@ -58,7 +58,8 @@ .UseOpenTelemetry(builder.Environment.ApplicationName) .UseLogging() .Use(EchoAndHandle) - .UseConversation(conversationWindowSeconds: 300 /* default */); + .UseConversation(conversationWindowSeconds: 300 /* default */) + .UseConsole(); // If event grid is set up, switch to processing messages using that if (builder.Configuration["EventGrid:Topic"] is { Length: > 0 } topic && diff --git a/src/Tests/WhatsAppClientTests.cs b/src/Tests/WhatsAppClientTests.cs index a0cd241..f63249b 100644 --- a/src/Tests/WhatsAppClientTests.cs +++ b/src/Tests/WhatsAppClientTests.cs @@ -56,8 +56,8 @@ public async Task ReplyToSentMessageAsync() Assert.NotEmpty(id); var reply = await client.ReplyAsync( - to, from, + to, id, "Reply here!"); diff --git a/src/WhatsApp/CompositeService.cs b/src/WhatsApp/CompositeService.cs new file mode 100644 index 0000000..362023b --- /dev/null +++ b/src/WhatsApp/CompositeService.cs @@ -0,0 +1,15 @@ +namespace Devlooped.WhatsApp; + +/// +/// Allows responding to console and WhatsApp simultaneously. +/// +record CompositeService : Service +{ + public CompositeService(Service primary, Service secondary) + : base(primary.Id, primary.Number) + { + Secondary = secondary; + } + + public Service Secondary { get; init; } +} diff --git a/src/WhatsApp/ConsoleHandlerExtensions.cs b/src/WhatsApp/ConsoleHandlerExtensions.cs new file mode 100644 index 0000000..1cdbcc8 --- /dev/null +++ b/src/WhatsApp/ConsoleHandlerExtensions.cs @@ -0,0 +1,66 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Devlooped.WhatsApp; + +/// +/// Extensions for configuring a console handler in the WhatsApp handler pipeline. +/// +public static class ConsoleHandlerExtensions +{ + /// + /// A development-only handler that marks messages sent via WhatsApp as coming from the + /// CLI if there is any user message in the conversation that came from the console. + /// + /// + /// This handler is very useful for testing scenarios where the console falls short, such + /// as sending media files, contacts, locations, etc. It allows sending these messages + /// from WhatsApp to the same number used by the console, so that the console can receive + /// the responses. + /// The corresponding handler is only added to the pipeline if the application is not + /// running in production mode as determined by the . + /// + public static WhatsAppHandlerBuilder UseConsole(this WhatsAppHandlerBuilder builder) + { + _ = Throw.IfNull(builder); + + return builder.Use((inner, services) => + { + // In production environments, we have ZERO impact since we're not even added to the pipeline. + if (services.GetRequiredService().IsProduction()) + return WhatsAppHandler.Skip; + + return new ConsoleHandler(inner); + }); + } + + class ConsoleHandler(IWhatsAppHandler inner) : DelegatingWhatsAppHandler(inner) + { + public override IAsyncEnumerable HandleAsync(IEnumerable messages, CancellationToken cancellation = default) + { + Service? console = null; + + messages = [.. messages.Select(message => + { + var user = message as UserMessage; + if (message.FromConsole && user is not null) + console ??= user.Service; + + // Mark non-console user messages as coming from the console by + // composing the service id with the console service id for dual reply + if (console != null && !message.FromConsole && user is not null) + { + return user with + { + Service = new CompositeService(user.Service, console), + FromConsole = true, + }; + } + // Otherwise, just return the message as is. + return message; + })]; + + return base.HandleAsync(messages, cancellation); + } + } +} \ No newline at end of file diff --git a/src/WhatsApp/MessageExtensions.cs b/src/WhatsApp/MessageExtensions.cs index 45a8560..1378599 100644 --- a/src/WhatsApp/MessageExtensions.cs +++ b/src/WhatsApp/MessageExtensions.cs @@ -23,7 +23,9 @@ public bool FromConsole /// Creates a reaction response for the user message. /// public static ReactionResponse React(this IMessage message, string emoji) - => new(message.ServiceId, message.UserNumber, message.Id, message.ConversationId, emoji); + => message is UserMessage user + ? new(user.Service, message.UserNumber, message.Id, message.ConversationId, emoji) + : new(message.ServiceId, message.UserNumber, message.Id, message.ConversationId, emoji); /// /// Creates a simple template response for the message. @@ -46,19 +48,23 @@ public static TemplateResponse Template(this IMessage message, object 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); + => new(message.Service, 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.ServiceId, message.UserNumber, message.Id, message.ConversationId, text); + => message is UserMessage user + ? new(user.Service, message.UserNumber, 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.ServiceId, message.UserNumber, message.Id, message.ConversationId, text, button1, button2); + => message is UserMessage user + ? new(user.Service, message.UserNumber, message.Id, message.ConversationId, text, button1, button2) + : new(message.ServiceId, message.UserNumber, 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 5c20f37..105ec47 100644 --- a/src/WhatsApp/ReactionResponse.cs +++ b/src/WhatsApp/ReactionResponse.cs @@ -11,9 +11,18 @@ /// The emoji representing the reaction to the message. public record ReactionResponse(string ServiceId, string UserNumber, string Context, string? ConversationId, string Emoji) : Response(ServiceId, UserNumber, Context, ConversationId) { + readonly CompositeService? service; + + internal ReactionResponse(Service service, string userNumber, string context, string? conversationId, string emoji) + : this(service.Id, userNumber, context, conversationId, emoji) + => this.service = service as CompositeService; + /// protected override async Task SendCoreAsync(IWhatsAppClient client, CancellationToken cancellationToken = default) { + if (service != null) + await client.ReactAsync(service.Secondary.Id, UserNumber, Context, Emoji, cancellationToken); + await client.ReactAsync(ServiceId, UserNumber, Context, Emoji); return Ulid.NewUlid().ToString(); diff --git a/src/WhatsApp/TextResponse.cs b/src/WhatsApp/TextResponse.cs index f68088c..2d538e9 100644 --- a/src/WhatsApp/TextResponse.cs +++ b/src/WhatsApp/TextResponse.cs @@ -13,18 +13,33 @@ /// An optional second button to include in the response for user interaction. public record TextResponse(string ServiceId, string UserNumber, string Context, string? ConversationId, string Text, Button? Button1 = default, Button? Button2 = default) : Response(ServiceId, UserNumber, Context, ConversationId) { + readonly CompositeService? service; + + internal TextResponse(Service service, string userNumber, string context, string? conversationId, string text, Button? button1 = default, Button? button2 = default) + : this(service.Id, userNumber, context, conversationId, text, button1, button2) + => this.service = service as CompositeService; + /// - protected override Task SendCoreAsync(IWhatsAppClient client, CancellationToken cancellationToken = default) + protected override async Task SendCoreAsync(IWhatsAppClient client, CancellationToken cancellation = default) + { + if (service != null) + await SendReplyAsync(client, service.Secondary.Id, cancellation); + + return await SendReplyAsync(client, ServiceId, cancellation); + } + + Task SendReplyAsync(IWhatsAppClient client, string serviceId, CancellationToken cancellation) { if (Button1 != null) { - return (Button2 == null ? - client.ReplyAsync(ServiceId, UserNumber, Context, Text, Button1) : - client.ReplyAsync(ServiceId, UserNumber, Context, Text, Button1, Button2)); + if (Button2 == null) + return client.ReplyAsync(serviceId, UserNumber, Context, Text, Button1, cancellation); + else + return client.ReplyAsync(serviceId, UserNumber, Context, Text, Button1, Button2, cancellation); } else { - return client.ReplyAsync(ServiceId, UserNumber, Context, Text); + return client.ReplyAsync(serviceId, UserNumber, Context, Text, cancellation); } } } \ No newline at end of file diff --git a/src/WhatsApp/TypingResponse.cs b/src/WhatsApp/TypingResponse.cs index 8db9efb..11633ef 100644 --- a/src/WhatsApp/TypingResponse.cs +++ b/src/WhatsApp/TypingResponse.cs @@ -1,5 +1,4 @@ - -namespace Devlooped.WhatsApp; +namespace Devlooped.WhatsApp; /// /// Represents a typing status update that can be sent in response to a user message. @@ -7,9 +6,19 @@ namespace Devlooped.WhatsApp; /// public record TypingResponse(string ServiceId, string UserNumber, string Context, string? ConversationId) : Response(ServiceId, UserNumber, Context, ConversationId) { + readonly CompositeService? service; + + internal TypingResponse(Service service, string userNumber, string context, string? conversationId) + : this(service.Id, userNumber, context, conversationId) + => this.service = service as CompositeService; + protected override async Task SendCoreAsync(IWhatsAppClient client, CancellationToken cancellation = default) { + if (service != null) + await client.SendTyping(service.Secondary.Id, Context, cancellation); + await client.SendTyping(ServiceId, Context, cancellation); + // These types of messages don't actually have an ID. return Ulid.NewUlid().ToString(); }