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
10 changes: 9 additions & 1 deletion src/SampleApp/Sample/Program.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<ILogger<Program>, JsonSerializerOptions>(ProcessMessagesAsync)
// Matches what we use in ConfigureOpenTelemetry
.UseOpenTelemetry(builder.Environment.ApplicationName)
.UseLogging();
.UseLogging()
.UseStorage();

builder.Build().Run();

Expand Down
8 changes: 3 additions & 5 deletions src/WhatsApp/AzureFunctions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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}");
Expand Down
33 changes: 33 additions & 0 deletions src/WhatsApp/IMessage.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
using System.Text.Json.Serialization;

namespace Devlooped.WhatsApp;

/// <summary>
/// Represents a message exchanged in a communication system, serving as a base type for various message types.
/// </summary>
/// <remarks>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 <see
/// cref="JsonPolymorphic"/> and <see cref="JsonDerivedTypeAttribute"/> annotations. Examples of derived types include
/// content messages, error messages, and interactive messages.</remarks>
[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
{
/// <summary>
/// Gets the phone number associated with the message sender.
/// </summary>
string Number { get; }

/// <summary>
/// Gets the message id.
/// </summary>
string Id { get; }
}
33 changes: 33 additions & 0 deletions src/WhatsApp/IStorageService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
namespace Devlooped.WhatsApp;

/// <summary>
/// Defines methods for storing and retrieving messages in an asynchronous manner.
/// </summary>
/// <remarks>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.</remarks>
public interface IStorageService
{
/// <summary>
/// Retrieves a stream of messages associated with the specified phone number.
/// </summary>
/// <remarks>This method uses asynchronous streaming to retrieve messages, allowing the caller to process
/// messages as they are received. Ensure proper handling of the <see cref="IAsyncEnumerable{T}"/> by using `await
/// foreach` or equivalent constructs.</remarks>
/// <param name="number">The phone number for which to retrieve messages. This must be a valid phone number in the expected format.</param>
/// <param name="cancellationToken">A token to monitor for cancellation requests. The operation will terminate early if the token is canceled.</param>
/// <returns>An asynchronous stream of <see cref="Message"/> objects representing the messages associated with the specified
/// phone number. The stream will be empty if no messages are found.</returns>
IAsyncEnumerable<IMessage> GetMessagesAsync(string number, CancellationToken cancellationToken = default);

/// <summary>
/// Asynchronously saves a collection of messages to the underlying storage.
/// </summary>
/// <remarks>If the operation is canceled via the <paramref name="cancellationToken"/>, the returned task
/// will be in a canceled state.</remarks>
/// <param name="messages">The collection of <see cref="Message"/> objects to be saved. Cannot be null or empty.</param>
/// <param name="cancellationToken">A <see cref="CancellationToken"/> that can be used to cancel the save operation. The default value is <see
/// langword="default"/>.</param>
/// <returns>A <see cref="Task"/> that represents the asynchronous save operation.</returns>
Task SaveAsync(IEnumerable<IMessage> messages, CancellationToken cancellationToken = default);
}
7 changes: 5 additions & 2 deletions src/WhatsApp/Message.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
/// <summary>
/// Optional related message identifier, such as message being replied
Expand Down Expand Up @@ -237,4 +237,7 @@ .value.statuses[0] as $status |
/// </summary>
[JsonIgnore]
public abstract MessageType Type { get; }
}

/// <inheritdoc/>
public string Number => From.Number;
}
29 changes: 29 additions & 0 deletions src/WhatsApp/MessageStorageHandler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
using System.Runtime.CompilerServices;

namespace Devlooped.WhatsApp;

