diff --git a/Matterhook.NET.MatterhookClient.Tests/MiscTests.cs b/Matterhook.NET.MatterhookClient.Tests/MiscTests.cs index 96bc82c..cc920e6 100644 --- a/Matterhook.NET.MatterhookClient.Tests/MiscTests.cs +++ b/Matterhook.NET.MatterhookClient.Tests/MiscTests.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; using Xunit; namespace Matterhook.NET.MatterhookClient.Tests @@ -20,5 +21,32 @@ public void StringSplitterThrowsExceptionWhenChunkSizeOfLessThan1() Assert.Throws(() => StringSplitter.SplitTextIntoChunks("A message", 0, false)); } + [Fact] + public void StringSplitterPreservesFencedCodeBlocksAcrossChunks() + { + var text = "Before\n```json\none two three four five six seven eight nine ten\n```\nAfter"; + + var chunks = StringSplitter.SplitTextIntoChunks(text, 25).ToList(); + + Assert.Equal(3, chunks.Count); + Assert.Equal("Before\n```json\none two\n```", chunks[0]); + Assert.Equal("```json\nthree four five six seven\n```", chunks[1]); + Assert.Equal("```json\neight nine ten\n```\nAfter", chunks[2]); + } + + [Fact] + public void StringSplitterTruncatesToTheFirstChunk() + { + var chunks = StringSplitter.SplitTextIntoChunks("one two three four", 7, truncate: true).ToList(); + + Assert.Single(chunks); + Assert.Equal("one two", chunks[0]); + + var markdownChunks = StringSplitter.SplitTextIntoChunks("Before\n```json\none two three four\n```", 18, truncate: true).ToList(); + + Assert.Single(markdownChunks); + Assert.Equal("Before\n```json\none\n```", markdownChunks[0]); + } + } } diff --git a/Matterhook.NET.MatterhookClient/MatterhookClient.cs b/Matterhook.NET.MatterhookClient/MatterhookClient.cs index 2895367..3945cd0 100644 --- a/Matterhook.NET.MatterhookClient/MatterhookClient.cs +++ b/Matterhook.NET.MatterhookClient/MatterhookClient.cs @@ -27,12 +27,13 @@ public MatterhookClient(string webhookUrl, int timeoutSeconds = 100) } /// - /// Post Message to Mattermost server. Messages will be automatically split. (Mattermost actually already auto splits long messages, but this will preserve whole words, rather than just splitting on message length alone. + /// Post Message to Mattermost server. Messages will be automatically split unless truncation is requested. /// /// The messsage you wish to send /// (Optional) Defaulted to 4000, but can be set to any value (Check with your Mattermost server admin!) + /// Whether to send only the first chunk of text and attachment text. /// - public async Task PostAsync(MattermostMessage inMessage, int maxMessageLength = 4000) + public async Task PostAsync(MattermostMessage inMessage, int maxMessageLength = 4000, bool truncate = false) { try { @@ -48,7 +49,7 @@ public async Task PostAsync(MattermostMessage inMessage, in if (inMessage.Text != null) { //Split messages text into chunks of maxMessageLength in size. - var textChunks = StringSplitter.SplitTextIntoChunks(inMessage.Text, maxMessageLength).ToList(); + var textChunks = StringSplitter.SplitTextIntoChunks(inMessage.Text, maxMessageLength, truncate: truncate).ToList(); //iterate through chunks and create a MattermostMessage object for each one and add it to outMessages list. foreach (var chunk in textChunks) @@ -73,7 +74,7 @@ public async Task PostAsync(MattermostMessage inMessage, in outMessages[msgIdx].Attachments.Add(att.Clone()); var attIdx = outMessages[msgIdx].Attachments.Count - 1; - var attTextChunks = StringSplitter.SplitTextIntoChunks(att.Text, 6600).ToList(); //arbitrary limit. MM files suggest limit is 7600, but that still results in attachments being truncated... + var attTextChunks = StringSplitter.SplitTextIntoChunks(att.Text, 6600, truncate: truncate).ToList(); //arbitrary limit. MM files suggest limit is 7600, but that still results in attachments being truncated... foreach (var attChunk in attTextChunks) { @@ -99,7 +100,8 @@ public async Task PostAsync(MattermostMessage inMessage, in var num = 1; foreach (var msg in outMessages) { - msg.Text = $"`({num}/{msgIdx + 1}): ` " + msg.Text; + var separator = msg.Text.StartsWith("```") || msg.Text.StartsWith("~~~") ? "\n" : " "; + msg.Text = $"`({num}/{msgIdx + 1}): `" + separator + msg.Text; num++; } } diff --git a/Matterhook.NET.MatterhookClient/StringSplitter.cs b/Matterhook.NET.MatterhookClient/StringSplitter.cs index 635b7e5..c82fa61 100644 --- a/Matterhook.NET.MatterhookClient/StringSplitter.cs +++ b/Matterhook.NET.MatterhookClient/StringSplitter.cs @@ -15,20 +15,18 @@ public static class StringSplitter /// The text to be splitted. /// Maximum size for each text chunk. /// Flag indicating if words should be preserved. + /// Flag indicating if only the first chunk should be returned. /// - public static IEnumerable SplitTextIntoChunks(string str, int maxChunkSize, bool preserveWords = true) + public static IEnumerable SplitTextIntoChunks(string str, int maxChunkSize, bool preserveWords = true, bool truncate = false) { if (string.IsNullOrEmpty(str)) throw new ArgumentException("Text can't be null or empty.", nameof(str)); if (maxChunkSize < 1) throw new ArgumentException("Max. chunk size must be at least 1 char.", nameof(maxChunkSize)); if (str.Length < maxChunkSize) return new List { str }; - if (preserveWords) - { - return SplitTextBySizePreservingWords(str, maxChunkSize); - } - else - { - return SplitTextBySize(str, maxChunkSize); - } + + var chunks = new List(PreserveFencedCodeBlocks(preserveWords + ? SplitTextBySizePreservingWords(str, maxChunkSize) + : SplitTextBySize(str, maxChunkSize))); + return truncate ? new List { chunks[0] } : chunks; } private static IEnumerable SplitTextBySize(string str, int maxChunkSize) @@ -52,7 +50,8 @@ private static IEnumerable SplitTextBySizePreservingWords(string str, in { if (word.Length + tempString.Length + 1 > maxChunkSize) { - list.Add(tempString.ToString()); + if (tempString.Length > 0) + list.Add(tempString.ToString()); tempString.Clear(); } tempString.Append(tempString.Length > 0 ? " " + word : word); @@ -61,5 +60,61 @@ private static IEnumerable SplitTextBySizePreservingWords(string str, in list.Add(tempString.ToString()); return list; } + + private static IEnumerable PreserveFencedCodeBlocks(IEnumerable chunks) + { + var chunkList = new List(chunks); + var result = new List(); + string openingFence = null; + string closingFence = null; + + for (var i = 0; i < chunkList.Count; i++) + { + var chunk = chunkList[i]; + var prefix = openingFence == null ? string.Empty : openingFence + "\n"; + + foreach (var line in chunk.Split('\n')) + { + var fence = GetFence(line); + if (fence == null) + continue; + + if (openingFence == null) + { + openingFence = line; + closingFence = fence; + } + else if (fence == closingFence) + { + openingFence = null; + closingFence = null; + } + } + + var suffix = openingFence != null && i < chunkList.Count - 1 + ? "\n" + closingFence + : string.Empty; + result.Add(prefix + chunk + suffix); + } + + return result; + } + + private static string GetFence(string line) + { + var trimmedLine = line.TrimStart(' ', '\t'); + if (trimmedLine.Length < 3) + return null; + + var character = trimmedLine[0]; + if (character != '`' && character != '~') + return null; + + var length = 0; + while (length < trimmedLine.Length && trimmedLine[length] == character) + length++; + + return length >= 3 ? new string(character, length) : null; + } } } \ No newline at end of file