diff --git a/WhatsApp.sln b/WhatsApp.sln index fd1a4da..d68a72d 100644 --- a/WhatsApp.sln +++ b/WhatsApp.sln @@ -9,6 +9,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests", "src\Tests\Tests.cs EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sample", "src\Sample\Sample.csproj", "{37A61B10-BE1C-476D-81E0-2D0BCEAF3EE7}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CodeAnalysis", "src\CodeAnalysis\CodeAnalysis.csproj", "{63583965-B86B-485E-AACF-5C4E453B182E}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -27,6 +29,10 @@ Global {37A61B10-BE1C-476D-81E0-2D0BCEAF3EE7}.Debug|Any CPU.Build.0 = Debug|Any CPU {37A61B10-BE1C-476D-81E0-2D0BCEAF3EE7}.Release|Any CPU.ActiveCfg = Release|Any CPU {37A61B10-BE1C-476D-81E0-2D0BCEAF3EE7}.Release|Any CPU.Build.0 = Release|Any CPU + {63583965-B86B-485E-AACF-5C4E453B182E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {63583965-B86B-485E-AACF-5C4E453B182E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {63583965-B86B-485E-AACF-5C4E453B182E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {63583965-B86B-485E-AACF-5C4E453B182E}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/readme.md b/readme.md index 1f91cae..256cfa2 100644 --- a/readme.md +++ b/readme.md @@ -58,7 +58,7 @@ builder.UseWhatsApp>(async (client, logger, me logger.LogInformation($"Got message type {message.Type}"); // Reply to an incoming content message, for example. if (message is ContentMessage content) - await client.SendTextAync(message.To.Id, message.From.Number, $"Got your {content.}"); + await client.ReplyAsync(message, $"☑️ Got your {content.Content.Type}"); } ``` @@ -86,10 +86,10 @@ common scenarios, such as reacting to a message and replying with plain text: ```csharp if (message is ContentMessage content) { - await client.ReactAsync(from: message.To.Id, to: message.From.Number, message.Id, "🧠"); + await client.ReactAsync(message, "🧠"); // simulate some hard work at hand, like doing some LLM-stuff :) await Task.Delay(2000); - await client.SendTextAync(message.To.Id, message.From.Number, $"☑️ Processed your {content.Type}"); + await client.ReplyAsync(message, $"☑️ Got your {content.Content.Type}"); } ``` diff --git a/src/CodeAnalysis/CodeAnalysis.csproj b/src/CodeAnalysis/CodeAnalysis.csproj new file mode 100644 index 0000000..a06ab5f --- /dev/null +++ b/src/CodeAnalysis/CodeAnalysis.csproj @@ -0,0 +1,21 @@ + + + + Devlooped.WhatsApp.CodeAnalysis + netstandard2.0 + analyzers/dotnet/cs + false + true + + + + + + + + + + + + + diff --git a/src/CodeAnalysis/Properties/launchSettings.json b/src/CodeAnalysis/Properties/launchSettings.json new file mode 100644 index 0000000..758efd8 --- /dev/null +++ b/src/CodeAnalysis/Properties/launchSettings.json @@ -0,0 +1,8 @@ +{ + "profiles": { + "Roslyn": { + "commandName": "DebugRoslynComponent", + "targetProject": "..\\Tests\\Tests.csproj" + } + } +} \ No newline at end of file diff --git a/src/CodeAnalysis/SendStringAnalyzer.cs b/src/CodeAnalysis/SendStringAnalyzer.cs new file mode 100644 index 0000000..28c1b39 --- /dev/null +++ b/src/CodeAnalysis/SendStringAnalyzer.cs @@ -0,0 +1,49 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Devlooped.WhatsApp; + +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public class SendStringAnalyzer : DiagnosticAnalyzer +{ + public static DiagnosticDescriptor Rule { get; } = new( + id: "WA001", + title: "Invalid Payload Type", + messageFormat: $"The second parameter of '{nameof(IWhatsAppClient)}.{nameof(IWhatsAppClient.SendAsync)}' should not be a string.", + category: "Usage", + defaultSeverity: DiagnosticSeverity.Error, + description: "The payload parameter is serialized and sent as JSON over HTTP. Use an object instead.", + isEnabledByDefault: true); + + public override ImmutableArray SupportedDiagnostics => ImmutableArray.Create(Rule); + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterSyntaxNodeAction(AnalyzeSyntax, SyntaxKind.InvocationExpression); + } + + static void AnalyzeSyntax(SyntaxNodeAnalysisContext context) + { + var invocation = (InvocationExpressionSyntax)context.Node; + if (invocation.Expression is MemberAccessExpressionSyntax memberAccess && + memberAccess.Name.Identifier.Text == nameof(IWhatsAppClient.SendAsync)) + { + var methodSymbol = context.SemanticModel.GetSymbolInfo(memberAccess).Symbol as IMethodSymbol; + if (methodSymbol?.ContainingSymbol.Name == nameof(IWhatsAppClient) && invocation.ArgumentList.Arguments.Count == 2) + { + var secondArgument = invocation.ArgumentList.Arguments[1]; + var argumentType = context.SemanticModel.GetTypeInfo(secondArgument.Expression).Type; + if (argumentType?.SpecialType == SpecialType.System_String) + { + var diagnostic = Diagnostic.Create(Rule, secondArgument.GetLocation()); + context.ReportDiagnostic(diagnostic); + } + } + } + } +} diff --git a/src/Sample/Program.cs b/src/Sample/Program.cs index e7edd68..e058e40 100644 --- a/src/Sample/Program.cs +++ b/src/Sample/Program.cs @@ -1,10 +1,21 @@ -using Devlooped.WhatsApp; +using System.Text.Json; +using System.Text.Json.Serialization; +using Devlooped.WhatsApp; using Microsoft.Azure.Functions.Worker.Builder; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; var builder = FunctionsApplication.CreateBuilder(args); +var options = new JsonSerializerOptions(JsonSerializerDefaults.General) +{ + Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + Converters = + { + new JsonStringEnumConverter() + }, + WriteIndented = true +}; builder.ConfigureFunctionsWebApplication(); builder.Configuration.AddUserSecrets(); @@ -18,7 +29,7 @@ // Reengagement error, we need to invite the user. if (error.Error.Code == 131047) { - await client.SendAync(error.To.Id, new + await client.SendAsync(error.To.Id, new { messaging_product = "whatsapp", to = error.From.Number, @@ -51,10 +62,10 @@ } else if (message is ContentMessage content) { - await client.ReactAsync(from: message.To.Id, to: message.From.Number, message.Id, "🧠"); + await client.ReactAsync(message, "🧠"); // simulate some hard work at hand, like doing some LLM-stuff :) - await Task.Delay(2000); - await client.SendTextAync(message.To.Id, message.From.Number, $"☑️ Got your {content.Type.ToString().ToLowerInvariant()}"); + //await Task.Delay(2000); + await client.ReplyAsync(message, $"☑️ Got your {content.Content.Type}:\r\n{JsonSerializer.Serialize(content, options)}"); } }); diff --git a/src/Sample/host.json b/src/Sample/host.json index 85775bf..4367b2f 100644 --- a/src/Sample/host.json +++ b/src/Sample/host.json @@ -12,6 +12,11 @@ "excludedTypes": "Request" }, "enableLiveMetricsFilters": true + }, + "logLevel": { + "Microsoft": "Warning", + "System": "Warning", + "Devlooped": "Trace" } } } \ No newline at end of file diff --git a/src/Tests/AnalyzerExtensions.cs b/src/Tests/AnalyzerExtensions.cs new file mode 100644 index 0000000..22d47e5 --- /dev/null +++ b/src/Tests/AnalyzerExtensions.cs @@ -0,0 +1,20 @@ +using Devlooped.WhatsApp; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Host; +using Microsoft.CodeAnalysis.Testing; + +namespace Devlooped.WhatsApp; + +public static class AnalyzerExtensions +{ + public static TTest WithWhatsApp(this TTest test) where TTest : AnalyzerTest + { + test.SolutionTransforms.Add((solution, projectId) + => solution + .GetProject(projectId)? + .AddMetadataReference(MetadataReference.CreateFromFile(typeof(IWhatsAppClient).Assembly.Location)) + .Solution ?? solution); + + return test; + } +} diff --git a/src/Tests/AnalyzerTests.cs b/src/Tests/AnalyzerTests.cs new file mode 100644 index 0000000..f103a20 --- /dev/null +++ b/src/Tests/AnalyzerTests.cs @@ -0,0 +1,45 @@ +extern alias CodeAnalysis; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Testing; +using Microsoft.CodeAnalysis.Testing; +using Analyzer = Microsoft.CodeAnalysis.CSharp.Testing.CSharpAnalyzerVerifier; +using AnalyzerTest = Microsoft.CodeAnalysis.CSharp.Testing.CSharpAnalyzerTest; +using SendStringAnalyzer = CodeAnalysis.Devlooped.WhatsApp.SendStringAnalyzer; + +namespace Devlooped.WhatsApp; + +public class AnalyzerTests +{ + [Fact] + public async Task InvalidSendTextAsync() + { + var test = new CSharpAnalyzerTest + { + ReferenceAssemblies = ReferenceAssemblies.Net.Net80, + TestCode = + $$""" + using System.Threading.Tasks; + using {{nameof(Devlooped)}}.{{nameof(Devlooped.WhatsApp)}}; + + public class Handler + { + public async Task Send({{nameof(IWhatsAppClient)}} client, string text) + { + await client.{{nameof(IWhatsAppClient.SendAsync)}}("1234", {|#0:text|}); + } + } + """ + }.WithWhatsApp(); + + var expected = Analyzer.Diagnostic(SendStringAnalyzer.Rule).WithLocation(0); + + test.ExpectedDiagnostics.Add(expected); + + await test.RunAsync(); + } +} diff --git a/src/Tests/Tests.csproj b/src/Tests/Tests.csproj index 0bd8be2..9cffb01 100644 --- a/src/Tests/Tests.csproj +++ b/src/Tests/Tests.csproj @@ -11,6 +11,7 @@ + @@ -25,6 +26,7 @@ + diff --git a/src/Tests/WhatsAppClientTests.cs b/src/Tests/WhatsAppClientTests.cs index 4dbc161..e6a7d68 100644 --- a/src/Tests/WhatsAppClientTests.cs +++ b/src/Tests/WhatsAppClientTests.cs @@ -21,7 +21,7 @@ public async Task ThrowsIfNoConfiguredNumberAsync() VerifyToken = "asdf" }, MockLogger.Create()); - var ex = await Assert.ThrowsAsync(() => client.SendAync("1234", new { })); + var ex = await Assert.ThrowsAsync(() => client.SendAsync("1234", new { })); Assert.Equal("from", ex.ParamName); } @@ -31,7 +31,7 @@ public async Task SendsMessageAsync() { var (configuration, client) = Initialize(); - await client.SendTextAync(configuration["SendFrom"]!, configuration["SendTo"]!, "Hi there!"); + await client.SendAync(configuration["SendFrom"]!, configuration["SendTo"]!, "Hi there!"); } [SecretsFact("Meta:VerifyToken", "SendFrom", "SendTo")] @@ -41,7 +41,7 @@ public async Task SendsButtonAsync() // Send an interactive message with three buttons showcasing the payload/value // being different than the button text - await client.SendAync(configuration["SendFrom"]!, new + await client.SendAsync(configuration["SendFrom"]!, new { messaging_product = "whatsapp", recipient_type = "individual", diff --git a/src/WhatsApp/IWhatsAppClient.cs b/src/WhatsApp/IWhatsAppClient.cs index d90d12e..2beb2d6 100644 --- a/src/WhatsApp/IWhatsAppClient.cs +++ b/src/WhatsApp/IWhatsAppClient.cs @@ -1,4 +1,5 @@ -using System.Threading.Tasks; +using System.ComponentModel; +using System.Threading.Tasks; namespace Devlooped.WhatsApp; @@ -16,5 +17,6 @@ public interface IWhatsAppClient /// /// The number is not registered in . /// The HTTP request failed. Exception message contains the error response body from WhatsApp. - Task SendAync(string from, object payload); + [Description(nameof(Devlooped) + nameof(WhatsApp) + nameof(IWhatsAppClient) + nameof(SendAsync))] + Task SendAsync(string from, object payload); } \ No newline at end of file diff --git a/src/WhatsApp/WhatsApp.csproj b/src/WhatsApp/WhatsApp.csproj index 5924d7d..c487ccb 100644 --- a/src/WhatsApp/WhatsApp.csproj +++ b/src/WhatsApp/WhatsApp.csproj @@ -21,4 +21,8 @@ + + + + diff --git a/src/WhatsApp/WhatsAppClient.cs b/src/WhatsApp/WhatsAppClient.cs index 26e944f..0c9896c 100644 --- a/src/WhatsApp/WhatsAppClient.cs +++ b/src/WhatsApp/WhatsAppClient.cs @@ -30,7 +30,7 @@ public static IWhatsAppClient Create(IHttpClientFactory httpFactory, MetaOptions => new WhatsAppClient(httpFactory, Options.Create(options), logger); /// - public async Task SendAync(string from, object payload) + public async Task SendAsync(string from, object payload) { if (!options.Numbers.TryGetValue(from, out var token)) throw new ArgumentException($"The number '{from}' is not registered in the options.", nameof(from)); diff --git a/src/WhatsApp/WhatsAppClientExtensions.cs b/src/WhatsApp/WhatsAppClientExtensions.cs index 3a67a99..5c573e5 100644 --- a/src/WhatsApp/WhatsAppClientExtensions.cs +++ b/src/WhatsApp/WhatsAppClientExtensions.cs @@ -3,19 +3,49 @@ namespace Devlooped.WhatsApp; +/// +/// Usability extensions for common messaging scenarios for WhatsApp. +/// public static class WhatsAppClientExtensions { + /// + /// Marks the message as read. Happens automatically when the + /// webhook endpoint is invoked with a message. + /// + public static Task MarkReadAsync(this IWhatsAppClient client, Message message) + => MarkReadAsync(client, message.To.Id, message.Id); + + /// + /// Marks the message as read. Happens automatically when the + /// webhook endpoint is invoked with a message. + /// public static Task MarkReadAsync(this IWhatsAppClient client, string from, string messageId) - => client.SendAync(from, new + => client.SendAsync(from, new { messaging_product = "whatsapp", status = "read", message_id = messageId, }); + /// + /// Reacts to a message. + /// + /// The WhatsApp client. + /// The message to react to. + /// The reaction emoji. + public static Task ReactAsync(this IWhatsAppClient client, Message message, string reaction) + => ReactAsync(client, message.To.Id, message.From.Number, message.Id, reaction); + /// + /// 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 ReactAsync(this IWhatsAppClient client, string from, string to, string messageId, string reaction) - => client.SendAync(from, new + => client.SendAsync(from, new { messaging_product = "whatsapp", recipient_type = "individual", @@ -28,8 +58,46 @@ public static Task ReactAsync(this IWhatsAppClient client, string from, string t } }); - public static Task SendTextAync(this IWhatsAppClient client, string from, string to, string message) - => client.SendAync(from, new + /// + /// Replies to a user message. + /// + /// The WhatsApp client. + /// The message to reply to. + /// The text message to respond with. + public static Task ReplyAsync(this IWhatsAppClient client, Message message, string reply) + => client.SendAsync(message.To.Id, new + { + messaging_product = "whatsapp", + preview_url = false, + recipient_type = "individual", + to = NormalizeNumber(message.From.Number), + type = "text", + context = new + { + message_id = message.Id + }, + text = new + { + body = reply + } + }); + + /// + /// Sends a text message a user given his incoming message, without making it a reply. + /// + /// The WhatsApp client. + public static Task SendAync(this IWhatsAppClient client, Message message, string text) + => SendAync(client, message.To.Id, message.From.Number, text); + + /// + /// Sends a text message a user. + /// + /// The WhatsApp client. + /// The service number to send the message through. + /// The user phone number to send the message to. + /// The text message to send. + public static Task SendAync(this IWhatsAppClient client, string from, string to, string message) + => client.SendAsync(from, new { messaging_product = "whatsapp", preview_url = false,