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
20 changes: 19 additions & 1 deletion readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -331,7 +331,6 @@ builder.Services.AddWhatsApp<MyWhatsAppHandler>()
.UseOpenTelemetry(builder.Environment.ApplicationName)
.UseLogging()
.UseIgnore() // 👈 Ignore status+unsupported messages. We do log them.
.UseStorage()
.UseConversation();
```

Expand Down Expand Up @@ -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<MyWhatsAppHandler>()
.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.

<!-- #cli -->

## Dogfooding
Expand Down
3 changes: 2 additions & 1 deletion src/SampleApp/Sample/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 &&
Expand Down
2 changes: 1 addition & 1 deletion src/Tests/WhatsAppClientTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,8 @@ public async Task ReplyToSentMessageAsync()
Assert.NotEmpty(id);

var reply = await client.ReplyAsync(
to,
from,
to,
id,
"Reply here!");

Expand Down
15 changes: 15 additions & 0 deletions src/WhatsApp/CompositeService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
namespace Devlooped.WhatsApp;

/// <summary>
/// Allows responding to console and WhatsApp simultaneously.
/// </summary>
record CompositeService : Service
{
public CompositeService(Service primary, Service secondary)
: base(primary.Id, primary.Number)
{
Secondary = secondary;
}

public Service Secondary { get; init; }
}
66 changes: 66 additions & 0 deletions src/WhatsApp/ConsoleHandlerExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

namespace Devlooped.WhatsApp;

/// <summary>
/// Extensions for configuring a console handler in the WhatsApp handler pipeline.
/// </summary>
public static class ConsoleHandlerExtensions
{
/// <summary>
/// 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.
/// </summary>
/// <returns>
/// 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 <see cref="IHostEnvironment"/>.
/// </returns>
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<IHostEnvironment>().IsProduction())
return WhatsAppHandler.Skip;

return new ConsoleHandler(inner);
});
}

class ConsoleHandler(IWhatsAppHandler inner) : DelegatingWhatsAppHandler(inner)
{
public override IAsyncEnumerable<Response> HandleAsync(IEnumerable<IMessage> 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);
}
}
}
14 changes: 10 additions & 4 deletions src/WhatsApp/MessageExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ public bool FromConsole
/// Creates a reaction response for the user message.
/// </summary>
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);

/// <summary>
/// Creates a simple template response for the message.
Expand All @@ -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.
/// </summary>
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);

/// <summary>
/// Creates a text response for the message.
/// </summary>
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);

/// <summary>
/// Creates a text response with buttons for the message.
/// </summary>
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);

/// <summary>
/// Attempts to retrieve a single message from the specified collection.
Expand Down
9 changes: 9 additions & 0 deletions src/WhatsApp/ReactionResponse.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,18 @@
/// <param name="Emoji">The emoji representing the reaction to the message.</param>
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;

/// <inheritdoc/>
protected override async Task<string?> 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();
Expand Down
25 changes: 20 additions & 5 deletions src/WhatsApp/TextResponse.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,33 @@
/// <param name="Button2">An optional second button to include in the response for user interaction.</param>
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;

/// <inheritdoc/>
protected override Task<string?> SendCoreAsync(IWhatsAppClient client, CancellationToken cancellationToken = default)
protected override async Task<string?> SendCoreAsync(IWhatsAppClient client, CancellationToken cancellation = default)
{
if (service != null)
await SendReplyAsync(client, service.Secondary.Id, cancellation);

return await SendReplyAsync(client, ServiceId, cancellation);
}

Task<string?> 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);
}
}
}
13 changes: 11 additions & 2 deletions src/WhatsApp/TypingResponse.cs
Original file line number Diff line number Diff line change
@@ -1,15 +1,24 @@

namespace Devlooped.WhatsApp;
namespace Devlooped.WhatsApp;

/// <summary>
/// Represents a typing status update that can be sent in response to a user message.
/// </summary>
/// <see cref="https://developers.facebook.com/docs/whatsapp/cloud-api/typing-indicators"/>
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<string?> 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();
}
Expand Down