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 new file mode 100644 index 00000000000..191d0a6c238 --- /dev/null +++ b/extensions/mattermost/src/mattermost/monitor-helpers.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; +import { normalizeMention } from "./monitor-helpers.js"; + +describe("normalizeMention", () => { + it("returns trimmed text when no mention provided", () => { + expect(normalizeMention(" hello world ", undefined)).toBe("hello world"); + }); + + it("strips bot mention from text", () => { + expect(normalizeMention("@echobot hello", "echobot")).toBe("hello"); + }); + + it("strips mention case-insensitively", () => { + expect(normalizeMention("@EchoBot hello", "echobot")).toBe("hello"); + }); + + it("preserves newlines in multi-line messages", () => { + const input = "@echobot\nline1\nline2\nline3"; + const result = normalizeMention(input, "echobot"); + expect(result).toBe("line1\nline2\nline3"); + }); + + it("preserves Markdown headings", () => { + const input = "@echobot\n# Heading\n\nSome text"; + const result = normalizeMention(input, "echobot"); + expect(result).toContain("# Heading"); + expect(result).toContain("\n"); + }); + + it("preserves Markdown blockquotes", () => { + const input = "@echobot\n> quoted line\n> second line"; + const result = normalizeMention(input, "echobot"); + expect(result).toContain("> quoted line"); + expect(result).toContain("> second line"); + }); + + it("preserves Markdown lists", () => { + const input = "@echobot\n- item A\n- item B\n - sub B1"; + const result = normalizeMention(input, "echobot"); + expect(result).toContain("- item A"); + expect(result).toContain("- item B"); + }); + + it("preserves task lists", () => { + const input = "@echobot\n- [ ] todo\n- [x] done"; + const result = normalizeMention(input, "echobot"); + expect(result).toContain("- [ ] todo"); + expect(result).toContain("- [x] done"); + }); + + it("handles mention in middle of text", () => { + const input = "hey @echobot check this\nout"; + const result = normalizeMention(input, "echobot"); + expect(result).toBe("hey check this\nout"); + }); + + it("preserves leading indentation for nested lists", () => { + const input = "@echobot\n- item\n - nested\n - deep"; + const result = normalizeMention(input, "echobot"); + expect(result).toContain(" - nested"); + 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 1724f577485..de264e6cf2c 100644 --- a/extensions/mattermost/src/mattermost/monitor-helpers.ts +++ b/extensions/mattermost/src/mattermost/monitor-helpers.ts @@ -70,3 +70,38 @@ export function resolveThreadSessionKeys(params: { normalizeThreadId: (threadId) => threadId, }); } + +/** + * Strip bot mention from message text while preserving newlines and + * block-level Markdown formatting (headings, lists, blockquotes). + */ +export function normalizeMention(text: string, mention: string | undefined): string { + if (!mention) { + return text.trim(); + } + const escaped = mention.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + 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"); +} diff --git a/extensions/mattermost/src/mattermost/monitor.ts b/extensions/mattermost/src/mattermost/monitor.ts index 93d4ce1cfcb..59bc6b39aee 100644 --- a/extensions/mattermost/src/mattermost/monitor.ts +++ b/extensions/mattermost/src/mattermost/monitor.ts @@ -70,6 +70,7 @@ import { import { createDedupeCache, formatInboundFromLabel, + normalizeMention, resolveThreadSessionKeys, } from "./monitor-helpers.js"; import { resolveOncharPrefixes, stripOncharPrefix } from "./monitor-onchar.js"; @@ -143,15 +144,6 @@ function resolveRuntime(opts: MonitorMattermostOpts): RuntimeEnv { ); } -function normalizeMention(text: string, mention: string | undefined): string { - if (!mention) { - return text.trim(); - } - const escaped = mention.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const re = new RegExp(`@${escaped}\\b`, "gi"); - return text.replace(re, " ").replace(/\s+/g, " ").trim(); -} - function isSystemPost(post: MattermostPost): boolean { const type = post.type?.trim(); return Boolean(type); diff --git a/src/config/markdown-tables.test.ts b/src/config/markdown-tables.test.ts new file mode 100644 index 00000000000..0049ccf9645 --- /dev/null +++ b/src/config/markdown-tables.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { DEFAULT_TABLE_MODES } from "./markdown-tables.js"; + +describe("DEFAULT_TABLE_MODES", () => { + it("mattermost mode is off", () => { + expect(DEFAULT_TABLE_MODES.get("mattermost")).toBe("off"); + }); + + it("signal mode is bullets", () => { + expect(DEFAULT_TABLE_MODES.get("signal")).toBe("bullets"); + }); + + it("whatsapp mode is bullets", () => { + expect(DEFAULT_TABLE_MODES.get("whatsapp")).toBe("bullets"); + }); +}); diff --git a/src/config/markdown-tables.ts b/src/config/markdown-tables.ts index 2095cd87b33..def751dce81 100644 --- a/src/config/markdown-tables.ts +++ b/src/config/markdown-tables.ts @@ -14,9 +14,10 @@ type MarkdownConfigSection = MarkdownConfigEntry & { accounts?: Record; }; -const DEFAULT_TABLE_MODES = new Map([ +export const DEFAULT_TABLE_MODES = new Map([ ["signal", "bullets"], ["whatsapp", "bullets"], + ["mattermost", "off"], ]); const isMarkdownTableMode = (value: unknown): value is MarkdownTableMode =>