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
96 changes: 96 additions & 0 deletions src/Console/ClientModel.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
using System.Diagnostics.CodeAnalysis;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;

namespace Devlooped.WhatsApp.Client;

[JsonSourceGenerationOptions(JsonSerializerDefaults.Web,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
UseStringEnumConverter = true,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower,
WriteIndented = true
)]
[JsonSerializable(typeof(ClientMessage))]
[JsonSerializable(typeof(ContentMessage))]
[JsonSerializable(typeof(ReactionMessage))]
[JsonSerializable(typeof(InteractiveMessage))]
partial class ClientContext : JsonSerializerContext
{
static readonly Lazy<JsonSerializerOptions> options = new(() => CreateDefaultOptions());

/// <summary>
/// Provides a pre-configured instance of <see cref="JsonSerializerOptions"/> that aligns with the context's settings.
/// </summary>
public static JsonSerializerOptions DefaultOptions { get => options.Value; }

[UnconditionalSuppressMessage("AotAnalysis", "IL3050", Justification = "DefaultJsonTypeInfoResolver is only used when reflection-based serialization is enabled")]
[UnconditionalSuppressMessage("ReflectionAnalysis", "IL2026", Justification = "DefaultJsonTypeInfoResolver is only used when reflection-based serialization is enabled")]
static JsonSerializerOptions CreateDefaultOptions()
{
JsonSerializerOptions options = new(Default.Options)
{
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
WriteIndented = true,
};

if (JsonSerializer.IsReflectionEnabledByDefault)
{
// If reflection-based serialization is enabled by default, use it as a fallback for all other types.
// Also turn on string-based enum serialization for all unknown enums.
options.TypeInfoResolverChain.Add(new DefaultJsonTypeInfoResolver());
options.Converters.Add(new JsonStringEnumConverter());
}

options.MakeReadOnly();
return options;
}
}

enum MessageType
{
Content = 1,
Interactive = 2,
Reaction = 4,
}

[JsonPolymorphic]
[JsonDerivedType(typeof(ContentMessage), "text")]
[JsonDerivedType(typeof(InteractiveMessage), "interactive")]
[JsonDerivedType(typeof(ReactionMessage), "reaction")]
abstract record ClientMessage
{
public abstract MessageType Type { get; }
}

record ContentMessage(Context Context, Text Text) : ClientMessage
{
public override MessageType Type => MessageType.Content;
public override string ToString() => Text.Body;
}

record Text(string Body);

record ReactionMessage(Reaction Reaction) : ClientMessage
{
public override MessageType Type => MessageType.Reaction;
public override string ToString() => Reaction.Emoji;
}

record Reaction(string MessageId, string Emoji);

record InteractiveMessage(Context Context, Interactive Interactive) : ClientMessage
{
public override MessageType Type => MessageType.Interactive;
public override string ToString() => Interactive.Body.Text;
}

record Interactive(Body Body, JsonNode? Action);

record Body(string Text);

record Context(string MessageId);
1 change: 1 addition & 0 deletions src/Console/Console.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Devlooped.JQ" Version="1.7.1.8" />
<PackageReference Include="DotNetConfig.Configuration" Version="1.2.0" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="9.0.6" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.6" />
Expand Down
87 changes: 74 additions & 13 deletions src/Console/Interactive.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System.Net;
using System.Text;
using System.Text.Json;
using Devlooped.WhatsApp.Client;
using DotNetConfig;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
Expand All @@ -10,6 +11,13 @@

namespace Devlooped.WhatsApp;

enum RenderMode
{
Yaml,
Json,
Text,
}

[Service]
class Interactive(IConfiguration configuration, IHttpClientFactory httpFactory) : IHostedService
{
Expand All @@ -18,6 +26,8 @@ class Interactive(IConfiguration configuration, IHttpClientFactory httpFactory)
string? serviceEndpoint = configuration["WhatsApp:Endpoint"];
string? clientEndpoint;
HttpListener? listener;
RenderMode mode = RenderMode.Text;
bool needsNewline = true;

public Task StartAsync(CancellationToken cancellationToken)
{
Expand All @@ -34,6 +44,12 @@ public Task StartAsync(CancellationToken cancellationToken)
.SetString("WhatsApp", "Endpoint", serviceEndpoint);
}

var choices = Enum.GetValues<MessageType>();
mode = AnsiConsole.Prompt(
new SelectionPrompt<RenderMode>()
.Title("Select render mode")
.AddChoices([RenderMode.Text, RenderMode.Yaml, RenderMode.Json]));

listener = new HttpListener();
// Attempt to grab the first free port we can find on localhost
while (true)
Expand Down Expand Up @@ -75,6 +91,7 @@ async Task InputListener()
var input = Console.ReadLine();
if (!string.IsNullOrWhiteSpace(input))
{
needsNewline = false;
try
{
var message = new ContentMessage(
Expand Down Expand Up @@ -120,20 +137,9 @@ async Task ResponseListener()
requestBody = await reader.ReadToEndAsync();
}

AnsiConsole.WriteLine();

// Try to deserialize the request so we can render it nicely in the console using YAML
if (DictionaryConverter.Parse(requestBody) is { } dictionary &&
DictionaryConverter.ToYaml(dictionary) is { Length: > 0 } payload)
{
AnsiConsole.Write(new Panel(payload));
}
else
{
AnsiConsole.Write(new Panel(new JsonText(requestBody)));
}

await RenderAsync(requestBody);
AnsiConsole.Markup($":person_beard: ");
needsNewline = true;

var buffer = Encoding.UTF8.GetBytes("OK");
response.ContentLength64 = buffer.Length;
Expand All @@ -150,4 +156,59 @@ async Task ResponseListener()
}
}
}

