diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..dc0be9b --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,8 @@ + + + enable + enable + latest + true + + diff --git a/README.md b/README.md index 9ca0bcf..55c91bb 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,13 @@ An app for [Seq](https://datalust.co/seq) that forwards messages to [Slack](http ### Getting started - 1. Install the app into Seq through the Seq UI: _Settings_ > _Apps_ > _Install from Nuget_. The package id is _Seq.App.Slack_. - 2. In Slack, select _Manage apps_ > _Search App Directory_ > _"Incoming WebHooks"_ - 3. Add a new incoming webhook configuration and copy the _Webhook URL_ - 4. Back in Seq, under _Settings_ > _Apps_, select _Add Instance_ next to the Slack app icon - 5. Configure the app instance, providing the webhook URL + 1. Install the app into Seq through the Seq UI: _Settings_ > _Apps_ > _Install from NuGet_; the package id is _Seq.App.Slack_ + 2. In Slack, select _Admin_ > _Apps and Workflows_ > _Build_ > _Create new App_ > _From Scratch_ + 3. In the app registration, choose _Incoming WebHooks_ (this is the new endpoint, not the legacy one) + 4. Add a new incoming webhook configuration and copy the _Webhook URL_ + 5. Back in Seq, under _Settings_ > _Apps_, select _Add Instance_ next to the Slack app icon + 6. Configure the app instance, providing the webhook URL Consult the Seq documentation for further information about [installing Seq apps](https://docs.datalust.co/docs/installing-seq-apps). -For more information see [Notifying with Slack](https://docs.datalust.co/docs/slack-notifications). \ No newline at end of file +For more information see [Notifying with Slack](https://docs.datalust.co/docs/slack-notifications). diff --git a/Seq.App.Slack.sln b/Seq.App.Slack.sln index 91eba8e..1016df2 100644 --- a/Seq.App.Slack.sln +++ b/Seq.App.Slack.sln @@ -10,6 +10,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "build", "build", "{225F20AD README.md = README.md Build.ps1 = Build.ps1 Run.ps1 = Run.ps1 + Directory.Build.props = Directory.Build.props EndProjectSection EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Seq.App.Slack.Tests", "test\Seq.App.Slack.Tests\Seq.App.Slack.Tests.csproj", "{143E44C0-C8B0-473D-9522-B31BCAA81A80}" diff --git a/src/Seq.App.Slack/Api/ISlackApi.cs b/src/Seq.App.Slack/Api/ISlackApi.cs index 992d057..f70a0ba 100644 --- a/src/Seq.App.Slack/Api/ISlackApi.cs +++ b/src/Seq.App.Slack/Api/ISlackApi.cs @@ -1,9 +1,8 @@ using System.Threading.Tasks; -namespace Seq.App.Slack.Api +namespace Seq.App.Slack.Api; + +public interface ISlackApi { - public interface ISlackApi - { - Task SendMessageAsync(string webhookUrl, SlackMessage message); - } -} + Task SendMessageAsync(string webhookUrl, SlackMessage message); +} \ No newline at end of file diff --git a/src/Seq.App.Slack/Api/SlackApi.cs b/src/Seq.App.Slack/Api/SlackApi.cs index 8675022..1ef5695 100644 --- a/src/Seq.App.Slack/Api/SlackApi.cs +++ b/src/Seq.App.Slack/Api/SlackApi.cs @@ -4,52 +4,51 @@ using System.Threading.Tasks; using Newtonsoft.Json; -namespace Seq.App.Slack.Api +namespace Seq.App.Slack.Api; + +class SlackApi : ISlackApi { - class SlackApi : ISlackApi + static SlackApi() { - static SlackApi() - { - // Enable TLS 1.2 before any connection to the Slack API is made. - ServicePointManager.SecurityProtocol |= SecurityProtocolType.Tls12; - } + // Enable TLS 1.2 before any connection to the Slack API is made. + ServicePointManager.SecurityProtocol |= SecurityProtocolType.Tls12; + } - private readonly HttpClient _httpClient; - private static readonly JsonSerializerSettings JsonSettings = new JsonSerializerSettings - { - NullValueHandling = NullValueHandling.Ignore - }; + private readonly HttpClient _httpClient; + private static readonly JsonSerializerSettings JsonSettings = new JsonSerializerSettings + { + NullValueHandling = NullValueHandling.Ignore + }; - public SlackApi(string proxyServer) + public SlackApi(string? proxyServer) + { + if (!string.IsNullOrWhiteSpace(proxyServer)) { - if (!string.IsNullOrWhiteSpace(proxyServer)) + var proxy = new WebProxy(proxyServer, false) { - var proxy = new WebProxy(proxyServer, false) - { - UseDefaultCredentials = true - }; - var httpClientHandler = new HttpClientHandler() - { - Proxy = proxy, - PreAuthenticate = true, - UseDefaultCredentials = true, - }; - _httpClient = new HttpClient(handler: httpClientHandler); - } - else + UseDefaultCredentials = true + }; + var httpClientHandler = new HttpClientHandler() { - _httpClient = new HttpClient(); - } + Proxy = proxy, + PreAuthenticate = true, + UseDefaultCredentials = true, + }; + _httpClient = new HttpClient(handler: httpClientHandler); + } + else + { + _httpClient = new HttpClient(); } + } - public async Task SendMessageAsync(string webhookUrl, SlackMessage message) + public async Task SendMessageAsync(string webhookUrl, SlackMessage message) + { + var json = JsonConvert.SerializeObject(message, JsonSettings); + using (var content = new StringContent(json, Encoding.UTF8, "application/json")) { - var json = JsonConvert.SerializeObject(message, JsonSettings); - using (var content = new StringContent(json, Encoding.UTF8, "application/json")) - { - var resp = await _httpClient.PostAsync(webhookUrl, content); - resp.EnsureSuccessStatusCode(); - } + var resp = await _httpClient.PostAsync(webhookUrl, content); + resp.EnsureSuccessStatusCode(); } } -} +} \ No newline at end of file diff --git a/src/Seq.App.Slack/Api/SlackMessage.cs b/src/Seq.App.Slack/Api/SlackMessage.cs index f4eb8bf..c57cac0 100644 --- a/src/Seq.App.Slack/Api/SlackMessage.cs +++ b/src/Seq.App.Slack/Api/SlackMessage.cs @@ -1,36 +1,35 @@ using System.Collections.Generic; using Newtonsoft.Json; -namespace Seq.App.Slack.Api +namespace Seq.App.Slack.Api; + +public class SlackMessage { - public class SlackMessage - { - [JsonProperty("fallback")] - public string Fallback { get; } + [JsonProperty("fallback")] + public string Fallback { get; } - [JsonProperty("text")] - public string Text { get; } + [JsonProperty("text")] + public string Text { get; } - [JsonProperty("attachments")] - public List Attachments { get; } + [JsonProperty("attachments")] + public List Attachments { get; } - [JsonProperty("username")] - public string Username { get; } + [JsonProperty("username")] + public string Username { get; } - [JsonProperty("icon_url")] - public string IconUrl { get; } + [JsonProperty("icon_url")] + public string IconUrl { get; } - [JsonProperty("channel")] - public string Channel { get; } + [JsonProperty("channel")] + public string? Channel { get; } - public SlackMessage(string fallback, string text, string username, string iconUrl, string channel) - { - this.Fallback = fallback; - this.Text = text; - this.Attachments = new List(); - this.Username = username; - this.IconUrl = iconUrl; - this.Channel = channel; - } + public SlackMessage(string fallback, string text, string username, string iconUrl, string? channel) + { + this.Fallback = fallback; + this.Text = text; + this.Attachments = new List(); + this.Username = username; + this.IconUrl = iconUrl; + this.Channel = channel; } } \ No newline at end of file diff --git a/src/Seq.App.Slack/Api/SlackMessageAttachment.cs b/src/Seq.App.Slack/Api/SlackMessageAttachment.cs index dd9f010..eab7953 100644 --- a/src/Seq.App.Slack/Api/SlackMessageAttachment.cs +++ b/src/Seq.App.Slack/Api/SlackMessageAttachment.cs @@ -1,35 +1,34 @@ using System.Collections.Generic; using Newtonsoft.Json; -namespace Seq.App.Slack.Api +namespace Seq.App.Slack.Api; + +public class SlackMessageAttachment { - public class SlackMessageAttachment - { - [JsonProperty("color")] - public string Color { get; } + [JsonProperty("color")] + public string Color { get; } - [JsonProperty("text")] - public string Text { get; } + [JsonProperty("text")] + public string? Text { get; } - [JsonProperty("title")] - public string Title { get; } + [JsonProperty("title")] + public string? Title { get; } - [JsonProperty("fields")] - public List Fields { get; } + [JsonProperty("fields")] + public List Fields { get; } - [JsonProperty("mrkdwn_in")] - public List MarkdownIn { get; } + [JsonProperty("mrkdwn_in")] + public List MarkdownIn { get; } - public SlackMessageAttachment(string color, string text = null, string title = null, bool textIsMarkdown = false) - { - this.Color = color; - this.Text = text; - this.Title = title; - this.Fields = new List(); - this.MarkdownIn = new List(); + public SlackMessageAttachment(string color, string? text = null, string? title = null, bool textIsMarkdown = false) + { + this.Color = color; + this.Text = text; + this.Title = title; + this.Fields = new List(); + this.MarkdownIn = new List(); - if (textIsMarkdown) - this.MarkdownIn.Add("text"); - } + if (textIsMarkdown) + this.MarkdownIn.Add("text"); } -} +} \ No newline at end of file diff --git a/src/Seq.App.Slack/Api/SlackMessageAttachmentField.cs b/src/Seq.App.Slack/Api/SlackMessageAttachmentField.cs index d2ad399..63926f8 100644 --- a/src/Seq.App.Slack/Api/SlackMessageAttachmentField.cs +++ b/src/Seq.App.Slack/Api/SlackMessageAttachmentField.cs @@ -1,23 +1,22 @@ using Newtonsoft.Json; -namespace Seq.App.Slack.Api +namespace Seq.App.Slack.Api; + +public class SlackMessageAttachmentField { - public class SlackMessageAttachmentField - { - [JsonProperty("title")] - public string Title { get; } + [JsonProperty("title")] + public string Title { get; } - [JsonProperty("value")] - public string Value { get; } + [JsonProperty("value")] + public string Value { get; } - [JsonProperty("short")] - public bool Short { get; } + [JsonProperty("short")] + public bool Short { get; } - public SlackMessageAttachmentField(string title, string value, bool @short) - { - Title = title; - Value = value; - Short = @short; - } + public SlackMessageAttachmentField(string title, string value, bool @short) + { + Title = title; + Value = value; + Short = @short; } } \ No newline at end of file diff --git a/src/Seq.App.Slack/Formatting/EventFormatting.cs b/src/Seq.App.Slack/Formatting/EventFormatting.cs index 607e023..9aa5033 100644 --- a/src/Seq.App.Slack/Formatting/EventFormatting.cs +++ b/src/Seq.App.Slack/Formatting/EventFormatting.cs @@ -1,110 +1,106 @@ -using System; -using System.Collections.Generic; -using System.Text.RegularExpressions; +using System.Text.RegularExpressions; using Seq.Apps; using Seq.Apps.LogEvents; using Serilog; -namespace Seq.App.Slack.Formatting +namespace Seq.App.Slack.Formatting; + +static class EventFormatting { - static class EventFormatting + private static readonly Regex PlaceholdersRegex = new(@"(\[(?[^\[\]]+?)(\:(?[^\[\]]+?))?\])", RegexOptions.CultureInvariant | RegexOptions.Compiled); + + private static readonly IReadOnlyDictionary LevelColorMap = new Dictionary + { + [LogEventLevel.Verbose] = "#D3D3D3", + [LogEventLevel.Debug] = "#D3D3D3", + [LogEventLevel.Information] = "#00A000", + [LogEventLevel.Warning] = "#f9c019", + [LogEventLevel.Error] = "#e03836", + [LogEventLevel.Fatal] = "#e03836", + }; + + public static string LevelToColor(LogEventLevel level) { - private static readonly Regex PlaceholdersRegex = new Regex(@"(\[(?[^\[\]]+?)(\:(?[^\[\]]+?))?\])", RegexOptions.CultureInvariant | RegexOptions.Compiled); + return LevelColorMap[level]; + } - private static readonly IReadOnlyDictionary LevelColorMap = new Dictionary - { - [LogEventLevel.Verbose] = "#D3D3D3", - [LogEventLevel.Debug] = "#D3D3D3", - [LogEventLevel.Information] = "#00A000", - [LogEventLevel.Warning] = "#f9c019", - [LogEventLevel.Error] = "#e03836", - [LogEventLevel.Fatal] = "#e03836", - }; - - public static string LevelToColor(LogEventLevel level) - { - return LevelColorMap[level]; - } + public static string SafeGetProperty(Event evt, string propertyPath, bool raw = false) + { + var path = new Queue(propertyPath.Split('.')); + var root = evt.Data.Properties; - public static string SafeGetProperty(Event evt, string propertyPath, bool raw = false) + while(root != null) { - var path = new Queue(propertyPath.Split('.')); - var root = evt.Data.Properties; + var step = path.Dequeue(); + if (!root.TryGetValue(step, out var next)) + return ""; - while(root != null) + if (path.Count == 0) { - var step = path.Dequeue(); - if (!root.TryGetValue(step, out var next)) - return ""; - - if (path.Count == 0) - { - if (next == null) return "`null`"; - return raw ? next.ToString() : SlackSyntax.Escape(next.ToString()); - } - - root = next as IReadOnlyDictionary; + if (next == null) return "`null`"; + return raw ? next.ToString() ?? "" : SlackSyntax.Escape(next.ToString() ?? ""); } - return ""; + root = next as IReadOnlyDictionary; } - public static string SubstitutePlaceholders(string messageTemplateToUse, Event evt, bool addLogData = true) - { - var data = evt.Data; - var eventType = evt.EventType; - var level = data.Level; + return ""; + } + + public static string SubstitutePlaceholders(string messageTemplateToUse, Event evt, bool addLogData = true) + { + var data = evt.Data; + var eventType = evt.EventType; + var level = data.Level; - var placeholders = new Dictionary(); - if (data.Properties != null) - foreach (var kvp in data.Properties) - placeholders[kvp.Key.ToLower()] = kvp.Value; + var placeholders = new Dictionary(); + if (data.Properties != null) + foreach (var kvp in data.Properties) + placeholders[kvp.Key.ToLower()] = kvp.Value; - if (addLogData) - { - AddValueIfKeyDoesNotExist(placeholders, "Level", level); - AddValueIfKeyDoesNotExist(placeholders, "EventType", eventType); - AddValueIfKeyDoesNotExist(placeholders, "RenderedMessage", data.RenderedMessage); - AddValueIfKeyDoesNotExist(placeholders, "Exception", data.Exception); - } - return PlaceholdersRegex.Replace(messageTemplateToUse, m => - { - var key = m.Groups["key"].Value.ToLower(); - var format = m.Groups["format"].Value; - return placeholders.ContainsKey(key) ? FormatValue(placeholders[key], format) : m.Value; - }); + if (addLogData) + { + AddValueIfKeyDoesNotExist(placeholders, "Level", level); + AddValueIfKeyDoesNotExist(placeholders, "EventType", eventType); + AddValueIfKeyDoesNotExist(placeholders, "RenderedMessage", data.RenderedMessage); + AddValueIfKeyDoesNotExist(placeholders, "Exception", data.Exception); } - - private static string FormatValue(object value, string format) + return PlaceholdersRegex.Replace(messageTemplateToUse, m => { - var rawValue = value?.ToString() ?? SlackSyntax.Code("null"); - - if (string.IsNullOrWhiteSpace(format)) - return rawValue; + var key = m.Groups["key"].Value.ToLower(); + var format = m.Groups["format"].Value; + return placeholders.TryGetValue(key, out var placeholder) ? FormatValue(placeholder, format) : m.Value; + }); + } - try - { - // Field values can contain formatting. - return SlackSyntax.Escape(string.Format(format, rawValue)); - } - catch (Exception ex) - { - Log.Error(ex, "Could not format Slack message: {Value} {Format}", value, format); - } + private static string FormatValue(object? value, string format) + { + var rawValue = value?.ToString() ?? SlackSyntax.Code("null"); + if (string.IsNullOrWhiteSpace(format)) return rawValue; - } - private static void AddValueIfKeyDoesNotExist(IDictionary placeholders, string key, object value) + try { - var loweredKey = key.ToLower(); - if (!placeholders.ContainsKey(loweredKey)) - placeholders.Add(loweredKey, value); + // Field values can contain formatting. + return SlackSyntax.Escape(string.Format(format, rawValue)); } - - public static string LinkToId(Host host, string eventId) + catch (Exception ex) { - return $"{host.BaseUri.TrimEnd('/')}/#/events?filter=@Id%20%3D%3D%20%22{eventId}%22&show=expanded"; + Log.Error(ex, "Could not format Slack message: {Value} {Format}", value, format); } + + return rawValue; + } + + private static void AddValueIfKeyDoesNotExist(IDictionary placeholders, string key, object value) + { + var loweredKey = key.ToLower(); + placeholders.TryAdd(loweredKey, value); + } + + public static string LinkToId(Host host, string eventId) + { + return $"{host.BaseUri.TrimEnd('/')}/#/events?filter=@Id%20%3D%3D%20%22{eventId}%22&show=expanded"; } -} +} \ No newline at end of file diff --git a/src/Seq.App.Slack/Formatting/PropertyValueFormatter.cs b/src/Seq.App.Slack/Formatting/PropertyValueFormatter.cs index fdbcd91..78e3130 100644 --- a/src/Seq.App.Slack/Formatting/PropertyValueFormatter.cs +++ b/src/Seq.App.Slack/Formatting/PropertyValueFormatter.cs @@ -1,49 +1,38 @@ -using System; -using System.Collections.Generic; -using Newtonsoft.Json; +using Newtonsoft.Json; -namespace Seq.App.Slack.Formatting +namespace Seq.App.Slack.Formatting; + +class PropertyValueFormatter { - class PropertyValueFormatter - { - private readonly int? _maxPropertyLength; + private readonly int? _maxPropertyLength; - private static readonly JsonSerializerSettings JsonSettings = new JsonSerializerSettings - { - NullValueHandling = NullValueHandling.Ignore - }; + private static readonly JsonSerializerSettings JsonSettings = new() + { + NullValueHandling = NullValueHandling.Ignore + }; - public PropertyValueFormatter(int? maxPropertyLength) - { - _maxPropertyLength = maxPropertyLength; - } + public PropertyValueFormatter(int? maxPropertyLength) + { + _maxPropertyLength = maxPropertyLength; + } - public string ConvertPropertyValueToString(object propertyValue) - { - if (propertyValue == null) - return string.Empty; + public string ConvertPropertyValueToString(object? propertyValue) + { + if (propertyValue == null) + return string.Empty; - string result; - Type t = propertyValue.GetType(); - bool isDict = t.IsGenericType && t.GetGenericTypeDefinition() == typeof(Dictionary<,>); - if (isDict) - { - result = JsonConvert.SerializeObject(propertyValue, JsonSettings); - } - else - { - result = propertyValue.ToString(); - } + var t = propertyValue.GetType(); + var isDict = t.IsGenericType && t.GetGenericTypeDefinition() == typeof(Dictionary<,>); + var result = isDict ? JsonConvert.SerializeObject(propertyValue, JsonSettings) : propertyValue.ToString(); - if (_maxPropertyLength.HasValue) + if (_maxPropertyLength.HasValue) + { + if (result?.Length > _maxPropertyLength) { - if (result.Length > _maxPropertyLength) - { - result = result.Substring(0, _maxPropertyLength.Value) + "..."; - } + result = result[.._maxPropertyLength.Value] + "..."; } - - return result; } + + return result ?? ""; } } \ No newline at end of file diff --git a/src/Seq.App.Slack/Formatting/SlackSyntax.cs b/src/Seq.App.Slack/Formatting/SlackSyntax.cs index e81753d..1f58bd4 100644 --- a/src/Seq.App.Slack/Formatting/SlackSyntax.cs +++ b/src/Seq.App.Slack/Formatting/SlackSyntax.cs @@ -1,35 +1,34 @@ using System; -namespace Seq.App.Slack.Formatting +namespace Seq.App.Slack.Formatting; + +static class SlackSyntax { - static class SlackSyntax + public static string Escape(string s) { - public static string Escape(string s) - { - if (s == null) throw new ArgumentNullException(nameof(s)); - return s - .Replace("<", "<") - .Replace(">", ">") - .Replace("&", "&"); - } + if (s == null) throw new ArgumentNullException(nameof(s)); + return s + .Replace("<", "<") + .Replace(">", ">") + .Replace("&", "&"); + } - public static string Hyperlink(string url, string caption) - { - if (url == null) throw new ArgumentNullException(nameof(url)); - if (caption == null) throw new ArgumentNullException(nameof(caption)); - return $"<{url}|{caption}>"; - } + public static string Hyperlink(string url, string caption) + { + if (url == null) throw new ArgumentNullException(nameof(url)); + if (caption == null) throw new ArgumentNullException(nameof(caption)); + return $"<{url}|{caption}>"; + } - public static string Preformatted(string s) - { - if (s == null) throw new ArgumentNullException(nameof(s)); - return $"```\n{s.Replace("\r", "")}\n```"; - } + public static string Preformatted(string s) + { + if (s == null) throw new ArgumentNullException(nameof(s)); + return $"```\n{s.Replace("\r", "")}\n```"; + } - public static string Code(string s) - { - if (s == null) throw new ArgumentNullException(nameof(s)); - return $"`{s}`"; - } + public static string Code(string s) + { + if (s == null) throw new ArgumentNullException(nameof(s)); + return $"`{s}`"; } -} +} \ No newline at end of file diff --git a/src/Seq.App.Slack/Messages/AlertV1MessageBuilder.cs b/src/Seq.App.Slack/Messages/AlertV1MessageBuilder.cs index cad5515..ba9e65d 100644 --- a/src/Seq.App.Slack/Messages/AlertV1MessageBuilder.cs +++ b/src/Seq.App.Slack/Messages/AlertV1MessageBuilder.cs @@ -3,44 +3,43 @@ using Seq.Apps; using Seq.Apps.LogEvents; -namespace Seq.App.Slack.Messages +namespace Seq.App.Slack.Messages; + +class AlertV1MessageBuilder : SlackMessageBuilder { - class AlertV1MessageBuilder : SlackMessageBuilder + private readonly string? _messageTemplate; + + public AlertV1MessageBuilder(Apps.App app, string? channel, string? username, string? messageTemplate, string? iconUrl, bool excludeOptionalAttachments) + : base(app, channel, username, iconUrl, excludeOptionalAttachments) { - private readonly string _messageTemplate; + _messageTemplate = messageTemplate; + } - public AlertV1MessageBuilder(Apps.App app, string channel, string username, string messageTemplate, string iconUrl, bool excludeOptionalAttachments) - : base(app, channel, username, iconUrl, excludeOptionalAttachments) + protected override string GenerateMessageText(Event evt) + { + var dashboardUrl = EventFormatting.SafeGetProperty(evt, "DashboardUrl"); + var condition = EventFormatting.SafeGetProperty(evt, "Condition", raw: true); + var dashboardTitle = EventFormatting.SafeGetProperty(evt, "DashboardTitle"); + var chartTitle = EventFormatting.SafeGetProperty(evt, "ChartTitle"); + var ownerNamespace = ""; + if (evt.Data.Properties.TryGetValue("OwnerUsername", out var ownerUsernameProperty) && ownerUsernameProperty is string ownerUsername) { - _messageTemplate = messageTemplate; + if (!string.IsNullOrEmpty(ownerUsername)) + ownerNamespace = SlackSyntax.Escape(ownerUsername) + "/"; } + return $"Alert condition {SlackSyntax.Code(condition)} detected on {SlackSyntax.Hyperlink(dashboardUrl, $"{ownerNamespace}{dashboardTitle}/{chartTitle}")}."; + } - protected override string GenerateMessageText(Event evt) - { - var dashboardUrl = EventFormatting.SafeGetProperty(evt, "DashboardUrl"); - var condition = EventFormatting.SafeGetProperty(evt, "Condition", raw: true); - var dashboardTitle = EventFormatting.SafeGetProperty(evt, "DashboardTitle"); - var chartTitle = EventFormatting.SafeGetProperty(evt, "ChartTitle"); - var ownerNamespace = ""; - if (evt.Data.Properties.TryGetValue("OwnerUsername", out var ownerUsernameProperty) && ownerUsernameProperty is string ownerUsername) - { - if (!string.IsNullOrEmpty(ownerUsername)) - ownerNamespace = SlackSyntax.Escape(ownerUsername) + "/"; - } - return $"Alert condition {SlackSyntax.Code(condition)} detected on {SlackSyntax.Hyperlink(dashboardUrl, $"{ownerNamespace}{dashboardTitle}/{chartTitle}")}."; - } + protected override void AddNecessaryAttachments(SlackMessage message, Event evt, string color) + { + var resultsUrl = EventFormatting.SafeGetProperty(evt, "ResultsUrl"); + var resultsText = SlackSyntax.Hyperlink(resultsUrl, "Explore detected results in Seq"); + var results = new SlackMessageAttachment(color, resultsText); + message.Attachments.Add(results); - protected override void AddNecessaryAttachments(SlackMessage message, Event evt, string color) + if (_messageTemplate != null) { - var resultsUrl = EventFormatting.SafeGetProperty(evt, "ResultsUrl"); - var resultsText = SlackSyntax.Hyperlink(resultsUrl, "Explore detected results in Seq"); - var results = new SlackMessageAttachment(color, resultsText); - message.Attachments.Add(results); - - if (_messageTemplate != null) - { - message.Attachments.Add(new SlackMessageAttachment(color, _messageTemplate, null, true)); - } + message.Attachments.Add(new SlackMessageAttachment(color, _messageTemplate, null, true)); } } } \ No newline at end of file diff --git a/src/Seq.App.Slack/Messages/AlertV2MessageBuilder.cs b/src/Seq.App.Slack/Messages/AlertV2MessageBuilder.cs index 9b29797..b00c4f6 100644 --- a/src/Seq.App.Slack/Messages/AlertV2MessageBuilder.cs +++ b/src/Seq.App.Slack/Messages/AlertV2MessageBuilder.cs @@ -1,95 +1,145 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; +using System.Text; using Seq.App.Slack.Api; using Seq.App.Slack.Formatting; using Seq.Apps; using Seq.Apps.LogEvents; // ReSharper disable PossibleMultipleEnumeration -namespace Seq.App.Slack.Messages +namespace Seq.App.Slack.Messages; + +class AlertV2MessageBuilder : SlackMessageBuilder { - class AlertV2MessageBuilder : SlackMessageBuilder + private readonly Host _host; + private readonly PropertyValueFormatter _propertyValueFormatter; + private readonly string? _messageTemplate; + private static readonly HashSet SpecialProperties = ["NamespacedAlertTitle", "Alert", "Source", "SuppressedUntil", "Failures"]; + + public AlertV2MessageBuilder(Host host, Apps.App app, PropertyValueFormatter propertyValueFormatter, string? channel, string? username, string? messageTemplate, string? iconUrl, bool excludeOptionalAttachments) + : base(app, channel, username, iconUrl, excludeOptionalAttachments) + { + _host = host; + _propertyValueFormatter = propertyValueFormatter ?? throw new ArgumentNullException(nameof(propertyValueFormatter)); + _messageTemplate = messageTemplate; + } + + protected override string GenerateMessageText(Event evt) + { + var namespacedAlertTitle = EventFormatting.SafeGetProperty(evt, "NamespacedAlertTitle"); + var alertUrl = EventFormatting.SafeGetProperty(evt, "Alert.Url"); + return $"Alert condition triggered by {SlackSyntax.Hyperlink(alertUrl, namespacedAlertTitle)}"; + } + + protected override void AddNecessaryAttachments(SlackMessage message, Event evt, string color) { - private readonly Host _host; - private readonly PropertyValueFormatter _propertyValueFormatter; - private readonly string _messageTemplate; - private static readonly HashSet SpecialProperties = new HashSet(new[] { "NamespacedAlertTitle", "Alert", "Source", "SuppressedUntil", "Failures" }); + var resultsUrl = EventFormatting.SafeGetProperty(evt, "Source.ResultsUrl"); + var contributingEventsUrl = EventFormatting.SafeGetProperty(evt, "Source.ContributingEventsUrl"); - public AlertV2MessageBuilder(Host host, Apps.App app, PropertyValueFormatter propertyValueFormatter, string channel, string username, string messageTemplate, string iconUrl, bool excludeOptionalAttachments) - : base(app, channel, username, iconUrl, excludeOptionalAttachments) + if (!string.IsNullOrWhiteSpace(contributingEventsUrl)) { - _host = host; - _propertyValueFormatter = propertyValueFormatter ?? throw new ArgumentNullException(nameof(propertyValueFormatter)); - _messageTemplate = messageTemplate; + // 2026.1+ + var exploreText = + "Explore " + + SlackSyntax.Hyperlink(resultsUrl, "detected results") + + " and " + + SlackSyntax.Hyperlink(contributingEventsUrl, "contributing events") + + " in Seq"; + message.Attachments.Add(new SlackMessageAttachment(color, exploreText)); } - - protected override string GenerateMessageText(Event evt) + else { - var namespacedAlertTitle = EventFormatting.SafeGetProperty(evt, "NamespacedAlertTitle"); - var alertUrl = EventFormatting.SafeGetProperty(evt, "Alert.Url"); - return $"Alert condition triggered by {SlackSyntax.Hyperlink(alertUrl, namespacedAlertTitle)}"; + // 2025.2 and earlier + var resultsText = SlackSyntax.Hyperlink(resultsUrl, "Explore detected results in Seq"); + message.Attachments.Add(new SlackMessageAttachment(color, resultsText)); } - protected override void AddNecessaryAttachments(SlackMessage message, Event evt, string color) + if (_messageTemplate != null) { - var resultsUrl = EventFormatting.SafeGetProperty(evt, "Source.ResultsUrl"); - var resultsText = SlackSyntax.Hyperlink(resultsUrl, "Explore detected results in Seq"); - var results = new SlackMessageAttachment(color, resultsText); - message.Attachments.Add(results); + message.Attachments.Add(new SlackMessageAttachment(color, _messageTemplate, null, true)); + } - if (_messageTemplate != null) + if (evt.Data.Properties.TryGetValue("Failures", out var f) && + f is IEnumerable failures) + { + foreach (var failure in failures) { - message.Attachments.Add(new SlackMessageAttachment(color, _messageTemplate, null, true)); + var failed = new SlackMessageAttachment(color, SlackSyntax.Escape(failure?.ToString() ?? ""), + "Alert Processing Failed"); + message.Attachments.Add(failed); } + } - if (evt.Data.Properties.TryGetValue("Failures", out var f) && - f is IEnumerable failures) + var notificationProperties = new SlackMessageAttachment(color); + foreach (var property in evt.Data.Properties) + { + if (SpecialProperties.Contains(property.Key)) continue; + var value = _propertyValueFormatter.ConvertPropertyValueToString(property.Value); + notificationProperties.Fields.Add(new SlackMessageAttachmentField(property.Key, value, @short: false)); + } + + if (notificationProperties.Fields.Count != 0) + message.Attachments.Add(notificationProperties); + + if (evt.Data.Properties.TryGetValue("Source", out var r) && + r is IReadOnlyDictionary rd) + { + if (rd.TryGetValue("Results", out var rs) && + rs is IEnumerable results && + results.Count() > 1 && + results.First() is IEnumerable labelsRow) { - foreach (var failure in failures) + var labels = labelsRow.ToArray(); + if (labels.Length > 1 && labels[0] is "time") { - var failed = new SlackMessageAttachment(color, SlackSyntax.Escape(failure?.ToString() ?? ""), "Alert Processing Failed"); - message.Attachments.Add(failed); + var text = new StringBuilder(); + foreach (var result in results.Skip(1).Cast>()) + { + var values = result.ToArray(); + + var pre = new StringBuilder(); + pre.Append(_propertyValueFormatter.ConvertPropertyValueToString(values[0])); + pre.Append('\n'); + for (var i = 1; i < values.Length; ++i) + { + if (i != 1) + pre.Append('\n'); + + var label = labels[i]; + var value = _propertyValueFormatter.ConvertPropertyValueToString(values[i]); + pre.Append($"{label}: {value}"); + } + + text.Append(SlackSyntax.Preformatted(pre.ToString())); + text.Append('\n'); + } + + message.Attachments.Add(new SlackMessageAttachment(color, text.ToString(), "Results")); } } - - var notificationProperties = new SlackMessageAttachment(color); - foreach (var property in evt.Data.Properties) - { - if (SpecialProperties.Contains(property.Key)) continue; - var value = _propertyValueFormatter.ConvertPropertyValueToString(property.Value); - notificationProperties.Fields.Add(new SlackMessageAttachmentField(property.Key, value, @short: false)); - } - - if (notificationProperties.Fields.Count != 0) - message.Attachments.Add(notificationProperties); // Contributing events are opted-in per notification, so they're considered minimal (the user can configure // the alert to exclude them if desired). - if (evt.Data.Properties.TryGetValue("Source", out var r) && - r is IReadOnlyDictionary rd && - rd.TryGetValue("ContributingEvents", out var ce) && - ce is IEnumerable contributingEvents && + if (rd.TryGetValue("ContributingEvents", out var ce) && + ce is IEnumerable contributingEvents && contributingEvents.Count() > 1) { var text = new StringBuilder(); - foreach (var contributing in contributingEvents.Skip(1).Cast>()) + foreach (var contributing in contributingEvents.Skip(1).Cast>()) { var columns = contributing.Cast().ToArray(); - + // Timestamp as ISO-8601 string text.Append(SlackSyntax.Code(columns[1])); - text.Append(" "); - + text.Append(' '); + // Message, linking to event - text.Append(SlackSyntax.Hyperlink(EventFormatting.LinkToId(_host, columns[0]), SlackSyntax.Escape(columns[2]))); - text.Append("\n"); + text.Append(SlackSyntax.Hyperlink(EventFormatting.LinkToId(_host, columns[0]), + SlackSyntax.Escape(columns[2]))); + text.Append('\n'); } - + var events = new SlackMessageAttachment(color, text.ToString(), "Contributing Events"); message.Attachments.Add(events); } } } -} +} \ No newline at end of file diff --git a/src/Seq.App.Slack/Messages/DefaultMessageBuilder.cs b/src/Seq.App.Slack/Messages/DefaultMessageBuilder.cs index 9fb36dd..ed16b3d 100644 --- a/src/Seq.App.Slack/Messages/DefaultMessageBuilder.cs +++ b/src/Seq.App.Slack/Messages/DefaultMessageBuilder.cs @@ -1,88 +1,83 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using Seq.App.Slack.Api; +using Seq.App.Slack.Api; using Seq.App.Slack.Formatting; using Seq.Apps; using Seq.Apps.LogEvents; -namespace Seq.App.Slack.Messages +namespace Seq.App.Slack.Messages; + +class DefaultMessageBuilder : SlackMessageBuilder { - class DefaultMessageBuilder : SlackMessageBuilder - { - private readonly Host _host; - private readonly PropertyValueFormatter _propertyValueFormatter; - private readonly string _messageTemplate; - private readonly HashSet _includedProperties; + private readonly Host _host; + private readonly PropertyValueFormatter _propertyValueFormatter; + private readonly string? _messageTemplate; + private readonly HashSet _includedProperties; - private static readonly IEnumerable SpecialProperties = new[] { "Id", "Host" }; + private static readonly IEnumerable SpecialProperties = ["Id", "Host"]; + + public DefaultMessageBuilder(Host host, Apps.App app, PropertyValueFormatter propertyValueFormatter, string? channel, + string? username, string? iconUrl, string? messageTemplate, bool excludeOptionalAttachments, IEnumerable includedProperties) + : base(app, channel, username, iconUrl, excludeOptionalAttachments) + { + _host = host ?? throw new ArgumentNullException(nameof(host)); + _propertyValueFormatter = propertyValueFormatter ?? throw new ArgumentNullException(nameof(propertyValueFormatter)); + _messageTemplate = messageTemplate; + _includedProperties = new HashSet(includedProperties ?? throw new ArgumentNullException(nameof(includedProperties))); + } + + protected override string GenerateMessageText(Event evt) + { + var messageTemplateToUse = string.IsNullOrWhiteSpace(_messageTemplate) ? "[RenderedMessage]" : _messageTemplate; + var message = EventFormatting.SubstitutePlaceholders(messageTemplateToUse, evt); + return SlackSyntax.Escape(message); + } + + protected override void AddNecessaryAttachments(SlackMessage message, Event evt, string color) + { + var viewUrl = EventFormatting.LinkToId(_host, evt.Id); + var viewText = SlackSyntax.Hyperlink(viewUrl, "View this event in Seq"); + var view = new SlackMessageAttachment(color, viewText); + message.Attachments.Add(view); + } - public DefaultMessageBuilder(Host host, Apps.App app, PropertyValueFormatter propertyValueFormatter, string channel, - string username, string iconUrl, string messageTemplate, bool excludeOptionalAttachments, IEnumerable includedProperties) - : base(app, channel, username, iconUrl, excludeOptionalAttachments) + protected override void AddOptionalAttachments(SlackMessage message, Event evt, string color) + { + var special = new SlackMessageAttachment(color); + special.Fields.Add(new SlackMessageAttachmentField("Level", evt.Data.Level.ToString(), @short: true)); + message.Attachments.Add(special); + + foreach (var key in SpecialProperties) { - _host = host ?? throw new ArgumentNullException(nameof(host)); - _propertyValueFormatter = propertyValueFormatter ?? throw new ArgumentNullException(nameof(propertyValueFormatter)); - _messageTemplate = messageTemplate; - _includedProperties = new HashSet(includedProperties ?? throw new ArgumentNullException(nameof(includedProperties))); + if (evt.Data.Properties == null || !evt.Data.Properties.TryGetValue(key, out var property)) continue; + + special.Fields.Add(new SlackMessageAttachmentField(key, property?.ToString() ?? "", @short: true )); } - protected override string GenerateMessageText(Event evt) + if (evt.Data.Exception != null) { - var messageTemplateToUse = string.IsNullOrWhiteSpace(_messageTemplate) ? "[RenderedMessage]" : _messageTemplate; - var message = EventFormatting.SubstitutePlaceholders(messageTemplateToUse, evt); - return SlackSyntax.Escape(message); + message.Attachments.Add(new SlackMessageAttachment(color, SlackSyntax.Preformatted(evt.Data.Exception), "Exception Details", textIsMarkdown: true)); } - protected override void AddNecessaryAttachments(SlackMessage message, Event evt, string color) + if (evt.Data.Properties != null && evt.Data.Properties.TryGetValue("StackTrace", out var st) && st is string stackTrace) { - var viewUrl = EventFormatting.LinkToId(_host, evt.Id); - var viewText = SlackSyntax.Hyperlink(viewUrl, "View this event in Seq"); - var view = new SlackMessageAttachment(color, viewText); - message.Attachments.Add(view); + message.Attachments.Add(new SlackMessageAttachment(color, SlackSyntax.Preformatted(stackTrace), "Stack Trace", textIsMarkdown: true)); } - protected override void AddOptionalAttachments(SlackMessage message, Event evt, string color) + var otherProperties = new SlackMessageAttachment(color); + if (evt.Data.Properties != null) { - var special = new SlackMessageAttachment(color); - special.Fields.Add(new SlackMessageAttachmentField("Level", evt.Data.Level.ToString(), @short: true)); - message.Attachments.Add(special); - - foreach (var key in SpecialProperties) + foreach (var property in evt.Data.Properties) { - if (evt.Data.Properties == null || !evt.Data.Properties.ContainsKey(key)) continue; - - var property = evt.Data.Properties[key]; - special.Fields.Add(new SlackMessageAttachmentField(key, property.ToString(), @short: true )); - } + if (SpecialProperties.Contains(property.Key)) continue; + if (property.Key == "StackTrace") continue; + if (_includedProperties.Any() && !_includedProperties.Contains(property.Key)) continue; - if (evt.Data.Exception != null) - { - message.Attachments.Add(new SlackMessageAttachment(color, SlackSyntax.Preformatted(evt.Data.Exception), "Exception Details", textIsMarkdown: true)); - } - - if (evt.Data.Properties != null && evt.Data.Properties.TryGetValue("StackTrace", out var st) && st is string stackTrace) - { - message.Attachments.Add(new SlackMessageAttachment(color, SlackSyntax.Preformatted(stackTrace), "Stack Trace", textIsMarkdown: true)); - } - - var otherProperties = new SlackMessageAttachment(color); - if (evt.Data.Properties != null) - { - foreach (var property in evt.Data.Properties) - { - if (SpecialProperties.Contains(property.Key)) continue; - if (property.Key == "StackTrace") continue; - if (_includedProperties.Any() && !_includedProperties.Contains(property.Key)) continue; - - var value = _propertyValueFormatter.ConvertPropertyValueToString(property.Value); + var value = _propertyValueFormatter.ConvertPropertyValueToString(property.Value); - otherProperties.Fields.Add(new SlackMessageAttachmentField(property.Key, value, @short: false)); - } + otherProperties.Fields.Add(new SlackMessageAttachmentField(property.Key, value, @short: false)); } - - if (otherProperties.Fields.Count != 0) - message.Attachments.Add(otherProperties); } + + if (otherProperties.Fields.Count != 0) + message.Attachments.Add(otherProperties); } } \ No newline at end of file diff --git a/src/Seq.App.Slack/Messages/SlackMessageBuilder.cs b/src/Seq.App.Slack/Messages/SlackMessageBuilder.cs index 0a08ed5..242e2ca 100644 --- a/src/Seq.App.Slack/Messages/SlackMessageBuilder.cs +++ b/src/Seq.App.Slack/Messages/SlackMessageBuilder.cs @@ -1,66 +1,64 @@ -using System; -using Seq.App.Slack.Api; +using Seq.App.Slack.Api; using Seq.App.Slack.Formatting; using Seq.Apps; using Seq.Apps.LogEvents; -namespace Seq.App.Slack.Messages +namespace Seq.App.Slack.Messages; + +abstract class SlackMessageBuilder { - abstract class SlackMessageBuilder - { - public const string DefaultIconUrl = "https://datalust.co/images/nuget/seq-apps.png"; + public const string DefaultIconUrl = "https://datalust.co/images/nuget/seq-apps.png"; - private readonly Apps.App _app; - private readonly string _channel; - private readonly string _username; - private readonly string _iconUrl; - private readonly bool _excludeOptionalAttachments; + private readonly Apps.App _app; + private readonly string? _channel; + private readonly string? _username; + private readonly string? _iconUrl; + private readonly bool _excludeOptionalAttachments; - protected SlackMessageBuilder(Apps.App app, string channel, - string username, string iconUrl, bool excludeOptionalAttachments) - { - _app = app ?? throw new ArgumentNullException(nameof(app)); - _channel = channel; - _username = username; - _iconUrl = iconUrl; - _excludeOptionalAttachments = excludeOptionalAttachments; - } + protected SlackMessageBuilder(Apps.App app, string? channel, + string? username, string? iconUrl, bool excludeOptionalAttachments) + { + _app = app ?? throw new ArgumentNullException(nameof(app)); + _channel = channel; + _username = username; + _iconUrl = iconUrl; + _excludeOptionalAttachments = excludeOptionalAttachments; + } - public SlackMessage BuildMessage(Event evt) - { - var message = new SlackMessage("[" + evt.Data.Level + "] " + evt.Data.RenderedMessage, - GenerateMessageText(evt), - string.IsNullOrWhiteSpace(_username) - ? _app.Title - : EventFormatting.SubstitutePlaceholders(_username, evt, false), - string.IsNullOrWhiteSpace(_iconUrl) ? DefaultIconUrl : _iconUrl, - _channel); + public SlackMessage BuildMessage(Event evt) + { + var message = new SlackMessage("[" + evt.Data.Level + "] " + evt.Data.RenderedMessage, + GenerateMessageText(evt), + string.IsNullOrWhiteSpace(_username) + ? _app.Title + : EventFormatting.SubstitutePlaceholders(_username, evt, false), + string.IsNullOrWhiteSpace(_iconUrl) ? DefaultIconUrl : _iconUrl, + _channel); - var color = EventFormatting.LevelToColor(evt.Data.Level); - AddNecessaryAttachments(message, evt, color); + var color = EventFormatting.LevelToColor(evt.Data.Level); + AddNecessaryAttachments(message, evt, color); - if (!_excludeOptionalAttachments) - { - AddOptionalAttachments(message, evt, color); - } - - return message; + if (!_excludeOptionalAttachments) + { + AddOptionalAttachments(message, evt, color); } - protected abstract string GenerateMessageText(Event evt); + return message; + } - /// - /// Add attachments without which the message cannot be reliably interpreted by a user. - /// - protected virtual void AddNecessaryAttachments(SlackMessage message, Event evt, string color) - { - } + protected abstract string GenerateMessageText(Event evt); - /// - /// Add attachments that don't impact the meaning/interpretation of the message. - /// - protected virtual void AddOptionalAttachments(SlackMessage message, Event evt, string color) - { - } + /// + /// Add attachments without which the message cannot be reliably interpreted by a user. + /// + protected virtual void AddNecessaryAttachments(SlackMessage message, Event evt, string color) + { + } + + /// + /// Add attachments that don't impact the meaning/interpretation of the message. + /// + protected virtual void AddOptionalAttachments(SlackMessage message, Event evt, string color) + { } -} +} \ No newline at end of file diff --git a/src/Seq.App.Slack/Seq.App.Slack.csproj b/src/Seq.App.Slack/Seq.App.Slack.csproj index 267690d..453bfee 100644 --- a/src/Seq.App.Slack/Seq.App.Slack.csproj +++ b/src/Seq.App.Slack/Seq.App.Slack.csproj @@ -1,8 +1,8 @@ - netstandard2.0 - 1.0.0 + net8.0 + 2.0.0 An app for Seq that forwards events and notifications to Slack. bytenik seq-app @@ -16,8 +16,8 @@ - - + + diff --git a/src/Seq.App.Slack/SlackApp.cs b/src/Seq.App.Slack/SlackApp.cs index 71ae058..62195b2 100644 --- a/src/Seq.App.Slack/SlackApp.cs +++ b/src/Seq.App.Slack/SlackApp.cs @@ -1,9 +1,5 @@ using Seq.Apps; using Seq.Apps.LogEvents; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; using Seq.App.Slack.Api; using Seq.App.Slack.Formatting; using Seq.App.Slack.Messages; @@ -11,122 +7,118 @@ // ReSharper disable AutoPropertyCanBeMadeGetOnly.Global, UnusedAutoPropertyAccessor.Global, MemberCanBePrivate.Global -namespace Seq.App.Slack +namespace Seq.App.Slack; + +[SeqApp("Slack Notifier", Description = "Sends events to a Slack channel.")] +public class SlackApp : SeqApp, ISubscribeToAsync { - [SeqApp("Slack Notifier", Description = "Sends events to a Slack channel.")] - public class SlackApp : SeqApp, ISubscribeToAsync + private const uint AlertV1EventType = 0xA1E77000, AlertV2EventType = 0xA1E77001; + + private Dictionary? _messageBuilders; + private SlackMessageBuilder? _defaultMessageBuilder; + + [SeqAppSetting( + DisplayName = "Webhook URL", + HelpText = "Add the Incoming WebHooks app to your Slack to get this URL.")] + public required string WebhookUrl { get; init; } + + [SeqAppSetting( + DisplayName = "Channel", + IsOptional = true, + HelpText = "The channel to be used for the Slack notification. If not specified, uses the webhook default.")] + public string? Channel { get; init; } + + [SeqAppSetting( + DisplayName = "App name", + IsOptional = true, + HelpText = "The name that Seq uses when posting to Slack. If not specified, uses the name of the Seq app instance. The name can also be read from a property by using the format [PropertyName].")] + public string? Username { get; init; } + + [SeqAppSetting( + DisplayName = "Suppression time (minutes)", + IsOptional = true, + HelpText = "Once an event type has been sent to Slack, the time to wait before sending again. The default is zero.")] + public int SuppressionMinutes { get; init; } = 0; + + [SeqAppSetting( + DisplayName = "Exclude optional attachments", + IsOptional = true, + HelpText = "Should event property information and other optional attachments be excluded from the message? The default is to attach all properties.")] + public bool ExcludePropertyInformation { get; init; } + + [SeqAppSetting( + DisplayName = "Message", + HelpText = "The message to send to Slack. Refer to https://api.slack.com/docs/formatting for formatting options. Event property values can be added in the format [PropertyName]. The default is \"[RenderedMessage]\". Added as a Markdown attachment for Alerts.", + IsOptional = true)] + public string? MessageTemplate { get; init; } + + [SeqAppSetting( + DisplayName = "Icon URL", + HelpText = "The image to show in the channel for the message. The default is " + SlackMessageBuilder.DefaultIconUrl + ".", + IsOptional = true)] + public string? IconUrl { get; init; } + + [SeqAppSetting( + DisplayName = "Proxy Server", + HelpText = "Proxy server to be used when making HTTPS requests to the Slack API. Uses default credentials.", + IsOptional = true)] + public string? ProxyServer { get; init; } + + [SeqAppSetting( + DisplayName = "Maximum property length", + IsOptional = true, + HelpText = "If a property when converted to a string is longer than this number it will be truncated.")] + public int? MaxPropertyLength { get; init; } + + [SeqAppSetting( + DisplayName = "Included properties", + IsOptional = true, + HelpText = "Comma separated list of properties to include as attachments. The default is to include all properties.")] + public string? IncludedProperties { get; init; } + + private EventTypeSuppressions? _suppressions; + private ISlackApi? _slackApi; + + // Used reflectively by the app host. + // ReSharper disable once UnusedMember.Global + public SlackApp() + { + } + + internal SlackApp(ISlackApi slackApi) + { + _slackApi = slackApi; + } + + protected override void OnAttached() { - private const uint AlertV1EventType = 0xA1E77000, AlertV2EventType = 0xA1E77001; - - private Dictionary _messageBuilders; - private SlackMessageBuilder _defaultMessageBuilder; - - [SeqAppSetting( - DisplayName = "Webhook URL", - HelpText = "Add the Incoming WebHooks app to your Slack to get this URL.")] - public string WebhookUrl { get; set; } - - [SeqAppSetting( - DisplayName = "Channel", - IsOptional = true, - HelpText = "The channel to be used for the Slack notification. If not specified, uses the webhook default.")] - public string Channel { get; set; } - - [SeqAppSetting( - DisplayName = "App name", - IsOptional = true, - HelpText = "The name that Seq uses when posting to Slack. If not specified, uses the name of the Seq app instance. The name can also be read from a property by using the format [PropertyName].")] - public string Username { get; set; } - - [SeqAppSetting( - DisplayName = "Suppression time (minutes)", - IsOptional = true, - HelpText = "Once an event type has been sent to Slack, the time to wait before sending again. The default is zero.")] - public int SuppressionMinutes { get; set; } = 0; - - [SeqAppSetting( - DisplayName = "Exclude optional attachments", - IsOptional = true, - HelpText = "Should event property information and other optional attachments be excluded from the message? The default is to attach all properties.")] - public bool ExcludePropertyInformation { get; set; } - - [SeqAppSetting( - DisplayName = "Message", - HelpText = "The message to send to Slack. Refer to https://api.slack.com/docs/formatting for formatting options. Event property values can be added in the format [PropertyName]. The default is \"[RenderedMessage]\". Added as a Markdown attachment for Alerts.", - IsOptional = true)] - public string MessageTemplate { get; set; } - - [SeqAppSetting( - DisplayName = "Icon URL", - HelpText = "The image to show in the channel for the message. The default is " + SlackMessageBuilder.DefaultIconUrl + ".", - IsOptional = true)] - public string IconUrl { get; set; } - - [SeqAppSetting( - DisplayName = "Proxy Server", - HelpText = "Proxy server to be used when making HTTPS requests to the Slack API. Uses default credentials.", - IsOptional = true)] - public string ProxyServer { get; set; } - - [SeqAppSetting( - DisplayName = "Maximum property length", - IsOptional = true, - HelpText = "If a property when converted to a string is longer than this number it will be truncated.")] - public int? MaxPropertyLength { get; set; } = null; - - [SeqAppSetting( - DisplayName = "Included properties", - IsOptional = true, - HelpText = "Comma separated list of properties to include as attachments. The default is to include all properties.")] - public string IncludedProperties { get; set; } - - private EventTypeSuppressions _suppressions; - private ISlackApi _slackApi; - - // Used reflectively by the app host. - // ReSharper disable once UnusedMember.Global - public SlackApp() - { - } - - internal SlackApp(ISlackApi slackApi) - { - _slackApi = slackApi; - } - - protected override void OnAttached() - { - if (_slackApi == null) - { - _slackApi = new SlackApi(ProxyServer); - } - - var propertyValueFormatter = new PropertyValueFormatter(MaxPropertyLength); - - _messageBuilders = new Dictionary - { - [AlertV1EventType] = new AlertV1MessageBuilder(App, Channel, Username, MessageTemplate, IconUrl, ExcludePropertyInformation), - [AlertV2EventType] = new AlertV2MessageBuilder(Host, App, propertyValueFormatter, Channel, Username, MessageTemplate, IconUrl, ExcludePropertyInformation) - }; - - var includedProperties = string.IsNullOrWhiteSpace(IncludedProperties) ? Array.Empty() : IncludedProperties.Split(',').Select(x => x.Trim()); + _slackApi ??= new SlackApi(ProxyServer); + + var propertyValueFormatter = new PropertyValueFormatter(MaxPropertyLength); + + _messageBuilders = new Dictionary + { + [AlertV1EventType] = new AlertV1MessageBuilder(App, Channel, Username, MessageTemplate, IconUrl, ExcludePropertyInformation), + [AlertV2EventType] = new AlertV2MessageBuilder(Host, App, propertyValueFormatter, Channel, Username, MessageTemplate, IconUrl, ExcludePropertyInformation) + }; + + var includedProperties = string.IsNullOrWhiteSpace(IncludedProperties) ? Array.Empty() : IncludedProperties.Split(',').Select(x => x.Trim()); - _defaultMessageBuilder = new DefaultMessageBuilder(Host, App, propertyValueFormatter, Channel, Username, - IconUrl, MessageTemplate, ExcludePropertyInformation, includedProperties); - } + _defaultMessageBuilder = new DefaultMessageBuilder(Host, App, propertyValueFormatter, Channel, Username, + IconUrl, MessageTemplate, ExcludePropertyInformation, includedProperties); + } - public async Task OnAsync(Event evt) - { - _suppressions = _suppressions ?? new EventTypeSuppressions(SuppressionMinutes); - if (_suppressions.ShouldSuppressAt(evt.EventType, DateTime.UtcNow)) - return; + public async Task OnAsync(Event evt) + { + _suppressions ??= new EventTypeSuppressions(SuppressionMinutes); + if (_suppressions.ShouldSuppressAt(evt.EventType, DateTime.UtcNow)) + return; - if (!_messageBuilders.TryGetValue(evt.EventType, out var builder)) - builder = _defaultMessageBuilder; + if (!_messageBuilders!.TryGetValue(evt.EventType, out var builder)) + builder = _defaultMessageBuilder!; - var message = builder.BuildMessage(evt); + var message = builder.BuildMessage(evt); - await _slackApi.SendMessageAsync(WebhookUrl, message); - } + await _slackApi!.SendMessageAsync(WebhookUrl, message); } -} +} \ No newline at end of file diff --git a/src/Seq.App.Slack/Suppression/EventTypeSuppressions.cs b/src/Seq.App.Slack/Suppression/EventTypeSuppressions.cs index 33701cb..0125119 100644 --- a/src/Seq.App.Slack/Suppression/EventTypeSuppressions.cs +++ b/src/Seq.App.Slack/Suppression/EventTypeSuppressions.cs @@ -2,44 +2,43 @@ using System.Collections.Generic; using System.Linq; -namespace Seq.App.Slack.Suppression +namespace Seq.App.Slack.Suppression; + +class EventTypeSuppressions { - class EventTypeSuppressions + private readonly Dictionary _suppressions = new Dictionary(); + private readonly int _suppressionMinutes; + + public EventTypeSuppressions(int suppressionMinutes) { - private readonly Dictionary _suppressions = new Dictionary(); - private readonly int _suppressionMinutes; + _suppressionMinutes = suppressionMinutes; + } - public EventTypeSuppressions(int suppressionMinutes) - { - _suppressionMinutes = suppressionMinutes; - } + public bool ShouldSuppressAt(uint eventType, DateTime utcNow) + { + if (_suppressionMinutes == 0) + return false; - public bool ShouldSuppressAt(uint eventType, DateTime utcNow) + if (!_suppressions.TryGetValue(eventType, out var suppressedSince) || + suppressedSince.AddMinutes(_suppressionMinutes) < utcNow) { - if (_suppressionMinutes == 0) - return false; + // Not suppressed, or suppression expired - if (!_suppressions.TryGetValue(eventType, out var suppressedSince) || - suppressedSince.AddMinutes(_suppressionMinutes) < utcNow) + // Clean up old entries + var expired = _suppressions.FirstOrDefault(kvp => kvp.Value.AddMinutes(_suppressionMinutes) < utcNow); + while (expired.Value != default) { - // Not suppressed, or suppression expired - - // Clean up old entries - var expired = _suppressions.FirstOrDefault(kvp => kvp.Value.AddMinutes(_suppressionMinutes) < utcNow); - while (expired.Value != default) - { - _suppressions.Remove(expired.Key); - expired = _suppressions.FirstOrDefault(kvp => kvp.Value.AddMinutes(_suppressionMinutes) < utcNow); - } - - // Start suppression again - suppressedSince = utcNow; - _suppressions[eventType] = suppressedSince; - return false; + _suppressions.Remove(expired.Key); + expired = _suppressions.FirstOrDefault(kvp => kvp.Value.AddMinutes(_suppressionMinutes) < utcNow); } - // Suppressed - return true; + // Start suppression again + suppressedSince = utcNow; + _suppressions[eventType] = suppressedSince; + return false; } + + // Suppressed + return true; } -} +} \ No newline at end of file diff --git a/test/Seq.App.Slack.Tests/EventFormattingTests.cs b/test/Seq.App.Slack.Tests/EventFormattingTests.cs index 2c43c4d..168281a 100644 --- a/test/Seq.App.Slack.Tests/EventFormattingTests.cs +++ b/test/Seq.App.Slack.Tests/EventFormattingTests.cs @@ -1,100 +1,97 @@ -using System; -using System.Collections.Generic; using Seq.App.Slack.Formatting; using Seq.Apps; using Seq.Apps.LogEvents; using Xunit; -namespace Seq.App.Slack.Tests +namespace Seq.App.Slack.Tests; + +public class EventFormattingTests { - public class EventFormattingTests + [Fact] + public void SubstitutePlaceholders_ReplacesValue() { - [Fact] - public void SubstitutePlaceholders_ReplacesValue() + var result = ExecuteSubstitutePlaceholders(new Dictionary { - var result = ExecuteSubstitutePlaceholders(new Dictionary() - { - {"noun", "force"}, - {"name", "Luke"} - }); + {"noun", "force"}, + {"name", "Luke"} + }); - Assert.Equal("Use the force Luke", result); - } + Assert.Equal("Use the force Luke", result); + } - [Fact] - public void SubstitutePlaceholders_IgnoresCase() + [Fact] + public void SubstitutePlaceholders_IgnoresCase() + { + var result = ExecuteSubstitutePlaceholders(new Dictionary { - var result = ExecuteSubstitutePlaceholders(new Dictionary() - { - {"Noun", "force"}, - {"naMe", "Newton"} - }); + {"Noun", "force"}, + {"naMe", "Newton"} + }); - Assert.Equal("Use the force Newton", result); - } + Assert.Equal("Use the force Newton", result); + } - [Fact] - public void SubstitutePlaceholders_IgnoresMissingProperties() + [Fact] + public void SubstitutePlaceholders_IgnoresMissingProperties() + { + var result = ExecuteSubstitutePlaceholders(new Dictionary { - var result = ExecuteSubstitutePlaceholders(new Dictionary() - { - {"noun", "spoon"} - }); + {"noun", "spoon"} + }); - Assert.Equal("Use the spoon [name]", result); - } + Assert.Equal("Use the spoon [name]", result); + } - [Fact] - public void SubstitutePlaceholders_AllowsPropertiesThatOnlyDifferByCase() + [Fact] + public void SubstitutePlaceholders_AllowsPropertiesThatOnlyDifferByCase() + { + var result = ExecuteSubstitutePlaceholders(new Dictionary { - var result = ExecuteSubstitutePlaceholders(new Dictionary() - { - {"noun", "velcro"}, - {"Noun", "zipper"} - }); + {"noun", "velcro"}, + {"Noun", "zipper"} + }); - Assert.Equal("Use the zipper [name]", result); - } + Assert.Equal("Use the zipper [name]", result); + } - [Fact] - public void SubstitutePlaceholders_SkipsSubstitutionIfPropertiesIsNull() - { - var result = ExecuteSubstitutePlaceholders(null); + [Fact] + public void SubstitutePlaceholders_SkipsSubstitutionIfPropertiesIsNull() + { + var result = ExecuteSubstitutePlaceholders(null); - Assert.Equal("Use the [noun] [name]", result); - } + Assert.Equal("Use the [noun] [name]", result); + } - [Theory] - [InlineData("First", "`null`")] - [InlineData("Second", "20")] - [InlineData("Third", "System.Collections.Generic.Dictionary`2[System.String,System.Object]")] - [InlineData("Third.Fourth", "test")] - [InlineData("Fifth", "")] - public void PropertiesAreRetrievedSafely(string path, string expected) + [Theory] + [InlineData("First", "`null`")] + [InlineData("Second", "20")] + [InlineData("Third", "System.Collections.Generic.Dictionary`2[System.String,System.Object]")] + [InlineData("Third.Fourth", "test")] + [InlineData("Fifth", "")] + public void PropertiesAreRetrievedSafely(string path, string expected) + { + var data = new LogEventData { - var data = new LogEventData + Properties = new Dictionary { - Properties = new Dictionary + ["First"] = null, + ["Second"] = 20, + ["Third"] = new Dictionary { - ["First"] = null, - ["Second"] = 20, - ["Third"] = new Dictionary - { - ["Fourth"] = "test" - } + ["Fourth"] = "test" } - }; + } + }; - var evt = new Event("event-123", 4, DateTime.UtcNow, data); + var evt = new Event("event-123", 4, DateTime.UtcNow, data); - var actual = EventFormatting.SafeGetProperty(evt, path); - Assert.Equal(expected, actual); - } - - private static string ExecuteSubstitutePlaceholders(IReadOnlyDictionary properties) - => EventFormatting.SubstitutePlaceholders( - "Use the [noun] [name]", - new Event("", 1, DateTime.Now, new LogEventData() {Properties = properties}) - ); + var actual = EventFormatting.SafeGetProperty(evt, path); + Assert.Equal(expected, actual); } -} + + private static string ExecuteSubstitutePlaceholders(IReadOnlyDictionary? properties) + => EventFormatting.SubstitutePlaceholders( + "Use the [noun] [name]", + new Event("", 1, DateTime.Now, new LogEventData {Properties = properties}) + ); +} \ No newline at end of file diff --git a/test/Seq.App.Slack.Tests/PropertyFormatterTests.cs b/test/Seq.App.Slack.Tests/PropertyFormatterTests.cs index 87c3f2a..85cdd2d 100644 --- a/test/Seq.App.Slack.Tests/PropertyFormatterTests.cs +++ b/test/Seq.App.Slack.Tests/PropertyFormatterTests.cs @@ -2,52 +2,50 @@ using Seq.App.Slack.Formatting; using Xunit; -namespace Seq.App.Slack.Tests +namespace Seq.App.Slack.Tests; + +public class PropertyValueFormatterTests { - public class PropertyValueFormatterTests + [Fact] + public void IntPropertyFormattedOk() { - [Fact] - public void IntPropertyFormattedOk() - { - var formatter = new PropertyValueFormatter(null); - var result = formatter.ConvertPropertyValueToString(1); - Assert.Equal("1", result); - } + var formatter = new PropertyValueFormatter(null); + var result = formatter.ConvertPropertyValueToString(1); + Assert.Equal("1", result); + } - [Fact] - public void NullPropertyFormattedOk() - { - var formatter = new PropertyValueFormatter(null); - var result = formatter.ConvertPropertyValueToString(null); - Assert.Equal(string.Empty, result); - } + [Fact] + public void NullPropertyFormattedOk() + { + var formatter = new PropertyValueFormatter(null); + var result = formatter.ConvertPropertyValueToString(null); + Assert.Equal(string.Empty, result); + } - [Fact] - public void DictionaryPropertiesSerialisedAsJson() - { - var formatter = new PropertyValueFormatter(null); - - var d = new Dictionary - { - { "test", "value" }, - { "test2", "value2" } - }; - var result = formatter.ConvertPropertyValueToString(d); - const string expected = "{\"test\":\"value\",\"test2\":\"value2\"}"; - Assert.Equal(expected, result); - } - - [Fact] - public void PropertiesShouldTruncateIfDesired() + [Fact] + public void DictionaryPropertiesSerialisedAsJson() + { + var formatter = new PropertyValueFormatter(null); + + var d = new Dictionary { - var formatter = new PropertyValueFormatter(5); + { "test", "value" }, + { "test2", "value2" } + }; + var result = formatter.ConvertPropertyValueToString(d); + const string expected = "{\"test\":\"value\",\"test2\":\"value2\"}"; + Assert.Equal(expected, result); + } - var d = new Dictionary { { "test", "value" } }; - var result = formatter.ConvertPropertyValueToString(d); - const string expected = "{\"tes..."; - Assert.Equal(expected, result); - } + [Fact] + public void PropertiesShouldTruncateIfDesired() + { + var formatter = new PropertyValueFormatter(5); + var d = new Dictionary { { "test", "value" } }; + var result = formatter.ConvertPropertyValueToString(d); + const string expected = "{\"tes..."; + Assert.Equal(expected, result); } -} +} \ No newline at end of file diff --git a/test/Seq.App.Slack.Tests/Seq.App.Slack.Tests.csproj b/test/Seq.App.Slack.Tests/Seq.App.Slack.Tests.csproj index 2d9eb50..9061d6e 100644 --- a/test/Seq.App.Slack.Tests/Seq.App.Slack.Tests.csproj +++ b/test/Seq.App.Slack.Tests/Seq.App.Slack.Tests.csproj @@ -1,15 +1,18 @@ - net6.0 + net10.0 false - - - - + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/test/Seq.App.Slack.Tests/SlackAppTests.cs b/test/Seq.App.Slack.Tests/SlackAppTests.cs index 712ee2c..67a9b7c 100644 --- a/test/Seq.App.Slack.Tests/SlackAppTests.cs +++ b/test/Seq.App.Slack.Tests/SlackAppTests.cs @@ -1,101 +1,95 @@ using NSubstitute; using Seq.Apps; using Seq.Apps.LogEvents; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; using Seq.App.Slack.Api; using Xunit; -namespace Seq.App.Slack.Tests +namespace Seq.App.Slack.Tests; + +public class SlackAppTests { - public class SlackAppTests + private ISlackApi _slackApi = null!; + private IAppHost _appHost = null!; + private Event _event = null!; + + private SlackApp CreateSlackApp(string? includedProperties = null) { - private ISlackApi _slackApi; - private IAppHost _appHost; - private Event _event; + _slackApi = Substitute.For(); + _appHost = Substitute.For(); + _appHost.Host.Returns(new Host("https://listen.example.com", "instance")); + _appHost.App.Returns(new Apps.App("app-id", "App Title", new Dictionary(), "storage-path")); - private SlackApp CreateSlackApp(Action configure = null) + var slackApp = new SlackApp(_slackApi) { - _slackApi = Substitute.For(); - _appHost = Substitute.For(); - _appHost.Host.Returns(new Host("http://listen.example.com", "instance")); - _appHost.App.Returns(new Apps.App("app-id", "App Title", new Dictionary(), "storage-path")); - - var slackApp = new SlackApp(_slackApi) - { - WebhookUrl = "http://webhook.example.com" - }; - - configure?.Invoke(slackApp); + WebhookUrl = "https://webhook.example.com", + IncludedProperties = includedProperties + }; - slackApp.Attach(_appHost); + slackApp.Attach(_appHost); - _event = new Event("id", 1, DateTime.Now, new LogEventData - { - Id = "111", - Level = LogEventLevel.Information, - Properties = new Dictionary - { - {"Property1", "Value1"}, - {"Property2", "Value2"}, - {"Property3", "Value3"} - } - }); - - return slackApp; - } - - [Fact] - public async Task GivenIncludedPropertiesWithWhitespaceAreSuppliedThenTheyAreRespected() + _event = new Event("id", 1, DateTime.Now, new LogEventData { - var slackApp = CreateSlackApp(app => app.IncludedProperties = " Property1 , Property2 "); + Id = "111", + Level = LogEventLevel.Information, + Properties = new Dictionary + { + {"Property1", "Value1"}, + {"Property2", "Value2"}, + {"Property3", "Value3"} + } + }); - await slackApp.OnAsync(_event); + return slackApp; + } - // Ensure the message we send to slack only includes the properties specified - await _slackApi.Received().SendMessageAsync(slackApp.WebhookUrl, Arg.Is(x => x.Attachments.Any(a => a.Fields.Count == 2 && - a.Fields.Any(f => f.Title == "Property1") && - a.Fields.Any(f => f.Title == "Property2")))); - } + [Fact] + public async Task GivenIncludedPropertiesWithWhitespaceAreSuppliedThenTheyAreRespected() + { + var slackApp = CreateSlackApp(" Property1 , Property2 "); - [Fact] - public async Task GivenIncludedPropertiesAreSuppliedThenTheyAreRespected() - { - var slackApp = CreateSlackApp(app => app.IncludedProperties = "Property1,Property3"); + await slackApp.OnAsync(_event); - await slackApp.OnAsync(_event); + // Ensure the message we send to slack only includes the properties specified + await _slackApi.Received().SendMessageAsync(slackApp.WebhookUrl, Arg.Is(x => x.Attachments.Any(a => a.Fields.Count == 2 && + a.Fields.Any(f => f.Title == "Property1") && + a.Fields.Any(f => f.Title == "Property2")))); + } - // Ensure the message we send to slack only includes the properties specified - await _slackApi.Received().SendMessageAsync(slackApp.WebhookUrl, Arg.Is(x => x.Attachments.Any(a => a.Fields.Count == 2 && - a.Fields.Any(f => f.Title == "Property1") && - a.Fields.Any(f => f.Title == "Property3")))); - } + [Fact] + public async Task GivenIncludedPropertiesAreSuppliedThenTheyAreRespected() + { + var slackApp = CreateSlackApp("Property1,Property3"); - [Fact] - public async Task GivenIncludedPropertiesNotSetThenAllPropertiesAreIncluded() - { - var slackApp = CreateSlackApp(); - await slackApp.OnAsync(_event); - - // Ensure the message we send to slack only includes the properties specified - await _slackApi.Received().SendMessageAsync(slackApp.WebhookUrl, Arg.Is(x => x.Attachments.Any(a => a.Fields.Count == 3 && - a.Fields.Any(f => f.Title == "Property1") && - a.Fields.Any(f => f.Title == "Property2") && - a.Fields.Any(f => f.Title == "Property3")))); - } - - [Fact] - public async Task GivenIncludedPropertiesContainsPropertiesThatDontExistThenTheyAreIgnored() - { - var slackApp = CreateSlackApp(app => app.IncludedProperties = "Property1,PropertyDoesntExist"); + await slackApp.OnAsync(_event); + + // Ensure the message we send to slack only includes the properties specified + await _slackApi.Received().SendMessageAsync(slackApp.WebhookUrl, Arg.Is(x => x.Attachments.Any(a => a.Fields.Count == 2 && + a.Fields.Any(f => f.Title == "Property1") && + a.Fields.Any(f => f.Title == "Property3")))); + } + + [Fact] + public async Task GivenIncludedPropertiesNotSetThenAllPropertiesAreIncluded() + { + var slackApp = CreateSlackApp(); + await slackApp.OnAsync(_event); + + // Ensure the message we send to slack only includes the properties specified + await _slackApi.Received().SendMessageAsync(slackApp.WebhookUrl, Arg.Is(x => x.Attachments.Any(a => a.Fields.Count == 3 && + a.Fields.Any(f => f.Title == "Property1") && + a.Fields.Any(f => f.Title == "Property2") && + a.Fields.Any(f => f.Title == "Property3")))); + } + + [Fact] + public async Task GivenIncludedPropertiesContainsPropertiesThatDontExistThenTheyAreIgnored() + { + var slackApp = CreateSlackApp("Property1,PropertyDoesntExist"); - await slackApp.OnAsync(_event); + await slackApp.OnAsync(_event); - // Ensure the message we send to slack only includes the properties specified - await _slackApi.Received().SendMessageAsync(slackApp.WebhookUrl, Arg.Is(x => x.Attachments.Any(a => a.Fields.Count == 1 && - a.Fields.Any(f => f.Title == "Property1")))); - } + // Ensure the message we send to slack only includes the properties specified + await _slackApi.Received().SendMessageAsync(slackApp.WebhookUrl, Arg.Is(x => x.Attachments.Any(a => a.Fields.Count == 1 && + a.Fields.Any(f => f.Title == "Property1")))); } -} +} \ No newline at end of file diff --git a/test/Seq.App.Slack.Tests/SlackSyntaxTests.cs b/test/Seq.App.Slack.Tests/SlackSyntaxTests.cs index 1bea85c..053dc5a 100644 --- a/test/Seq.App.Slack.Tests/SlackSyntaxTests.cs +++ b/test/Seq.App.Slack.Tests/SlackSyntaxTests.cs @@ -1,15 +1,14 @@ using Seq.App.Slack.Formatting; using Xunit; -namespace Seq.App.Slack.Tests +namespace Seq.App.Slack.Tests; + +public class SlackSyntaxTests { - public class SlackSyntaxTests + [Fact] + public void HyperlinksAreCorrectlyFormatted() { - [Fact] - public void HyperlinksAreCorrectlyFormatted() - { - var link = SlackSyntax.Hyperlink("http://example.com", "Hello, world!"); - Assert.Equal("", link); - } + var link = SlackSyntax.Hyperlink("http://example.com", "Hello, world!"); + Assert.Equal("", link); } -} +} \ No newline at end of file