/// <summary>
/// Handles incoming messages by saving user messages to storage and delegating further processing to an inner handler.
/// </summary>
class MessageStorageHandler : DelegatingWhatsAppHandler
{
readonly IStorageService storageService;

public MessageStorageHandler(IWhatsAppHandler innerHandler, IStorageService storageService)
: base(innerHandler)
{
this.storageService = storageService;
}

public override async IAsyncEnumerable<Response> HandleAsync(IEnumerable<Message> 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;
}
}
}
2 changes: 1 addition & 1 deletion src/WhatsApp/ReactionResponse.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
/// </summary>
/// <param name="UserMessage">The message this reaction applies to.</param>
/// <param name="Emoji">The emoji of the reaction.</param>
public record ReactionResponse(UserMessage UserMessage, string Emoji) : Response
public record ReactionResponse(UserMessage UserMessage, string Emoji) : Response(UserMessage)
{
/// <inheritdoc/>
internal override Task SendAsync(IWhatsAppClient client, CancellationToken cancellationToken = default)
Expand Down
29 changes: 23 additions & 6 deletions src/WhatsApp/Response.cs
Original file line number Diff line number Diff line change
@@ -1,12 +1,29 @@
using System.Text.Json.Serialization;
using static System.Runtime.InteropServices.JavaScript.JSType;

namespace Devlooped.WhatsApp;
namespace Devlooped.WhatsApp;

/// <summary>
/// Base class for responses.
/// Represents a response sent via WhatsApp, containing the associated message and response metadata.
/// </summary>
public abstract partial record Response
/// <remarks>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.</remarks>
/// <param name="Message">The message this response is created for</param>
public abstract partial record Response(Message Message) : IMessage
{
/// <inheritdoc/>
public string Id { get; set; } = string.Empty;

/// <inheritdoc/>
public string Number => Message.From.Number;

/// <summary>
/// Sends a request asynchronously using the specified WhatsApp client.
/// </summary>
/// <remarks>This method is abstract and must be implemented by a derived class to define the specific
/// behavior for sending a request.</remarks>
/// <param name="client">The <see cref="IWhatsAppClient"/> instance used to send the request. This parameter cannot be <see
/// langword="null"/>.</param>
/// <param name="cancellation">An optional <see cref="CancellationToken"/> to observe while waiting for the task to complete. Defaults to <see
/// cref="CancellationToken.None"/>.</param>
/// <returns>A <see cref="Task"/> that represents the asynchronous operation.</returns>
internal abstract Task SendAsync(IWhatsAppClient client, CancellationToken cancellation = default);
}
27 changes: 27 additions & 0 deletions src/WhatsApp/ResponseStorageHandler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
using System.Runtime.CompilerServices;

namespace Devlooped.WhatsApp;

/// <summary>
/// A handler that processes WhatsApp messages and stores the generated responses using a storage service.
/// </summary>
class ResponseStorageHandler : DelegatingWhatsAppHandler
{
readonly IStorageService storageService;

public ResponseStorageHandler(IWhatsAppHandler innerHandler, IStorageService storageService)
: base(innerHandler)
{
this.storageService = storageService;
}

public async override IAsyncEnumerable<Response> HandleAsync(IEnumerable<Message> messages, [EnumeratorCancellation] CancellationToken cancellation = default)
{
await foreach (var response in InnerHandler.HandleAsync(messages, cancellation))
{
await storageService.SaveAsync([response], cancellation);

yield return response;
}
}
}
27 changes: 27 additions & 0 deletions src/WhatsApp/SendResponsesHandler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
using System.Runtime.CompilerServices;
namespace Devlooped.WhatsApp;

/// <summary>
/// Handles the processing of messages by delegating to an inner handler and sending the resulting responses using the
/// specified WhatsApp client.
/// </summary>
class SendResponsesHandler : DelegatingWhatsAppHandler
{
readonly IWhatsAppClient client;

public SendResponsesHandler(IWhatsAppHandler innerHandler, IWhatsAppClient client)
: base(innerHandler)
{
this.client = client;
}

public async override IAsyncEnumerable<Response> HandleAsync(IEnumerable<Message> messages, [EnumeratorCancellation] CancellationToken cancellation = default)
{
await foreach (var response in InnerHandler.HandleAsync(messages, cancellation))
{
await response.SendAsync(client, cancellation);

yield return response;
}
}
}
19 changes: 19 additions & 0 deletions src/WhatsApp/StorageHandlerExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using Microsoft.Extensions.DependencyInjection;

namespace Devlooped.WhatsApp;

/// <summary>
/// Provides extensions for configuring <see cref="MessageStorageHandler"/> instances.
/// </summary>
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<IStorageService, StorageService>(services => new StorageService(services.GetRequiredService<CloudStorageAccount>()));

return builder;
}
}
28 changes: 28 additions & 0 deletions src/WhatsApp/StorageService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
namespace Devlooped.WhatsApp;