async Task RenderAsync(string json)
{
if (needsNewline)
AnsiConsole.WriteLine();

// Try to parse the request body as a dictionary and render it as YAML
if (mode == RenderMode.Yaml &&
DictionaryConverter.Parse(json) is { } dictionary &&
DictionaryConverter.ToYaml(dictionary) is { Length: > 0 } payload)
{
AnsiConsole.Write(new Panel(payload)
{
Width = Math.Min(100, AnsiConsole.Profile.Width)
});
return;
}

if (mode == RenderMode.Text)
{
try
{
// Move discriminator to top.
json = await JQ.ExecuteAsync(json, "{ \"$type\": .type } + .");

if (JsonSerializer.Deserialize(json, ClientContext.Default.ClientMessage) is { } message &&
message.ToString() is { } text)
{
AnsiConsole.Write(new Panel(Markup.FromInterpolated($":robot: {text}"))
{
Border = BoxBorder.None,
Width = Math.Min(100, AnsiConsole.Profile.Width),
Padding = new(0, 0, 0, 0)
});
if (message is Client.InteractiveMessage interactive && interactive.Interactive.Action is { } node)
{
AnsiConsole.Write(new Panel(DictionaryConverter.Parse(node.ToString()).ToYaml(true))
{
Width = Math.Min(60, AnsiConsole.Profile.Width)
});
}
return;
}
}
catch (JsonException e)
{
AnsiConsole.MarkupLineInterpolated($"[grey]{e.Message}[/]");
}
}

AnsiConsole.Write(new Panel(new JsonText(json))
{
Width = Math.Min(100, AnsiConsole.Profile.Width)
});
}
}
13 changes: 11 additions & 2 deletions src/Console/Yaml/DictionaryConverter.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Text.Json;
using System.Text.RegularExpressions;
using YamlDotNet.Core;
using YamlDotNet.Serialization;

namespace Devlooped.WhatsApp;
Expand Down Expand Up @@ -27,12 +28,20 @@ public static partial class DictionaryConverter
public static Dictionary<string, object?>? Parse(string json)
=> JsonSerializer.Deserialize<Dictionary<string, object?>>(json, options);

public static string ToYaml(this object? value)
public static string ToYaml(this object? value, bool formatted = true)
{
if (value is null)
return string.Empty;

return ConvertUnicodeEscapes(serializer.Serialize(value).Trim());
if (!formatted)
return ConvertUnicodeEscapes(serializer.Serialize(value).Trim());

using var writer = new StringWriter();
var settings = new EmitterSettings();
var emitter = new SpectreConsoleEmitter(new Emitter(writer, settings));

serializer.Serialize(emitter, value);
return ConvertUnicodeEscapes(writer.ToString().Trim());
}

public static Dictionary<string, object?> FromYaml(string yaml)
Expand Down
76 changes: 76 additions & 0 deletions src/Console/Yaml/SpectreConsoleEmitter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
using System.Globalization;
using YamlDotNet.Core;
using YamlDotNet.Core.Events;

namespace Devlooped.WhatsApp;

/// <summary>
/// An IEmitter that formats YAML output for Spectre.Console consumption.
/// </summary>
class SpectreConsoleEmitter : IEmitter
{
readonly IEmitter inner;
// Tracks whether the next scalar is a mapping key (true) or value (false)
int mappingDepth = 0;
bool expectingKey = false;

public SpectreConsoleEmitter(IEmitter inner) => this.inner = inner;

public void Emit(ParsingEvent @event)
{
if (@event is MappingStart)
{
mappingDepth++;
expectingKey = true;
inner.Emit(@event);
}
else if (@event is MappingEnd)
{
mappingDepth--;
expectingKey = mappingDepth > 0;
inner.Emit(@event);
}
else if (@event is Scalar scalar)
{
if (mappingDepth > 0 && expectingKey)
{
// Format as Spectre grey key
var key = scalar.Value ?? string.Empty;
inner.Emit(new Scalar(null, null, $"[grey]{key}[/]", ScalarStyle.ForcePlain, true, false));
expectingKey = false;
}
else if (mappingDepth > 0)
{
// Format value
var value = scalar.Value;
var style = DetectStyle(value);
if (value.Contains('\n'))
inner.Emit(new Scalar(null, null, value, ScalarStyle.Literal, true, false));
else
inner.Emit(new Scalar(null, null, style, ScalarStyle.ForcePlain, true, false));

expectingKey = true;
}
else
{
// Not in mapping, just emit as-is
inner.Emit(@event);
}
}
else
{
inner.Emit(@event);
}
}

static string DetectStyle(string? value)
{
if (value == null)
return string.Empty;
if (bool.TryParse(value, out var b))
return $"[green]{value.ToLowerInvariant()}[/]";
if (double.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out _))
return $"[blue]{value}[/]";
return $"[red]{value}[/]";
}
}
16 changes: 14 additions & 2 deletions src/SampleApp/Sample/Program.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Text.Json;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Text.Json.Serialization;
using Devlooped;
using Devlooped.WhatsApp;
Expand Down Expand Up @@ -45,6 +46,17 @@
// Matches what we use in ConfigureOpenTelemetry
.UseOpenTelemetry(builder.Environment.ApplicationName)
.UseLogging()
.Use(EchoAndHandle)
.UseConversation(conversationWindowSeconds: 300 /* default */);

builder.Build().Run();
builder.Build().Run();

static async IAsyncEnumerable<Response> EchoAndHandle(IEnumerable<IMessage> messages, IWhatsAppHandler inner, [EnumeratorCancellation] CancellationToken cancellation)
{
var content = messages.OfType<ContentMessage>().LastOrDefault();
if (content != null)
yield return content.Reply("Echo: " + content.Content.ToString());

await foreach (var response in inner.HandleAsync(messages, cancellation))
yield return response;
}