diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a63e369f58..155bc867062 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,7 @@ Docs: https://docs.openclaw.ai - Sandbox/subagents: pass the real configured workspace through `sessions_spawn` inheritance when a parent agent runs in a copied-workspace sandbox, so child `/agent` mounts point at the configured workspace instead of the parent sandbox copy. (#40757) Thanks @dsantoreis. - Mattermost/plugin send actions: normalize direct `replyTo` fallback handling so threaded plugin sends trim blank IDs and reuse the correct reply target again. (#41176) Thanks @hnykda. - MS Teams/allowlist resolution: use the General channel conversation ID as the resolved team key (with Graph GUID fallback) so Bot Framework runtime `channelData.team.id` matching works for team and team/channel allowlist entries. (#41838) Thanks @BradGroux. +- Mattermost/Markdown formatting: preserve first-line indentation when stripping bot mentions so nested list items and indented code blocks keep their structure, and render Mattermost tables natively by default instead of fenced-code fallback. (#18655) thanks @echo931. ## 2026.3.8 diff --git a/extensions/mattermost/src/mattermost/monitor-helpers.test.ts b/extensions/mattermost/src/mattermost/monitor-helpers.test.ts index b42a01a124f..191d0a6c238 100644 --- a/extensions/mattermost/src/mattermost/monitor-helpers.test.ts +++ b/extensions/mattermost/src/mattermost/monitor-helpers.test.ts @@ -61,10 +61,22 @@ describe("normalizeMention", () => { expect(result).toContain(" - deep"); }); + it("preserves first-line indentation for nested list items", () => { + const input = "@echobot\n - nested\n - deep"; + const result = normalizeMention(input, "echobot"); + expect(result).toBe(" - nested\n - deep"); + }); + it("preserves indented code blocks", () => { const input = "@echobot\ntext\n code line 1\n code line 2"; const result = normalizeMention(input, "echobot"); expect(result).toContain(" code line 1"); expect(result).toContain(" code line 2"); }); + + it("preserves first-line indentation for indented code blocks", () => { + const input = "@echobot\n code line 1\n code line 2"; + const result = normalizeMention(input, "echobot"); + expect(result).toBe(" code line 1\n code line 2"); + }); }); diff --git a/extensions/mattermost/src/mattermost/monitor-helpers.ts b/extensions/mattermost/src/mattermost/monitor-helpers.ts index c321f43565b..de264e6cf2c 100644 --- a/extensions/mattermost/src/mattermost/monitor-helpers.ts +++ b/extensions/mattermost/src/mattermost/monitor-helpers.ts @@ -80,16 +80,28 @@ export function normalizeMention(text: string, mention: string | undefined): str return text.trim(); } const escaped = mention.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const re = new RegExp(`@${escaped}\\b`, "gi"); - // Replace the mention itself, then clean up without destroying Markdown structure. - // 1. Remove the mention (replace with empty to avoid injecting spaces into indentation) - // 2. Collapse only runs of multiple spaces/tabs within a line (preserving leading indent) - // 3. Trim blank lines left by mention removal - return text - .replace(re, "") - .split("\n") - .map((line) => line.replace(/(\S) {2,}/g, "$1 ")) - .join("\n") - .replace(/^\s*\n/, "") - .trim(); + const hasMentionRe = new RegExp(`@${escaped}\\b`, "i"); + const leadingMentionRe = new RegExp(`^([\\t ]*)@${escaped}\\b[\\t ]*`, "i"); + const trailingMentionRe = new RegExp(`[\\t ]*@${escaped}\\b[\\t ]*$`, "i"); + const normalizedLines = text.split("\n").map((line) => { + const hadMention = hasMentionRe.test(line); + const normalizedLine = line + .replace(leadingMentionRe, "$1") + .replace(trailingMentionRe, "") + .replace(new RegExp(`@${escaped}\\b`, "gi"), "") + .replace(/(\S)[ \t]{2,}/g, "$1 "); + return { + text: normalizedLine, + mentionOnlyBlank: hadMention && normalizedLine.trim() === "", + }; + }); + + while (normalizedLines[0]?.mentionOnlyBlank) { + normalizedLines.shift(); + } + while (normalizedLines.at(-1)?.text.trim() === "") { + normalizedLines.pop(); + } + + return normalizedLines.map((line) => line.text).join("\n"); }