Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,11 +233,11 @@ You could easily create a custom component that filters out these messages:
static class IgnoreMessagesExtensions
{
public static WhatsAppHandlerBuilder UseIgnore(this WhatsAppHandlerBuilder builder)
=> Throw.IfNull(builder).Use((inner, services) => new IgnoreMessagesHandler(inner,
=> builder.Use((inner, services) => new IgnoreMessagesHandler(inner,
message => message.Type != MessageType.Status && message.Type != MessageType.Unsupported));

public static WhatsAppHandlerBuilder UseIgnore(this WhatsAppHandlerBuilder builder, Func<IMessage, bool> filter)
=> Throw.IfNull(builder).Use((inner, services) => new IgnoreMessagesHandler(inner, filter));
=> builder.Use((inner, services) => new IgnoreMessagesHandler(inner, filter));

class IgnoreMessagesHandler(IWhatsAppHandler inner, Func<IMessage, bool> filter) : DelegatingWhatsAppHandler(inner)
{
Expand All @@ -248,7 +248,7 @@ static class IgnoreMessagesExtensions
if (filtered.Length == 0)
return AsyncEnumerable.Empty<Response>();

return base.HandleAsync(filtered, cancellation).WithExecutionFlow();
return base.HandleAsync(filtered, cancellation);
}
}
}
Expand Down
30 changes: 30 additions & 0 deletions src/SampleApp/Sample/IgnoreExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
namespace Devlooped.WhatsApp;

static class IgnoreMessagesExtensions
{
/// <summary>
/// Ignores status and unsupported messages.
/// </summary>
public static WhatsAppHandlerBuilder UseIgnore(this WhatsAppHandlerBuilder builder)
=> builder.Use((inner, services) => new IgnoreMessagesHandler(inner,
message => message.Type != MessageType.Status && message.Type != MessageType.Unsupported));

/// <summary>
/// Ignores messages based on the provided filter function.
/// </summary>
public static WhatsAppHandlerBuilder UseIgnore(this WhatsAppHandlerBuilder builder, Func<IMessage, bool> filter)
=> builder.Use((inner, services) => new IgnoreMessagesHandler(inner, filter));

class IgnoreMessagesHandler(IWhatsAppHandler inner, Func<IMessage, bool> filter) : DelegatingWhatsAppHandler(inner)
{
public override IAsyncEnumerable<Response> HandleAsync(IEnumerable<IMessage> messages, CancellationToken cancellation = default)
{
var filtered = messages.Where(filter).ToArray();
// Skip inner handler altogether if no messages pass the filter.
if (filtered.Length == 0)
return AsyncEnumerable.Empty<Response>();

return base.HandleAsync(filtered, cancellation);
}
}
}
14 changes: 12 additions & 2 deletions src/SampleApp/Sample/ProcessHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,21 @@ public async IAsyncEnumerable<Response> HandleAsync(IEnumerable<IMessage> messag
yield return content.React("🧠");

// simulate some hard work at hand, like doing some LLM-stuff :)
//await Task.Delay(2000);
await Task.Delay(2000);

yield return content.Reply(
$"☑️ Got your {content.Content.Type}:\r\n{JsonSerializer.Serialize(content, options)}",
$"☑️ Got your {content.Content.Type}",
new Button("btn_good", "👍"),
new Button("btn_bad", "👎"));

yield return content.Reply(
$"""
```
{JsonSerializer.Serialize(content, options)}
```
""");

yield return content.React("✅");
}
else if (message is UnsupportedMessage unsupported)
{
Expand Down
8 changes: 7 additions & 1 deletion src/SampleApp/Sample/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,13 @@
CloudStorageAccount.Parse(builder.Configuration["AzureWebJobsStorage"]));

builder.Services
.AddWhatsApp<ProcessHandler>()
.AddWhatsApp<ProcessHandler>(configure: options =>
{
options.ReactOnMessage = "🌐";
options.ReactOnProcess = "⚙️";
options.ReactOnConversation = "💭";
})
.UseIgnore()
// Matches what we use in ConfigureOpenTelemetry
.UseOpenTelemetry(builder.Environment.ApplicationName)
.UseLogging()
Expand Down
56 changes: 43 additions & 13 deletions src/WhatsApp/AzureFunctions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,13 @@ public class AzureFunctions(
TableServiceClient tableClient,
IWhatsAppClient whatsapp,
IWhatsAppHandler handler,
IOptions<MetaOptions> options,
IOptions<MetaOptions> metaOptions,
IOptions<WhatsAppOptions> functionOptions,
ILogger<AzureFunctions> logger,
IHostEnvironment environment)
{
readonly WhatsAppOptions functionOptions = functionOptions.Value;

[Function("whatsapp_message")]
public async Task<IActionResult> Message([HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "whatsapp")] HttpRequest req)
{
Expand All @@ -45,6 +48,19 @@ public async Task<IActionResult> Message([HttpTrigger(AuthorizationLevel.Anonymo
return new OkResult();
}

if (functionOptions.ReactOnMessage != null &&
message.Type == MessageType.Content)
{
try
{
await message.React(functionOptions.ReactOnMessage).SendAsync(whatsapp);
}
catch (Exception e)
{
logger.LogWarning("Failed to react to message on received: {Id}\r\n{Payload}", message.Id, e.Message);
}
}

// Otherwise, queue the new message
var queue = queueClient.GetQueueClient("whatsappwebhook");
await queue.CreateIfNotExistsAsync();
Expand Down Expand Up @@ -76,23 +92,37 @@ public async Task Process([QueueTrigger("whatsappwebhook", Connection = "AzureWe
return;
}

// We only mark messages read in production, since in development this makes things
// much harder to debug as WhatsApp will notify deliver of these messages too!
if (environment.IsProduction() &&
(message.Type == MessageType.Content || message.Type == MessageType.Interactive))
if (message.Type == MessageType.Content)
{
// Mark read these two types of messages we want to explicitly acknowledge from users.
try
if (functionOptions.ReactOnProcess != null)
{
// Best-effort to mark as read. This might be an old message callback,
// or the message might have been deleted.
await whatsapp.MarkReadAsync(message.Service.Id, message.Id);
try
{
await message.React(functionOptions.ReactOnProcess).SendAsync(whatsapp);
}
catch (Exception e)
{
logger.LogWarning("Failed to react to message on process: {Id}\r\n{Payload}", message.Id, e.Message);
}
}
catch (HttpRequestException e)

// We only mark messages read in production, since in development this makes things
// much harder to debug as WhatsApp will notify deliver of these messages too!
if (environment.IsProduction())
{
logger.LogWarning("Failed to mark message as read: {Id}\r\n{Payload}", message.Id, e.Message);
try
{
// Best-effort to mark as read. This might be an old message callback,
// or the message might have been deleted.
await whatsapp.MarkReadAsync(message.Service.Id, message.Id);
}
catch (HttpRequestException e)
{
logger.LogWarning("Failed to mark message as read: {Id}\r\n{Payload}", message.Id, e.Message);
}
}
}

// Await all responses
// No action needed, just make sure all items are processed
await handler.HandleAsync([message]).ToArrayAsync();
Expand All @@ -110,7 +140,7 @@ public async Task Process([QueueTrigger("whatsappwebhook", Connection = "AzureWe
public IActionResult Register([HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "whatsapp")] HttpRequest req)
{
if (req.Query.TryGetValue("hub.mode", out var mode) && mode == "subscribe" &&
req.Query.TryGetValue("hub.verify_token", out var token) && token == options.Value.VerifyToken &&
req.Query.TryGetValue("hub.verify_token", out var token) && token == metaOptions.Value.VerifyToken &&
req.Query.TryGetValue("hub.challenge", out var values) &&
values.ToString() is { } challenge)
{
Expand Down
8 changes: 7 additions & 1 deletion src/WhatsApp/ConversationHandler.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
using System.Runtime.CompilerServices;
using Microsoft.Extensions.Options;

namespace Devlooped.WhatsApp;


class ConversationHandler(IWhatsAppHandler inner, IConversationStorage storage) : DelegatingWhatsAppHandler(inner)
class ConversationHandler(IWhatsAppHandler inner, IConversationStorage storage, IOptions<WhatsAppOptions> options) : DelegatingWhatsAppHandler(inner)
{
readonly WhatsAppOptions options = options.Value;

/// <summary>
/// Configures the time window to consider for conversation messages.
/// Messages sent within this time frame will be grouped together as part of the same conversation.
Expand All @@ -15,6 +18,9 @@ public override async IAsyncEnumerable<Response> HandleAsync(IEnumerable<IMessag
{
var conversation = new List<IMessage>();

if (options.ReactOnConversation != null && messages.LastOrDefault() is ContentMessage content)
yield return content.React(options.ReactOnConversation);

foreach (var message in messages)
{
var fixup = await SetConversationIdAsync(message, cancellation);
Expand Down
5 changes: 4 additions & 1 deletion src/WhatsApp/ConversationHandlerExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;

namespace Devlooped.WhatsApp;

Expand All @@ -24,7 +25,9 @@ public static WhatsAppHandlerBuilder UseConversation(this WhatsAppHandlerBuilder
=> new ConversationStorage(services.GetRequiredService<CloudStorageAccount>()));
}

return builder.Use((inner, services) => new ConversationHandler(inner, services.GetRequiredService<IConversationStorage>())
return builder.Use((inner, services) => new ConversationHandler(inner,
services.GetRequiredService<IConversationStorage>(),
services.GetRequiredService<IOptions<WhatsAppOptions>>())
{
ConversationWindowSeconds = conversationWindowSeconds
});
Expand Down
23 changes: 23 additions & 0 deletions src/WhatsApp/WhatsAppOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
namespace Devlooped.WhatsApp;

/// <summary>
/// Allows configuring behaviors on the core processing.
/// </summary>
public class WhatsAppOptions
{
/// <summary>
/// An optional emoji to react with when a message is received
/// in the WhatsApp webhook endpoint.
/// </summary>
public string? ReactOnMessage { get; set; }

/// <summary>
/// An optional emoji to react with when message processing is started.
/// </summary>
public string? ReactOnProcess { get; set; }

/// <summary>
/// An optional emoji to react with when restoring conversation context.
/// </summary>
public string? ReactOnConversation { get; set; }
}
Loading