diff --git a/src/SampleApp/Sample/Program.cs b/src/SampleApp/Sample/Program.cs index 9729cf8..8694e6d 100644 --- a/src/SampleApp/Sample/Program.cs +++ b/src/SampleApp/Sample/Program.cs @@ -1,4 +1,5 @@ -using System.Text.Json; +using System.Runtime.CompilerServices; +using System.Text.Json; using System.Text.Json.Serialization; using Devlooped.WhatsApp; using Microsoft.Azure.Functions.Worker.Builder; @@ -34,71 +35,64 @@ }); builder.Services - .AddWhatsApp, JsonSerializerOptions>(async (client, logger, options, messages, cancellation) => - { - var message = messages.Last(); - logger.LogInformation("💬 Received message: {Message}", message); + .AddWhatsApp, JsonSerializerOptions>(ProcessMessagesAsync) + // Matches what we use in ConfigureOpenTelemetry + .UseOpenTelemetry(builder.Environment.ApplicationName) + .UseLogging(); - if (message is ErrorMessage error) - { - // Reengagement error, we need to invite the user. - if (error.Error.Code == 131047) - { - await client.SendAsync(error.To.Id, new - { - messaging_product = "whatsapp", - to = error.From.Number, - type = "template", - template = new - { - name = "reengagement", - language = new - { - code = "es_AR" - } - } - }); - } - else - { - logger.LogWarning("⚠️ Unknown error message received: {Error}", message); - } - return; - } - else if (message is InteractiveMessage interactive) - { - logger.LogWarning("👤 chose {Button} ({Title})", interactive.Button.Id, interactive.Button.Title); - await client.ReplyAsync(interactive, $"👤 chose: {interactive.Button.Title} ({interactive.Button.Id})"); - return; - } - else if (message is ReactionMessage reaction) - { - logger.LogInformation("👤 reaction: {Reaction}", reaction.Emoji); - await client.ReplyAsync(reaction, $"👤 reaction: {reaction.Emoji}"); - return; - } - else if (message is StatusMessage status) - { - logger.LogInformation("☑️ status: {Status}", status.Status); - return; - } - else if (message is ContentMessage content) +builder.Build().Run(); + +static async IAsyncEnumerable ProcessMessagesAsync( + ILogger logger, + JsonSerializerOptions options, + IEnumerable messages, + [EnumeratorCancellation] CancellationToken cancellationToken) +{ + // Avoid warning CS1998 // Async method lacks 'await' operators and will run synchronously + await Task.CompletedTask; + + var message = messages.Last(); + logger.LogInformation("💬 Received message: {Message}", message); + + if (message is ErrorMessage error) + { + // Reengagement error, we need to invite the user. + if (error.Error.Code == 131047) { - await client.ReactAsync(content, "🧠"); - // simulate some hard work at hand, like doing some LLM-stuff :) - //await Task.Delay(2000); - await client.ReplyAsync(content, $"☑️ Got your {content.Content.Type}:\r\n{JsonSerializer.Serialize(content, options)}", - new Button("btn_good", "👍"), - new Button("btn_bad", "👎")); + yield return error.Reengage(); } - else if (message is UnsupportedMessage unsupported) + else { - logger.LogWarning("⚠️ {Message}", unsupported); - return; + logger.LogWarning("⚠️ Unknown error message received: {Error}", message); } - }) - // Matches what we use in ConfigureOpenTelemetry - .UseOpenTelemetry(builder.Environment.ApplicationName) - .UseLogging(); + } + else if (message is InteractiveMessage interactive) + { + logger.LogWarning("👤 chose {Button} ({Title})", interactive.Button.Id, interactive.Button.Title); + yield return interactive.Text($"👤 chose: {interactive.Button.Title} ({interactive.Button.Id})"); + } + else if (message is ReactionMessage reaction) + { + logger.LogInformation("👤 reaction: {Reaction}", reaction.Emoji); + yield return reaction.Text($"👤 reaction: {reaction.Emoji}"); + } + else if (message is StatusMessage status) + { + logger.LogInformation("☑️ status: {Status}", status.Status); + } + else if (message is ContentMessage content) + { + yield return content.React("🧠"); -builder.Build().Run(); + // simulate some hard work at hand, like doing some LLM-stuff :) + //await Task.Delay(2000); + yield return content.TextWithButtons( + $"☑️ Got your {content.Content.Type}:\r\n{JsonSerializer.Serialize(content, options)}", + new Button("btn_good", "👍"), + new Button("btn_bad", "👎")); + } + else if (message is UnsupportedMessage unsupported) + { + logger.LogWarning("⚠️ {Message}", unsupported); + } +} diff --git a/src/Tests/PipelineTests.cs b/src/Tests/PipelineTests.cs index 0b01d99..f0856e1 100644 --- a/src/Tests/PipelineTests.cs +++ b/src/Tests/PipelineTests.cs @@ -46,7 +46,8 @@ public async Task CanBuildLoggingPipeline() Assert.True(before); Assert.True(target); target = true; - return Task.CompletedTask; + + return AsyncEnumerable.Empty(); })) .Use((message, inner, cancellation) => { diff --git a/src/Tests/WhatsAppHandlerExtensions.cs b/src/Tests/WhatsAppHandlerExtensions.cs index b38d8d9..ce75d69 100644 --- a/src/Tests/WhatsAppHandlerExtensions.cs +++ b/src/Tests/WhatsAppHandlerExtensions.cs @@ -3,5 +3,5 @@ public static class WhatsAppHandlerExtensions { public static Task HandleAsync(this IWhatsAppHandler handler, Message message, CancellationToken cancellation = default) - => handler.HandleAsync([message], cancellation); -} + => handler.HandleAsync([message], cancellation).ForEachAsync(x => { }, cancellation); +} \ No newline at end of file diff --git a/src/WhatsApp/AnonymousDelegatingWhatsAppHandler.cs b/src/WhatsApp/AnonymousDelegatingWhatsAppHandler.cs index cb65883..5836901 100644 --- a/src/WhatsApp/AnonymousDelegatingWhatsAppHandler.cs +++ b/src/WhatsApp/AnonymousDelegatingWhatsAppHandler.cs @@ -11,11 +11,11 @@ namespace Devlooped.WhatsApp; /// A delegate that provides the implementation for class AnonymousDelegatingWhatsAppHandler( IWhatsAppHandler innerHandler, - Func, IWhatsAppHandler, CancellationToken, Task> handlerFunc) : DelegatingWhatsAppHandler(innerHandler) + Func, IWhatsAppHandler, CancellationToken, IAsyncEnumerable> handlerFunc) : DelegatingWhatsAppHandler(innerHandler) { /// The delegate to use as the implementation of . - readonly Func, IWhatsAppHandler, CancellationToken, Task> handlerFunc = Throw.IfNull(handlerFunc); + readonly Func, IWhatsAppHandler, CancellationToken, IAsyncEnumerable> handlerFunc = Throw.IfNull(handlerFunc); - public override Task HandleAsync(IEnumerable messages, CancellationToken cancellation = default) + public override IAsyncEnumerable HandleAsync(IEnumerable messages, CancellationToken cancellation = default) => handlerFunc(messages, InnerHandler, cancellation); } \ No newline at end of file diff --git a/src/WhatsApp/AnonymousWhatsAppHandler.cs b/src/WhatsApp/AnonymousWhatsAppHandler.cs index ffea1dc..3bd92fc 100644 --- a/src/WhatsApp/AnonymousWhatsAppHandler.cs +++ b/src/WhatsApp/AnonymousWhatsAppHandler.cs @@ -6,101 +6,101 @@ public class AnonymousWhatsAppHandler : IWhatsAppHandler { readonly IServiceProvider services; - readonly Func, CancellationToken, Task> handler; + readonly Func, CancellationToken, IAsyncEnumerable> handler; - AnonymousWhatsAppHandler(IServiceProvider services, Func, CancellationToken, Task> handler) + AnonymousWhatsAppHandler(IServiceProvider services, Func, CancellationToken, IAsyncEnumerable> handler) => (this.services, this.handler) = (Throw.IfNull(services), Throw.IfNull(handler)); - AnonymousWhatsAppHandler(IServiceProvider services, Func, CancellationToken, Task> handler) + AnonymousWhatsAppHandler(IServiceProvider services, Func, CancellationToken, IAsyncEnumerable> handler) : this(services, (_, messages, cancellation) => handler(messages, cancellation)) { } /// - public Task HandleAsync(IEnumerable messages, CancellationToken cancellation = default) + public IAsyncEnumerable HandleAsync(IEnumerable messages, CancellationToken cancellation = default) => handler(services, messages, cancellation); /// /// Creates a new instance of an with the specified service provider and message /// handler. /// - public static IWhatsAppHandler Create(IServiceProvider services, Func, CancellationToken, Task> handler) + public static IWhatsAppHandler Create(IServiceProvider services, Func, CancellationToken, IAsyncEnumerable> handler) => new AnonymousWhatsAppHandler(services, handler); /// /// Creates a new instance of an with the specified service provider and message /// handler. /// - public static IWhatsAppHandler Create(IServiceProvider services, Func, CancellationToken, Task> handler) + public static IWhatsAppHandler Create(IServiceProvider services, Func, CancellationToken, IAsyncEnumerable> handler) => new AnonymousWhatsAppHandler(services, handler); /// /// Creates a new instance of an using the specified service and message handler /// function. /// - public static IWhatsAppHandler Create(TService service, Func, CancellationToken, Task> handler) + public static IWhatsAppHandler Create(TService service, Func, CancellationToken, IAsyncEnumerable> handler) => new AnonymousWhatsAppHandler1(service, handler); /// /// Creates a new instance of a WhatsApp message handler that processes messages using the specified services and /// handler function. /// - public static IWhatsAppHandler Create(TService1 service1, TService2 service2, Func, CancellationToken, Task> handler) + public static IWhatsAppHandler Create(TService1 service1, TService2 service2, Func, CancellationToken, IAsyncEnumerable> handler) => new AnonymousWhatsAppHandler2(service1, service2, handler); /// /// Creates a new instance of a WhatsApp message handler that processes messages using the specified services and /// handler function. /// - public static IWhatsAppHandler Create(TService1 service1, TService2 service2, TService3 service3, Func, CancellationToken, Task> handler) + public static IWhatsAppHandler Create(TService1 service1, TService2 service2, TService3 service3, Func, CancellationToken, IAsyncEnumerable> handler) => new AnonymousWhatsAppHandler3(service1, service2, service3, handler); /// /// Creates a new instance of a WhatsApp message handler that processes messages using the specified services and /// handler function. /// - public static IWhatsAppHandler Create(TService1 service1, TService2 service2, TService3 service3, TService4 service4, Func, CancellationToken, Task> handler) + public static IWhatsAppHandler Create(TService1 service1, TService2 service2, TService3 service3, TService4 service4, Func, CancellationToken, IAsyncEnumerable> handler) => new AnonymousWhatsAppHandler4(service1, service2, service3, service4, handler); /// /// Creates a new instance of a WhatsApp message handler that processes messages using the specified services and /// handler function. /// - public static IWhatsAppHandler Create(TService1 service1, TService2 service2, TService3 service3, TService4 service4, TService5 service5, Func, CancellationToken, Task> handler) + public static IWhatsAppHandler Create(TService1 service1, TService2 service2, TService3 service3, TService4 service4, TService5 service5, Func, CancellationToken, IAsyncEnumerable> handler) => new AnonymousWhatsAppHandler5(service1, service2, service3, service4, service5, handler); /// /// Creates a new instance of a WhatsApp message handler that processes messages using the specified services and /// handler function. /// - public static IWhatsAppHandler Create(TService1 service1, TService2 service2, TService3 service3, TService4 service4, TService5 service5, TService6 service6, Func, CancellationToken, Task> handler) + public static IWhatsAppHandler Create(TService1 service1, TService2 service2, TService3 service3, TService4 service4, TService5 service5, TService6 service6, Func, CancellationToken, IAsyncEnumerable> handler) => new AnonymousWhatsAppHandler6(service1, service2, service3, service4, service5, service6, handler); - class AnonymousWhatsAppHandler1(TService service, Func, CancellationToken, Task> handler) : IWhatsAppHandler + class AnonymousWhatsAppHandler1(TService service, Func, CancellationToken, IAsyncEnumerable> handler) : IWhatsAppHandler { - public Task HandleAsync(IEnumerable messages, CancellationToken cancellation = default) => handler(service, messages, cancellation); + public IAsyncEnumerable HandleAsync(IEnumerable messages, CancellationToken cancellation = default) => handler(service, messages, cancellation); } - class AnonymousWhatsAppHandler2(TService1 service1, TService2 service2, Func, CancellationToken, Task> handler) : IWhatsAppHandler + class AnonymousWhatsAppHandler2(TService1 service1, TService2 service2, Func, CancellationToken, IAsyncEnumerable> handler) : IWhatsAppHandler { - public Task HandleAsync(IEnumerable messages, CancellationToken cancellation = default) => handler(service1, service2, messages, cancellation); + public IAsyncEnumerable HandleAsync(IEnumerable messages, CancellationToken cancellation = default) => handler(service1, service2, messages, cancellation); } - class AnonymousWhatsAppHandler3(TService1 service1, TService2 service2, TService3 service3, Func, CancellationToken, Task> handler) : IWhatsAppHandler + class AnonymousWhatsAppHandler3(TService1 service1, TService2 service2, TService3 service3, Func, CancellationToken, IAsyncEnumerable> handler) : IWhatsAppHandler { - public Task HandleAsync(IEnumerable messages, CancellationToken cancellation = default) => handler(service1, service2, service3, messages, cancellation); + public IAsyncEnumerable HandleAsync(IEnumerable messages, CancellationToken cancellation = default) => handler(service1, service2, service3, messages, cancellation); } - class AnonymousWhatsAppHandler4(TService1 service1, TService2 service2, TService3 service3, TService4 service4, Func, CancellationToken, Task> handler) : IWhatsAppHandler + class AnonymousWhatsAppHandler4(TService1 service1, TService2 service2, TService3 service3, TService4 service4, Func, CancellationToken, IAsyncEnumerable> handler) : IWhatsAppHandler { - public Task HandleAsync(IEnumerable messages, CancellationToken cancellation = default) => handler(service1, service2, service3, service4, messages, cancellation); + public IAsyncEnumerable HandleAsync(IEnumerable messages, CancellationToken cancellation = default) => handler(service1, service2, service3, service4, messages, cancellation); } - class AnonymousWhatsAppHandler5(TService1 service1, TService2 service2, TService3 service3, TService4 service4, TService5 service5, Func, CancellationToken, Task> handler) : IWhatsAppHandler + class AnonymousWhatsAppHandler5(TService1 service1, TService2 service2, TService3 service3, TService4 service4, TService5 service5, Func, CancellationToken, IAsyncEnumerable> handler) : IWhatsAppHandler { - public Task HandleAsync(IEnumerable messages, CancellationToken cancellation = default) => handler(service1, service2, service3, service4, service5, messages, cancellation); + public IAsyncEnumerable HandleAsync(IEnumerable messages, CancellationToken cancellation = default) => handler(service1, service2, service3, service4, service5, messages, cancellation); } - class AnonymousWhatsAppHandler6(TService1 service1, TService2 service2, TService3 service3, TService4 service4, TService5 service5, TService6 service6, Func, CancellationToken, Task> handler) : IWhatsAppHandler + class AnonymousWhatsAppHandler6(TService1 service1, TService2 service2, TService3 service3, TService4 service4, TService5 service5, TService6 service6, Func, CancellationToken, IAsyncEnumerable> handler) : IWhatsAppHandler { - public Task HandleAsync(IEnumerable messages, CancellationToken cancellation = default) => handler(service1, service2, service3, service4, service5, service6, messages, cancellation); + public IAsyncEnumerable HandleAsync(IEnumerable messages, CancellationToken cancellation = default) => handler(service1, service2, service3, service4, service5, service6, messages, cancellation); } } \ No newline at end of file diff --git a/src/WhatsApp/AzureFunctions.cs b/src/WhatsApp/AzureFunctions.cs index d705315..ff80f78 100644 --- a/src/WhatsApp/AzureFunctions.cs +++ b/src/WhatsApp/AzureFunctions.cs @@ -90,7 +90,12 @@ public async Task Process([QueueTrigger("whatsapp", Connection = "AzureWebJobsSt return; } - await handler.HandleAsync([message]); + // Send responses + await foreach (var response in handler.HandleAsync([message])) + { + await response.SendAsync(whatsapp); + } + await table.UpsertEntityAsync(new TableEntity(message.From.Number, message.Id)); logger.LogInformation($"Completed work item: {message.Id}"); } diff --git a/src/WhatsApp/DelegatingWhatsAppHandler.cs b/src/WhatsApp/DelegatingWhatsAppHandler.cs index fa30526..1b1b715 100644 --- a/src/WhatsApp/DelegatingWhatsAppHandler.cs +++ b/src/WhatsApp/DelegatingWhatsAppHandler.cs @@ -26,7 +26,7 @@ public void Dispose() } /// - public virtual Task HandleAsync(IEnumerable messages, CancellationToken cancellation = default) => InnerHandler.HandleAsync(messages, cancellation); + public virtual IAsyncEnumerable HandleAsync(IEnumerable messages, CancellationToken cancellation = default) => InnerHandler.HandleAsync(messages, cancellation); /// Provides a mechanism for releasing unmanaged resources. /// if being called from ; otherwise, . diff --git a/src/WhatsApp/IWhatsAppClient.cs b/src/WhatsApp/IWhatsAppClient.cs index 3e1b1c9..3d04212 100644 --- a/src/WhatsApp/IWhatsAppClient.cs +++ b/src/WhatsApp/IWhatsAppClient.cs @@ -21,11 +21,12 @@ public interface IWhatsAppClient /// Sends a raw payload object that must match the WhatsApp API. /// /// The phone identifier to send the message from, which must be configured via . - /// The message payload. + /// The message payload.> + /// The cancellation token. /// The message id that was sent/reacted/marked, if any. /// /// The number is not registered in . /// The HTTP request failed. Exception message contains the error response body from WhatsApp. [Description(nameof(Devlooped) + nameof(WhatsApp) + nameof(IWhatsAppClient) + nameof(SendAsync))] - Task SendAsync(string numberId, object payload); + Task SendAsync(string numberId, object payload, CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/src/WhatsApp/IWhatsAppHandler.cs b/src/WhatsApp/IWhatsAppHandler.cs index ccbde3c..c2ca86c 100644 --- a/src/WhatsApp/IWhatsAppHandler.cs +++ b/src/WhatsApp/IWhatsAppHandler.cs @@ -25,5 +25,5 @@ public interface IWhatsAppHandler /// After the max dequeue retries, the message will be moved to the whatsapp-poison /// queue. /// - Task HandleAsync(IEnumerable messages, CancellationToken cancellation = default); + IAsyncEnumerable HandleAsync(IEnumerable messages, CancellationToken cancellation = default); } diff --git a/src/WhatsApp/LoggingHandler.cs b/src/WhatsApp/LoggingHandler.cs index 23a2c9b..1c43607 100644 --- a/src/WhatsApp/LoggingHandler.cs +++ b/src/WhatsApp/LoggingHandler.cs @@ -14,7 +14,7 @@ public JsonSerializerOptions JsonSerializerOptions set => options = Throw.IfNull(value); } - public override async Task HandleAsync(IEnumerable messages, CancellationToken cancellation = default) + public override IAsyncEnumerable HandleAsync(IEnumerable messages, CancellationToken cancellation = default) { if (logger.IsEnabled(LogLevel.Debug)) { @@ -24,22 +24,16 @@ public override async Task HandleAsync(IEnumerable messages, Cancellati LogInvoked(nameof(HandleAsync)); } - try - { - await base.HandleAsync(messages, cancellation); - if (logger.IsEnabled(LogLevel.Debug)) - LogCompleted(nameof(HandleAsync)); - } - catch (OperationCanceledException) - { - LogInvocationCanceled(nameof(HandleAsync)); - throw; - } - catch (Exception ex) - { - LogInvocationFailed(nameof(HandleAsync), ex); - throw; - } + return base.HandleAsync(messages, cancellation).WithErrorHandlingAsync( + errorCallback: ex => + { + if (ex is OperationCanceledException) + LogInvocationCanceled(nameof(HandleAsync)); + else + LogInvocationFailed(nameof(HandleAsync), ex); + }, + completionCallback: logger.IsEnabled(LogLevel.Debug) ? () => LogCompleted(nameof(HandleAsync)) : null, + cancellation: cancellation); } /// Serializes as JSON for logging purposes. diff --git a/src/WhatsApp/MessageExtensions.cs b/src/WhatsApp/MessageExtensions.cs new file mode 100644 index 0000000..6995166 --- /dev/null +++ b/src/WhatsApp/MessageExtensions.cs @@ -0,0 +1,31 @@ +namespace Devlooped.WhatsApp; + +/// +/// Usability extensions for creating responses for user messages. +/// +public static partial class MessageExtensions +{ + /// + /// Creates a reaction response for the user message. + /// + public static ReactionResponse React(this UserMessage message, string emoji) + => new ReactionResponse(message, emoji); + + /// + /// Creates a reengagement response for the error message. + /// + public static TemplateResponse Reengage(this ErrorMessage message) + => new TemplateResponse(message, "reengagement", "es_AR"); + + /// + /// Creates a text response for the message. + /// + public static TextResponse Text(this Message message, string text) + => new TextResponse(message, text); + + /// + /// Creates a text response with buttons for the message. + /// + public static TextResponse TextWithButtons(this Message message, string text, Button button1, Button? button2 = default) + => new TextResponse(message, text, button1, button2); +} \ No newline at end of file diff --git a/src/WhatsApp/OpenTelemetryHandler.cs b/src/WhatsApp/OpenTelemetryHandler.cs index 9864197..db7c5fb 100644 --- a/src/WhatsApp/OpenTelemetryHandler.cs +++ b/src/WhatsApp/OpenTelemetryHandler.cs @@ -1,5 +1,7 @@ using System.Diagnostics; using System.Diagnostics.Metrics; +using System.Reflection.Metadata.Ecma335; +using Microsoft.Extensions.Logging; namespace Devlooped.WhatsApp; @@ -67,49 +69,45 @@ protected override void Dispose(bool disposing) /// public bool EnableSensitiveData { get; set; } - public override async Task HandleAsync(IEnumerable messages, CancellationToken cancellation = default) + public override IAsyncEnumerable HandleAsync(IEnumerable messages, CancellationToken cancellation = default) { // In a conversation, the last message is the most recent one sent by the user. // This is just in case the handler is not configured as the first in the pipeline. var message = messages.LastOrDefault(); if (message is null) { - await base.HandleAsync(messages, cancellation); - return; + return base.HandleAsync(messages, cancellation); } - - using var span = activitySource.StartActivity("whatsapp process", ActivityKind.Consumer); - if (span != null) + else { - span.SetTag("messaging.system", "whatsapp"); - span.SetTag("messaging.destination", "whatsapp"); - span.SetTag("messaging.operation", "process"); - span.SetTag("messaging.message.id", message.Id); - if (message.Context is string { } conversationId) - span.SetTag("messaging.message.conversation_id", conversationId); - } + using var span = activitySource.StartActivity("whatsapp process", ActivityKind.Consumer); + if (span != null) + { + span.SetTag("messaging.system", "whatsapp"); + span.SetTag("messaging.destination", "whatsapp"); + span.SetTag("messaging.operation", "process"); + span.SetTag("messaging.message.id", message.Id); + if (message.Context is string { } conversationId) + span.SetTag("messaging.message.conversation_id", conversationId); + } - var startTime = Stopwatch.GetTimestamp(); - var tags = new TagList - { - { "messaging.system", "whatsapp" }, - { "messaging.operation", "process" }, - }; + var startTime = Stopwatch.GetTimestamp(); + var tags = new TagList + { + { "messaging.system", "whatsapp" }, + { "messaging.operation", "process" }, + }; - try - { - await base.HandleAsync(messages, cancellation); - messagesProcessed.Add(1, tags); - } - catch (Exception ex) - { - messagesProcessed.Add(1, span.RecordException(ex, EnableSensitiveData, tags)); - throw; - } - finally - { - var duration = Stopwatch.GetElapsedTime(startTime).TotalSeconds; - processDuration.Record(duration, tags); + + return base.HandleAsync(messages, cancellation).WithErrorHandlingAsync( + errorCallback: ex => messagesProcessed.Add(1, span.RecordException(ex, EnableSensitiveData, tags)), + completionCallback: () => messagesProcessed.Add(1, tags), + finallyCallback: () => + { + var duration = Stopwatch.GetElapsedTime(startTime).TotalSeconds; + processDuration.Record(duration, tags); + }, + cancellation); } } -} +} \ No newline at end of file diff --git a/src/WhatsApp/ReactionResponse.cs b/src/WhatsApp/ReactionResponse.cs new file mode 100644 index 0000000..2d2f3db --- /dev/null +++ b/src/WhatsApp/ReactionResponse.cs @@ -0,0 +1,13 @@ +namespace Devlooped.WhatsApp; + +/// +/// A reaction response to a user message. +/// +/// The message this reaction applies to. +/// The emoji of the reaction. +public record ReactionResponse(UserMessage UserMessage, string Emoji) : Response +{ + /// + internal override Task SendAsync(IWhatsAppClient client, CancellationToken cancellationToken = default) + => client.ReactAsync(UserMessage, Emoji); +} \ No newline at end of file diff --git a/src/WhatsApp/Response.cs b/src/WhatsApp/Response.cs new file mode 100644 index 0000000..1786636 --- /dev/null +++ b/src/WhatsApp/Response.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; +using static System.Runtime.InteropServices.JavaScript.JSType; + +namespace Devlooped.WhatsApp; + +/// +/// Base class for responses. +/// +public abstract partial record Response +{ + internal abstract Task SendAsync(IWhatsAppClient client, CancellationToken cancellation = default); +} \ No newline at end of file diff --git a/src/WhatsApp/TemplateResponse.cs b/src/WhatsApp/TemplateResponse.cs new file mode 100644 index 0000000..42a3e7f --- /dev/null +++ b/src/WhatsApp/TemplateResponse.cs @@ -0,0 +1,12 @@ +namespace Devlooped.WhatsApp; + +/// +/// A template response to a user message. +/// +/// The message this reaction applies to. +public record TemplateResponse(Message Message, string Name, string Code) : Response +{ + /// + internal override Task SendAsync(IWhatsAppClient client, CancellationToken cancellationToken = default) + => client.SendTemplateAsync(Message.To.Id, Message.From.Number, Name, Code, cancellationToken); +} \ No newline at end of file diff --git a/src/WhatsApp/TextResponse.cs b/src/WhatsApp/TextResponse.cs new file mode 100644 index 0000000..2fd4545 --- /dev/null +++ b/src/WhatsApp/TextResponse.cs @@ -0,0 +1,23 @@ +namespace Devlooped.WhatsApp; + +/// +/// A simple text response to a user 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 +{ + /// + internal override Task SendAsync(IWhatsAppClient client, CancellationToken cancellationToken = default) + { + if (Button1 != null) + { + return Button2 == null ? + client.ReplyAsync(Message, Text, Button1) : + client.ReplyAsync(Message, Text, Button1, Button2); + + } + + return client.ReplyAsync(Message, Text); + } +} \ No newline at end of file diff --git a/src/WhatsApp/WhatsApp.csproj b/src/WhatsApp/WhatsApp.csproj index 98d7099..34baff6 100644 --- a/src/WhatsApp/WhatsApp.csproj +++ b/src/WhatsApp/WhatsApp.csproj @@ -1,4 +1,4 @@ - + net8.0;net9.0 @@ -22,6 +22,7 @@ + diff --git a/src/WhatsApp/WhatsAppClient.cs b/src/WhatsApp/WhatsAppClient.cs index 1bfdce6..8ba338b 100644 --- a/src/WhatsApp/WhatsAppClient.cs +++ b/src/WhatsApp/WhatsAppClient.cs @@ -41,7 +41,7 @@ public HttpClient CreateHttp(string numberId) } /// - public async Task SendAsync(string numberId, object payload) + public async Task SendAsync(string numberId, object payload, CancellationToken cancellationToken = default) { if (!options.Numbers.TryGetValue(numberId, out var token)) throw new ArgumentException($"The number '{numberId}' is not registered in the options.", nameof(numberId)); @@ -51,7 +51,7 @@ public HttpClient CreateHttp(string numberId) http.DefaultRequestHeaders.TryAddWithoutValidation("Authorization", $"Bearer {token}"); http.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); - var result = await http.PostAsJsonAsync($"https://graph.facebook.com/{options.ApiVersion}/{numberId}/messages", payload); + var result = await http.PostAsJsonAsync($"https://graph.facebook.com/{options.ApiVersion}/{numberId}/messages", payload, cancellationToken); if (!result.IsSuccessStatusCode) { @@ -60,7 +60,7 @@ public HttpClient CreateHttp(string numberId) throw new HttpRequestException(error, null, result.StatusCode); } - var response = await result.Content.ReadFromJsonAsync(InternalJsonContext.Default.SendResponse); + var response = await result.Content.ReadFromJsonAsync(InternalJsonContext.Default.SendResponse, cancellationToken); return response?.Messages?.FirstOrDefault()?.Id; } diff --git a/src/WhatsApp/WhatsAppClientExtensions.cs b/src/WhatsApp/WhatsAppClientExtensions.cs index 9a1528b..d27e8f4 100644 --- a/src/WhatsApp/WhatsAppClientExtensions.cs +++ b/src/WhatsApp/WhatsAppClientExtensions.cs @@ -3,7 +3,7 @@ /// /// Usability extensions for common messaging scenarios for WhatsApp. /// -public static partial class WhatsAppClientExtensions +static partial class WhatsAppClientExtensions { /// /// Creates an authenticated HTTP client for the given service number. @@ -42,8 +42,8 @@ public static Task MarkReadAsync(this IWhatsAppClient client, string from, strin /// The WhatsApp client. /// The message to react to. /// The reaction emoji. - public static Task ReactAsync(this IWhatsAppClient client, UserMessage message, string emoji) - => ReactAsync(client, message.To.Id, message.From.Number, message.Id, emoji); + public static Task ReactAsync(this IWhatsAppClient client, UserMessage message, string emoji, CancellationToken cancellationToken = default) + => ReactAsync(client, message.To.Id, message.From.Number, message.Id, emoji, cancellationToken); /// /// Reacts to a message. @@ -53,7 +53,7 @@ public static Task ReactAsync(this IWhatsAppClient client, UserMessage message, /// The user phone number to send the reaction to. /// The message identifier to react to. /// The reaction emoji. - public static Task ReactAsync(this IWhatsAppClient client, string from, string to, string messageId, string emoji) + public static Task ReactAsync(this IWhatsAppClient client, string from, string to, string messageId, string emoji, CancellationToken cancellationToken = default) => client.SendAsync(from, new { messaging_product = "whatsapp", @@ -65,6 +65,30 @@ public static Task ReactAsync(this IWhatsAppClient client, string from, string t message_id = messageId, emoji } + }, cancellationToken); + + /// + /// Reacts to a message. + /// + /// The WhatsApp client. + /// The service number to send the reaction through. + /// The user phone number to send the reaction to. + /// The message identifier to react to. + /// The reaction emoji. + public static Task SendTemplateAsync(this IWhatsAppClient client, string from, string to, string templateName, string code, CancellationToken cancellationToken = default) + => client.SendAsync(from, new + { + messaging_product = "whatsapp", + to = NormalizeNumber(to), + type = "template", + template = new + { + name = templateName, + language = new + { + code = code + } + } }); /// @@ -74,7 +98,7 @@ public static Task ReactAsync(this IWhatsAppClient client, string from, string t /// The message to reply to. /// The text message to respond with. /// The identifier of the reply. - public static Task ReplyAsync(this IWhatsAppClient client, UserMessage message, string reply) + public static Task ReplyAsync(this IWhatsAppClient client, Message message, string reply) => client.SendAsync(message.To.Id, new { messaging_product = "whatsapp", @@ -100,7 +124,7 @@ public static Task ReactAsync(this IWhatsAppClient client, string from, string t /// The text message to respond with. /// Interactive button for users to reply. /// The identifier of the reply message. - public static Task ReplyAsync(this IWhatsAppClient client, UserMessage message, string reply, Button button) + public static Task ReplyAsync(this IWhatsAppClient client, Message message, string reply, Button button) => client.SendAsync(message.To.Id, new { messaging_product = "whatsapp", @@ -138,7 +162,7 @@ public static Task ReactAsync(this IWhatsAppClient client, string from, string t /// Interactive button for a user choice. /// Interactive button for a user choice. /// The identifier of the reply message. - public static Task ReplyAsync(this IWhatsAppClient client, UserMessage message, string reply, Button button1, Button button2) + public static Task ReplyAsync(this IWhatsAppClient client, Message message, string reply, Button button1, Button button2) => client.SendAsync(message.To.Id, new { messaging_product = "whatsapp", diff --git a/src/WhatsApp/WhatsAppHandler.cs b/src/WhatsApp/WhatsAppHandler.cs index 22a5266..bb2b4c2 100644 --- a/src/WhatsApp/WhatsAppHandler.cs +++ b/src/WhatsApp/WhatsAppHandler.cs @@ -12,6 +12,7 @@ public static class WhatsAppHandler class EmptyWhatsAppHandler : IWhatsAppHandler { - public Task HandleAsync(IEnumerable messages, CancellationToken cancellation = default) => Task.CompletedTask; + public IAsyncEnumerable HandleAsync(IEnumerable messages, CancellationToken cancellation = default) + => AsyncEnumerable.Empty(); } } diff --git a/src/WhatsApp/WhatsAppHandlerBuilder.cs b/src/WhatsApp/WhatsAppHandlerBuilder.cs index a4d42fc..e70e233 100644 --- a/src/WhatsApp/WhatsAppHandlerBuilder.cs +++ b/src/WhatsApp/WhatsAppHandlerBuilder.cs @@ -78,7 +78,7 @@ public WhatsAppHandlerBuilder Use(Func /// is . - public WhatsAppHandlerBuilder Use(Func, IWhatsAppHandler, CancellationToken, Task> handlerFunc) + public WhatsAppHandlerBuilder Use(Func, IWhatsAppHandler, CancellationToken, IAsyncEnumerable> handlerFunc) { _ = Throw.IfNull(handlerFunc); diff --git a/src/WhatsApp/WhatsAppHandlerExtensions.cs b/src/WhatsApp/WhatsAppHandlerExtensions.cs index 736893c..8d1fba3 100644 --- a/src/WhatsApp/WhatsAppHandlerExtensions.cs +++ b/src/WhatsApp/WhatsAppHandlerExtensions.cs @@ -1,4 +1,6 @@ -namespace Devlooped.WhatsApp; +using System.Runtime.CompilerServices; + +namespace Devlooped.WhatsApp; /// /// Provides the extension method to build a pipeline @@ -18,4 +20,64 @@ public static WhatsAppHandlerBuilder AsBuilder(this IWhatsAppHandler handler) Throw.IfNull(handler); return new(_ => handler); } -} + + /// + /// Provides an easy way to handle errors and completion in an asynchronous enumerator. + /// + /// The function that provides the async enumerator for responses. + /// The callback to invoke on error. + /// The callback to invoke upon completion. + /// The cancellation token. + /// + internal static async IAsyncEnumerable WithErrorHandlingAsync( + this IAsyncEnumerable responses, + Action? errorCallback = default, + Action? completionCallback = default, + Action? finallyCallback = default, + [EnumeratorCancellation] CancellationToken cancellation = default) + { + IAsyncEnumerator responsesEnumerator; + try + { + responsesEnumerator = responses.GetAsyncEnumerator(); + } + catch (Exception ex) + { + errorCallback?.Invoke(ex); + throw; + } + + try + { + Response? currentResponse = null; + + while (true) + { + try + { + if (!await responsesEnumerator.MoveNextAsync()) + { + break; + } + + currentResponse = responsesEnumerator.Current; + } + catch (Exception ex) + { + errorCallback?.Invoke(ex); + throw; + } + + yield return currentResponse; + } + + completionCallback?.Invoke(); + } + finally + { + finallyCallback?.Invoke(); + + await responsesEnumerator.DisposeAsync(); + } + } +} \ No newline at end of file diff --git a/src/WhatsApp/WhatsAppServiceCollectionExtensions.cs b/src/WhatsApp/WhatsAppServiceCollectionExtensions.cs index 99b5c37..745cecb 100644 --- a/src/WhatsApp/WhatsAppServiceCollectionExtensions.cs +++ b/src/WhatsApp/WhatsAppServiceCollectionExtensions.cs @@ -73,7 +73,7 @@ public static WhatsAppHandlerBuilder AddWhatsApp( /// public static WhatsAppHandlerBuilder AddWhatsApp( this IServiceCollection collection, - Func, CancellationToken, Task> handler, + Func, CancellationToken, IAsyncEnumerable> handler, ServiceLifetime lifetime = ServiceLifetime.Singleton) { return collection.AddWhatsApp( @@ -85,7 +85,7 @@ public static WhatsAppHandlerBuilder AddWhatsApp( /// public static WhatsAppHandlerBuilder AddWhatsApp( this IServiceCollection collection, - Func, CancellationToken, Task> handler, + Func, CancellationToken, IAsyncEnumerable> handler, ServiceLifetime lifetime = ServiceLifetime.Singleton) { return collection.AddWhatsApp( @@ -97,7 +97,7 @@ public static WhatsAppHandlerBuilder AddWhatsApp( /// public static WhatsAppHandlerBuilder AddWhatsApp( this IServiceCollection collection, - Func, CancellationToken, Task> handler, + Func, CancellationToken, IAsyncEnumerable> handler, ServiceLifetime lifetime = ServiceLifetime.Singleton) where TService : notnull { @@ -110,7 +110,7 @@ public static WhatsAppHandlerBuilder AddWhatsApp( /// public static WhatsAppHandlerBuilder AddWhatsApp( this IServiceCollection collection, - Func, CancellationToken, Task> handler, + Func, CancellationToken, IAsyncEnumerable> handler, ServiceLifetime lifetime = ServiceLifetime.Singleton) where TService1 : notnull where TService2 : notnull @@ -127,7 +127,7 @@ public static WhatsAppHandlerBuilder AddWhatsApp( /// public static WhatsAppHandlerBuilder AddWhatsApp( this IServiceCollection collection, - Func, CancellationToken, Task> handler, + Func, CancellationToken, IAsyncEnumerable> handler, ServiceLifetime lifetime = ServiceLifetime.Singleton) where TService1 : notnull where TService2 : notnull @@ -146,7 +146,7 @@ public static WhatsAppHandlerBuilder AddWhatsApp public static WhatsAppHandlerBuilder AddWhatsApp( this IServiceCollection collection, - Func, CancellationToken, Task> handler, + Func, CancellationToken, IAsyncEnumerable> handler, ServiceLifetime lifetime = ServiceLifetime.Singleton) where TService1 : notnull where TService2 : notnull @@ -167,7 +167,7 @@ public static WhatsAppHandlerBuilder AddWhatsApp public static WhatsAppHandlerBuilder AddWhatsApp( this IServiceCollection collection, - Func, CancellationToken, Task> handler, + Func, CancellationToken, IAsyncEnumerable> handler, ServiceLifetime lifetime = ServiceLifetime.Singleton) where TService1 : notnull where TService2 : notnull @@ -190,7 +190,7 @@ public static WhatsAppHandlerBuilder AddWhatsApp public static WhatsAppHandlerBuilder AddWhatsApp( this IServiceCollection collection, - Func, CancellationToken, Task> handler, + Func, CancellationToken, IAsyncEnumerable> handler, ServiceLifetime lifetime = ServiceLifetime.Singleton) where TService1 : notnull where TService2 : notnull