class StorageService(CloudStorageAccount storage) : IStorageService
{
const string MessagesTableName = "messages";

Lazy<IDocumentRepository<IMessage>> messagesRepository = new(() =>
DocumentRepository.Create<IMessage>(
storage,
MessagesTableName,
x => x.Number,
x => x.Id));

/// <inheritdoc/>
public async Task SaveAsync(IEnumerable<IMessage> messages, CancellationToken cancellationToken = default)
{
var repository = messagesRepository.Value;

foreach (var message in messages.Where(x => !string.IsNullOrEmpty(x.Id)))
{
await repository.PutAsync(message, cancellationToken);
}
}

/// <inheritdoc/>
public IAsyncEnumerable<IMessage> GetMessagesAsync(string number, CancellationToken cancellationToken = default)
=> messagesRepository.Value.EnumerateAsync(number, cancellationToken);
}
11 changes: 8 additions & 3 deletions src/WhatsApp/TemplateResponse.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
namespace Devlooped.WhatsApp;

/// <summary>
/// A template response to a user message.
/// Represents a response containing a template message to be sent via a WhatsApp client.
/// </summary>
/// <param name="Message">The message this reaction applies to.</param>
public record TemplateResponse(Message Message, string Name, string Code) : Response
/// <remarks>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.</remarks>
/// <param name="Message">The message details, including sender and recipient information.</param>
/// <param name="Name">The name of the template to be sent. This must match a pre-configured template in the WhatsApp system.</param>
/// <param name="Code">The code associated with the template, used to identify the specific template version or configuration.</param>
public record TemplateResponse(Message Message, string Name, string Code) : Response(Message)
{
/// <inheritdoc/>
internal override Task SendAsync(IWhatsAppClient client, CancellationToken cancellationToken = default)
Expand Down
25 changes: 15 additions & 10 deletions src/WhatsApp/TextResponse.cs
Original file line number Diff line number Diff line change
@@ -1,23 +1,28 @@
namespace Devlooped.WhatsApp;

/// <summary>
/// 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.
Copy link

Copilot AI May 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] Remove the extra space before 'which' to maintain consistent spacing in the XML comment.

Suggested change
/// Represents a response containing text and optional interactive buttons, which can be sent as a reply to a message.
/// Represents a response containing text and optional interactive buttons, which can be sent as a reply to a message.

Copilot uses AI. Check for mistakes.
/// </summary>
/// <param name="Message">The message this reaction applies to.</param>
/// <param name="Text">The text of the response.</param>
public record TextResponse(Message Message, string Text, Button? Button1 = default, Button? Button2 = default) : Response
/// <remarks>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.</remarks>
/// <param name="Message">The message to which this response is a reply.</param>
/// <param name="Text">The text content of the response message.</param>
/// <param name="Button1">An optional button to include in the response for user interaction.</param>
/// <param name="Button2">An optional second button to include in the response for user interaction.</param>
public record TextResponse(Message Message, string Text, Button? Button1 = default, Button? Button2 = default) : Response(Message)
{
/// <inheritdoc/>
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);
}
}
7 changes: 5 additions & 2 deletions src/WhatsApp/WhatsApp.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Azure.Data.Tables" Version="12.11.0" />
<PackageReference Include="Microsoft.AspNetCore.Http" Version="2.3.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Abstractions" Version="2.3.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Core" Version="2.3.0" />
Expand All @@ -19,8 +18,12 @@
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="8.10.0" />
<PackageReference Include="Microsoft.Extensions.Options.DataAnnotations" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.1" />
<PackageReference Include="NuGetizer" Version="1.2.4" />

<PackageReference Include="Azure.Data.Tables" Version="12.11.0" />
<PackageReference Include="Devlooped.JQ" Version="1.7.1.8" PackExclude="contentFiles" />
<PackageReference Include="Devlooped.TableStorage" Version="5.2.2" />

<PackageReference Include="NuGetizer" Version="1.2.4" />
<PackageReference Include="PolySharp" Version="1.15.0" />
<PackageReference Include="System.Linq.Async" Version="6.0.1" />
<PackageReference Include="System.Net.Http.Json" Version="8.0.1" />
Expand Down
Loading