Skip to content
This repository was archived by the owner on Sep 12, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions Matterhook.NET.MatterhookClient.Tests/MiscTests.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Xunit;

namespace Matterhook.NET.MatterhookClient.Tests
Expand All @@ -20,5 +21,47 @@ public void StringSplitterThrowsExceptionWhenChunkSizeOfLessThan1()
Assert.Throws<ArgumentException>(() => 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(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]
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```", 30, truncate: true).ToList();

Assert.Single(markdownChunks);
Assert.Equal("Before\n```json\none two\n```", markdownChunks[0]);
}

}
}
13 changes: 8 additions & 5 deletions Matterhook.NET.MatterhookClient/MatterhookClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,13 @@ public MatterhookClient(string webhookUrl, int timeoutSeconds = 100)
}

/// <summary>
/// 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.
/// </summary>
/// <param name="inMessage">The messsage you wish to send</param>
/// <param name="maxMessageLength">(Optional) Defaulted to 4000, but can be set to any value (Check with your Mattermost server admin!)</param>
/// <param name="truncate">Whether to send only the first chunk of text and attachment text.</param>
/// <returns></returns>
public async Task<HttpResponseMessage> PostAsync(MattermostMessage inMessage, int maxMessageLength = 4000)
public async Task<HttpResponseMessage> PostAsync(MattermostMessage inMessage, int maxMessageLength = 4000, bool truncate = false)
{
try
{
Expand All @@ -48,7 +49,7 @@ public async Task<HttpResponseMessage> 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)
Expand All @@ -73,7 +74,7 @@ public async Task<HttpResponseMessage> 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)
{
Expand All @@ -99,7 +100,9 @@ public async Task<HttpResponseMessage> PostAsync(MattermostMessage inMessage, in
var num = 1;
foreach (var msg in outMessages)
{
msg.Text = $"`({num}/{msgIdx + 1}): ` " + msg.Text;
var trimmedText = msg.Text.TrimStart(' ', '\t');
var separator = trimmedText.StartsWith("```") || trimmedText.StartsWith("~~~") ? "\n" : " ";
msg.Text = $"`({num}/{msgIdx + 1}): `" + separator + msg.Text;
num++;
}
}
Expand Down
131 changes: 106 additions & 25 deletions Matterhook.NET.MatterhookClient/StringSplitter.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Text;

Expand All @@ -15,25 +15,23 @@ public static class StringSplitter
/// <param name="str">The text to be splitted.</param>
/// <param name="maxChunkSize">Maximum size for each text chunk.</param>
/// <param name="preserveWords">Flag indicating if words should be preserved.</param>
/// <param name="truncate">Flag indicating if only the first chunk should be returned.</param>
/// <returns></returns>
public static IEnumerable<string> SplitTextIntoChunks(string str, int maxChunkSize, bool preserveWords = true)
public static IEnumerable<string> 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<string> { str };
if (preserveWords)
{
return SplitTextBySizePreservingWords(str, maxChunkSize);
}
else
{
return SplitTextBySize(str, maxChunkSize);
}

var chunks = preserveWords
? SplitTextBySizePreservingWords(str, maxChunkSize)
: SplitTextBySize(str, maxChunkSize);

return truncate ? new List<string> { chunks[0] } : chunks;
}

private static IEnumerable<string> SplitTextBySize(string str, int maxChunkSize)
private static List<string> SplitTextBySize(string str, int maxChunkSize)
{
if (str.Length < maxChunkSize) return new List<string> { str };
var list = new List<string>();
for (var i = 0; i < str.Length; i += maxChunkSize)
{
Expand All @@ -42,24 +40,107 @@ private static IEnumerable<string> SplitTextBySize(string str, int maxChunkSize)
return list;
}

private static IEnumerable<string> SplitTextBySizePreservingWords(string str, int maxChunkSize)
/// <summary>
/// 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.
/// </summary>
private static List<string> SplitTextBySizePreservingWords(string str, int maxChunkSize)
{
if (str.Length < maxChunkSize) return new List<string> { str };
var words = str.Split(' ');
var tempString = new StringBuilder("");
var list = new List<string>();
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'))
{
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;

var result = new List<string>();
var current = new StringBuilder();
var stateAtEndOfCurrent = (OpenLine: (string)null, CloseMarker: (string)null);

for (var i = 0; i < words.Length; i++)
{
var word = words[i];
var stateBeforeWord = i == 0 ? (OpenLine: (string)null, CloseMarker: (string)null) : fenceStateAfterWord[i - 1];

if (current.Length > 0)
{
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 (current.Length + addLength + suffixLength > maxChunkSize)
{
if (stateAtEndOfCurrent.OpenLine != null)
current.Append('\n').Append(stateAtEndOfCurrent.CloseMarker);
result.Add(current.ToString());
current.Clear();
}
}

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;
}

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;
}
}
}
}
Loading