From 64c554e80867d7591fab06b4dbf99b614f74ed2e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:48:39 +0000 Subject: [PATCH 1/4] Initial plan From 1eaf53d19a7d7f9dec3238f42b29fb1dd5ab1ff3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:52:13 +0000 Subject: [PATCH 2/4] Preserve Markdown code blocks when splitting Co-authored-by: PromoFaux <1998970+PromoFaux@users.noreply.github.com> --- .../MiscTests.cs | 14 +++++ .../MatterhookClient.cs | 3 +- .../StringSplitter.cs | 60 ++++++++++++++++++- 3 files changed, 74 insertions(+), 3 deletions(-) diff --git a/Matterhook.NET.MatterhookClient.Tests/MiscTests.cs b/Matterhook.NET.MatterhookClient.Tests/MiscTests.cs index 96bc82c..4da30ce 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,18 @@ 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]); + } + } } diff --git a/Matterhook.NET.MatterhookClient/MatterhookClient.cs b/Matterhook.NET.MatterhookClient/MatterhookClient.cs index 2895367..ec2a7b6 100644 --- a/Matterhook.NET.MatterhookClient/MatterhookClient.cs +++ b/Matterhook.NET.MatterhookClient/MatterhookClient.cs @@ -99,7 +99,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..6b83ef4 100644 --- a/Matterhook.NET.MatterhookClient/StringSplitter.cs +++ b/Matterhook.NET.MatterhookClient/StringSplitter.cs @@ -23,11 +23,11 @@ public static IEnumerable SplitTextIntoChunks(string str, int maxChunkSi if (str.Length < maxChunkSize) return new List { str }; if (preserveWords) { - return SplitTextBySizePreservingWords(str, maxChunkSize); + return PreserveFencedCodeBlocks(SplitTextBySizePreservingWords(str, maxChunkSize)); } else { - return SplitTextBySize(str, maxChunkSize); + return PreserveFencedCodeBlocks(SplitTextBySize(str, maxChunkSize)); } } @@ -61,5 +61,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 From 8b27d53fa3f845e34bc87511aab7171f07389295 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 2 Sep 2026 22:38:39 +0000 Subject: [PATCH 3/4] Add optional message truncation Co-authored-by: PromoFaux <1998970+PromoFaux@users.noreply.github.com> --- .../MiscTests.cs | 14 ++++++++++++++ .../MatterhookClient.cs | 9 +++++---- .../StringSplitter.cs | 19 +++++++++---------- 3 files changed, 28 insertions(+), 14 deletions(-) diff --git a/Matterhook.NET.MatterhookClient.Tests/MiscTests.cs b/Matterhook.NET.MatterhookClient.Tests/MiscTests.cs index 4da30ce..cc920e6 100644 --- a/Matterhook.NET.MatterhookClient.Tests/MiscTests.cs +++ b/Matterhook.NET.MatterhookClient.Tests/MiscTests.cs @@ -34,5 +34,19 @@ public void StringSplitterPreservesFencedCodeBlocksAcrossChunks() 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 ec2a7b6..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) { diff --git a/Matterhook.NET.MatterhookClient/StringSplitter.cs b/Matterhook.NET.MatterhookClient/StringSplitter.cs index 6b83ef4..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 PreserveFencedCodeBlocks(SplitTextBySizePreservingWords(str, maxChunkSize)); - } - else - { - return PreserveFencedCodeBlocks(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); From 2d9293f21ea139af7757a71e8d5fc4f7f94e8648 Mon Sep 17 00:00:00 2001 From: Adam Warner Date: Sat, 12 Sep 2026 20:10:51 +0100 Subject: [PATCH 4/4] Fix fenced-block preservation exceeding maxChunkSize PreserveFencedCodeBlocks reopened/closed fences after chunks were already packed to maxChunkSize, so the added fence lines could push a chunk past the requested size limit - defeating the point of maxMessageLength. SplitTextBySizePreservingWords now tracks fence state per word up front and reserves room for a closing fence before deciding a word still fits, so chunks stay within maxChunkSize (same caveat as before: a single word/fence-line bigger than maxChunkSize on its own still can't be shrunk further). Also trims leading whitespace before checking for a fence when choosing the sequence-number separator, so an indented reopened fence still gets its own line. Co-Authored-By: Claude Sonnet 5 --- .../MiscTests.cs | 27 ++++- .../MatterhookClient.cs | 3 +- .../StringSplitter.cs | 114 +++++++++++------- 3 files changed, 93 insertions(+), 51 deletions(-) diff --git a/Matterhook.NET.MatterhookClient.Tests/MiscTests.cs b/Matterhook.NET.MatterhookClient.Tests/MiscTests.cs index cc920e6..1982fc5 100644 --- a/Matterhook.NET.MatterhookClient.Tests/MiscTests.cs +++ b/Matterhook.NET.MatterhookClient.Tests/MiscTests.cs @@ -28,10 +28,25 @@ public void StringSplitterPreservesFencedCodeBlocksAcrossChunks() 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]); + Assert.Equal(6, chunks.Count); + Assert.Equal("Before\n```json\none\n```", chunks[0]); + Assert.Equal("```json\ntwo three\n```", chunks[1]); + Assert.Equal("```json\nfour five six\n```", chunks[2]); + Assert.Equal("```json\nseven eight\n```", chunks[3]); + Assert.Equal("```json\nnine\n```", chunks[4]); + Assert.Equal("```json\nten\n```\nAfter", chunks[5]); + } + + [Fact] + public void StringSplitterKeepsFencedChunksWithinMaxChunkSize() + { + var text = "Before\n```json\none two three four five six seven eight nine ten\n```\nAfter"; + const int maxChunkSize = 25; + + var chunks = StringSplitter.SplitTextIntoChunks(text, maxChunkSize).ToList(); + + Assert.All(chunks, chunk => Assert.True(chunk.Length <= maxChunkSize, + $"Chunk '{chunk}' ({chunk.Length} chars) exceeds maxChunkSize ({maxChunkSize}).")); } [Fact] @@ -42,10 +57,10 @@ public void StringSplitterTruncatesToTheFirstChunk() 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(); + var markdownChunks = StringSplitter.SplitTextIntoChunks("Before\n```json\none two three four\n```", 30, truncate: true).ToList(); Assert.Single(markdownChunks); - Assert.Equal("Before\n```json\none\n```", markdownChunks[0]); + Assert.Equal("Before\n```json\none two\n```", markdownChunks[0]); } } diff --git a/Matterhook.NET.MatterhookClient/MatterhookClient.cs b/Matterhook.NET.MatterhookClient/MatterhookClient.cs index 3945cd0..657404e 100644 --- a/Matterhook.NET.MatterhookClient/MatterhookClient.cs +++ b/Matterhook.NET.MatterhookClient/MatterhookClient.cs @@ -100,7 +100,8 @@ public async Task PostAsync(MattermostMessage inMessage, in var num = 1; foreach (var msg in outMessages) { - var separator = msg.Text.StartsWith("```") || msg.Text.StartsWith("~~~") ? "\n" : " "; + var trimmedText = msg.Text.TrimStart(' ', '\t'); + var separator = trimmedText.StartsWith("```") || trimmedText.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 c82fa61..6ce4874 100644 --- a/Matterhook.NET.MatterhookClient/StringSplitter.cs +++ b/Matterhook.NET.MatterhookClient/StringSplitter.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Text; @@ -23,15 +23,15 @@ public static IEnumerable SplitTextIntoChunks(string str, int maxChunkSi 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 }; - var chunks = new List(PreserveFencedCodeBlocks(preserveWords + var chunks = preserveWords ? SplitTextBySizePreservingWords(str, maxChunkSize) - : SplitTextBySize(str, maxChunkSize))); + : SplitTextBySize(str, maxChunkSize); + return truncate ? new List { chunks[0] } : chunks; } - private static IEnumerable SplitTextBySize(string str, int maxChunkSize) + private static List SplitTextBySize(string str, int maxChunkSize) { - if (str.Length < maxChunkSize) return new List { str }; var list = new List(); for (var i = 0; i < str.Length; i += maxChunkSize) { @@ -40,63 +40,89 @@ private static IEnumerable SplitTextBySize(string str, int maxChunkSize) return list; } - private static IEnumerable SplitTextBySizePreservingWords(string str, int maxChunkSize) + /// + /// Splits text into chunks no larger than maxChunkSize, preserving whole words. Any fenced + /// code block (``` or ~~~) that a chunk boundary would otherwise land inside of is closed at + /// the end of the chunk and re-opened (with its original language hint) at the start of the + /// next one. The overhead of that closing/re-opening text is reserved for up front, so a + /// chunk is only ever allowed to grow past maxChunkSize when a single word (including any + /// fence line stuck to it) is already too big to fit on its own - the same pre-existing + /// limit that plain word-preserving splitting has always had. + /// + private static List SplitTextBySizePreservingWords(string str, int maxChunkSize) { - if (str.Length < maxChunkSize) return new List { str }; var words = str.Split(' '); - var tempString = new StringBuilder(""); - var list = new List(); - foreach (var word in words) + + // Fence state (opening line + closing marker) as of just after each word - computed up + // front so the packing loop below knows, before committing a word to the current chunk, + // whether it would need to leave a fence open (and therefore reserve room to close it). + var fenceStateAfterWord = new (string OpenLine, string CloseMarker)[words.Length]; + string openLine = null; + string closeMarker = null; + for (var i = 0; i < words.Length; i++) { - if (word.Length + tempString.Length + 1 > maxChunkSize) + foreach (var line in words[i].Split('\n')) { - if (tempString.Length > 0) - list.Add(tempString.ToString()); - tempString.Clear(); + var fence = GetFence(line); + if (fence == null) + continue; + + if (openLine == null) + { + openLine = line; + closeMarker = fence; + } + else if (fence == closeMarker) + { + openLine = null; + closeMarker = null; + } } - tempString.Append(tempString.Length > 0 ? " " + word : word); + + fenceStateAfterWord[i] = (openLine, closeMarker); } - if (tempString.Length >= 1) - 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; + var current = new StringBuilder(); + var stateAtEndOfCurrent = (OpenLine: (string)null, CloseMarker: (string)null); - for (var i = 0; i < chunkList.Count; i++) + for (var i = 0; i < words.Length; i++) { - var chunk = chunkList[i]; - var prefix = openingFence == null ? string.Empty : openingFence + "\n"; + var word = words[i]; + var stateBeforeWord = i == 0 ? (OpenLine: (string)null, CloseMarker: (string)null) : fenceStateAfterWord[i - 1]; - foreach (var line in chunk.Split('\n')) + if (current.Length > 0) { - var fence = GetFence(line); - if (fence == null) - continue; + var stateAfterWord = fenceStateAfterWord[i]; + var addLength = 1 + word.Length; // +1 for the joining space + var suffixLength = stateAfterWord.CloseMarker != null ? stateAfterWord.CloseMarker.Length + 1 : 0; - if (openingFence == null) + if (current.Length + addLength + suffixLength > maxChunkSize) { - openingFence = line; - closingFence = fence; - } - else if (fence == closingFence) - { - openingFence = null; - closingFence = null; + if (stateAtEndOfCurrent.OpenLine != null) + current.Append('\n').Append(stateAtEndOfCurrent.CloseMarker); + result.Add(current.ToString()); + current.Clear(); } } - var suffix = openingFence != null && i < chunkList.Count - 1 - ? "\n" + closingFence - : string.Empty; - result.Add(prefix + chunk + suffix); + if (current.Length == 0) + { + if (stateBeforeWord.OpenLine != null) + current.Append(stateBeforeWord.OpenLine).Append('\n'); + current.Append(word); + } + else + { + current.Append(' ').Append(word); + } + + stateAtEndOfCurrent = fenceStateAfterWord[i]; } + if (current.Length > 0) + result.Add(current.ToString()); + return result; } @@ -117,4 +143,4 @@ private static string GetFence(string line) return length >= 3 ? new string(character, length) : null; } } -} \ No newline at end of file +}