diff --git a/extensions/slack/src/actions.blocks.test.ts b/extensions/slack/src/actions.blocks.test.ts index 8c058987af0d..1219cc017631 100644 --- a/extensions/slack/src/actions.blocks.test.ts +++ b/extensions/slack/src/actions.blocks.test.ts @@ -1,6 +1,13 @@ // Slack tests cover actions.blocks plugin behavior. -import { describe, expect, it } from "vitest"; +import { + createTestRegistry, + resetPluginRuntimeStateForTest, + setActivePluginRegistry, +} from "openclaw/plugin-sdk/channel-test-helpers"; +import type { MarkdownTableMode } from "openclaw/plugin-sdk/config-contracts"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { createSlackEditTestClient, createSlackSendTestClient } from "./blocks.test-helpers.js"; +import { slackSetupPlugin } from "./channel.setup.js"; import { countSlackTextUtf8Bytes } from "./truncate.js"; const { editSlackMessage, editSlackRenderedMessage, sendSlackMessage } = @@ -69,6 +76,75 @@ describe("sendSlackMessage blocks", () => { }); describe("editSlackMessage blocks", () => { + beforeEach(() => { + setActivePluginRegistry( + createTestRegistry([{ pluginId: "slack", source: "test", plugin: slackSetupPlugin }]), + ); + }); + afterEach(() => resetPluginRuntimeStateForTest()); + + const table = "| Name | Value |\n| --- | --- |\n| Beta | 2 |"; + const codeTable = "```\n| Name | Value |\n| ---- | ----- |\n| Beta | 2 |\n```"; + const bulletTable = "*Beta*\n• Value: 2"; + + it.each<{ + name: string; + channelMode?: MarkdownTableMode; + accountMode?: MarkdownTableMode; + useDefaultAccount?: boolean; + expected: string; + }>([ + { name: "default code tables", expected: codeTable }, + { name: "channel code tables", channelMode: "code", expected: codeTable }, + { name: "channel bullet tables", channelMode: "bullets", expected: bulletTable }, + { name: "disabled tables", channelMode: "off", expected: table }, + { + name: "account bullet override", + channelMode: "off", + accountMode: "bullets", + expected: bulletTable, + }, + { + name: "account disabled override", + channelMode: "code", + accountMode: "off", + expected: table, + }, + { + name: "configured default account override", + channelMode: "off", + accountMode: "bullets", + useDefaultAccount: true, + expected: bulletTable, + }, + ])( + "preserves $name when editing authored Markdown", + async ({ channelMode, accountMode, useDefaultAccount, expected }) => { + const client = createSlackEditTestClient(); + + await editSlackMessage("C123", "171234.567", table, { + token: "xoxb-test", + client, + accountId: useDefaultAccount ? undefined : "work", + cfg: { + channels: { + slack: { + defaultAccount: "work", + markdown: { tables: channelMode }, + accounts: { work: { markdown: { tables: accountMode } } }, + }, + }, + }, + }); + + expect(client.chat.update).toHaveBeenCalledExactlyOnceWith({ + channel: "C123", + ts: "171234.567", + text: expected, + }); + }, + ); + it("renders authored Markdown using the same mrkdwn dialect as sends", async () => { const client = createSlackEditTestClient(); diff --git a/extensions/slack/src/actions.ts b/extensions/slack/src/actions.ts index 185084389531..673708f95874 100644 --- a/extensions/slack/src/actions.ts +++ b/extensions/slack/src/actions.ts @@ -2,11 +2,12 @@ import type { Block, KnownBlock, WebClient } from "@slack/web-api"; import { normalizeAccountId } from "openclaw/plugin-sdk/account-resolution"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime"; import { requireRuntimeConfig } from "openclaw/plugin-sdk/plugin-config-runtime"; import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { z } from "zod"; -import { resolveSlackAccount } from "./accounts.js"; +import { resolveDefaultSlackAccountId, resolveSlackAccount } from "./accounts.js"; import { SLACK_PRIVATE_ACTION_DELIVERY_RESULT } from "./action-threading.js"; import type { SlackAuthoredTextPlacement } from "./authored-text.js"; import { buildSlackBlocksFallbackText } from "./blocks-fallback.js"; @@ -389,7 +390,11 @@ export async function editSlackMessage( content: string, opts: SlackActionClientOpts & { blocks?: (Block | KnownBlock)[] } = {}, ) { - await editSlackRenderedMessage(channelId, messageId, normalizeSlackOutboundText(content), opts); + const accountId = + opts.accountId ?? (opts.cfg ? resolveDefaultSlackAccountId(opts.cfg) : undefined); + const tableMode = resolveMarkdownTableMode({ cfg: opts.cfg, channel: "slack", accountId }); + const text = normalizeSlackOutboundText(content, { tableMode }); + await editSlackRenderedMessage(channelId, messageId, text, opts); } // Finalized previews already contain Slack mrkdwn; a second Markdown render changes its meaning. diff --git a/extensions/slack/src/format.test.ts b/extensions/slack/src/format.test.ts index c003e0e527a7..ec1e8878a2e9 100644 --- a/extensions/slack/src/format.test.ts +++ b/extensions/slack/src/format.test.ts @@ -56,6 +56,11 @@ describe("chunkSlackMrkdwnText", () => { }); describe("normalizeSlackOutboundText", () => { + it("leaves table parsing off for callers without an authored-text table mode", () => { + const table = "| Name | Value |\n| --- | --- |\n| Beta | 2 |"; + expect(normalizeSlackOutboundText(table)).toBe(table); + }); + it("marks assistant-authored transcript role headers after parsing Markdown", () => { expect(normalizeSlackOutboundText("**user**[Thu 2026-07-02] question")).toBe( "`user[Thu 2026-07-02]` question", diff --git a/extensions/slack/src/format.ts b/extensions/slack/src/format.ts index 4934c87d1f35..60de3c621451 100644 --- a/extensions/slack/src/format.ts +++ b/extensions/slack/src/format.ts @@ -425,7 +425,10 @@ function buildSlackRenderOptions() { }; } -function markdownToSlackMrkdwn(markdown: string, options: SlackMarkdownOptions = {}): string { +export function normalizeSlackOutboundText( + markdown: string, + options: SlackMarkdownOptions = {}, +): string { const ir = makeSlackEmphasisStylesSafe( markdownToIR(markdown ?? "", { assistantTranscriptRoleHeaders: true, @@ -436,11 +439,9 @@ function markdownToSlackMrkdwn(markdown: string, options: SlackMarkdownOptions = tableMode: options.tableMode, }), ); - return renderMarkdownWithMarkers(ir, buildSlackRenderOptions(), SLACK_FORMAT_PROFILE); -} - -export function normalizeSlackOutboundText(markdown: string): string { - return protectSlackAssistantTranscriptRoleHeaders(markdownToSlackMrkdwn(markdown ?? "")); + return protectSlackAssistantTranscriptRoleHeaders( + renderMarkdownWithMarkers(ir, buildSlackRenderOptions(), SLACK_FORMAT_PROFILE), + ); } /** Chunk already-rendered Slack mrkdwn without splitting entities or code markers. */ diff --git a/extensions/slack/src/message-action-dispatch.test.ts b/extensions/slack/src/message-action-dispatch.test.ts index d90f283d405c..61aee04e1d1f 100644 --- a/extensions/slack/src/message-action-dispatch.test.ts +++ b/extensions/slack/src/message-action-dispatch.test.ts @@ -1,5 +1,11 @@ // Slack tests cover message action dispatch plugin behavior. -import { describe, expect, it, vi } from "vitest"; +import { + createTestRegistry, + resetPluginRuntimeStateForTest, + setActivePluginRegistry, +} from "openclaw/plugin-sdk/channel-test-helpers"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { slackSetupPlugin } from "./channel.setup.js"; import { handleSlackMessageAction } from "./message-action-dispatch.js"; import { extractSlackToolSend } from "./message-actions.js"; import { renderSlackMessagePresentationFallbackText } from "./presentation-fallback.js"; @@ -83,7 +89,20 @@ function largeTablePresentation() { }; } +const paddedMarkdownTable = [ + `| ${"H".repeat(200)} | Value |`, + "| --- | --- |", + ...Array.from({ length: 20 }, () => "| x | 2 |"), +].join("\n"); + describe("handleSlackMessageAction", () => { + beforeEach(() => { + setActivePluginRegistry( + createTestRegistry([{ pluginId: "slack", source: "test", plugin: slackSetupPlugin }]), + ); + }); + afterEach(() => resetPluginRuntimeStateForTest()); + it("defaults reactions to the current inbound Slack message", async () => { const invoke = createInvokeSpy(); const toolContext = { currentMessageId: "171234.567" }; @@ -267,6 +286,10 @@ describe("handleSlackMessageAction", () => { { name: "ASCII", message: `${"x".repeat(4_000)}TAIL` }, { name: "multibyte", message: `${"😀".repeat(1_000)}TAIL` }, { name: "expanded Slack markdown", message: `${"&".repeat(801)}TAIL` }, + { + name: "padded Markdown table", + message: paddedMarkdownTable, + }, ])("rejects oversized $name text-only edits before sending", async ({ message }) => { const invoke = createInvokeSpy(); @@ -285,6 +308,34 @@ describe("handleSlackMessageAction", () => { expect(invoke).not.toHaveBeenCalled(); }); + it.each(["off", "bullets"] as const)( + "measures the account's %s table rendering before rejecting an edit", + async (tables) => { + const invoke = createInvokeSpy(); + await handleSlackMessageAction({ + providerId: "slack", + ctx: { + action: "edit", + cfg: { + channels: { + slack: { + defaultAccount: "work", + markdown: { tables: "code" }, + accounts: { work: { markdown: { tables } } }, + }, + }, + }, + params: { channelId: "C1", messageId: "171234.567", message: paddedMarkdownTable }, + } as never, + invoke: invoke as never, + }); + expect(firstAction(invoke)).toMatchObject({ + action: "editMessage", + content: paddedMarkdownTable, + }); + }, + ); + it("accepts a text-only edit at Slack's exact byte limit", async () => { const invoke = createInvokeSpy(); const message = "x".repeat(4_000); diff --git a/extensions/slack/src/message-action-dispatch.ts b/extensions/slack/src/message-action-dispatch.ts index 3a13764ee005..f303d114d846 100644 --- a/extensions/slack/src/message-action-dispatch.ts +++ b/extensions/slack/src/message-action-dispatch.ts @@ -8,6 +8,7 @@ import { normalizeLegacyInteractiveReply, normalizeMessagePresentation, } from "openclaw/plugin-sdk/interactive-runtime"; +import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime"; import { readPositiveIntegerParam, readStringParam } from "openclaw/plugin-sdk/param-readers"; import { normalizeOptionalLowercaseString, @@ -232,9 +233,14 @@ export async function handleSlackMessageAction(params: { const accessibleContent = renderedPresentation.usesPresentationTextFallback ? renderSlackMessagePresentationFallbackText({ text: content, presentation }) : resolveSlackPresentationText(content, presentation); + const tableMode = resolveMarkdownTableMode({ + cfg, + channel: "slack", + accountId: accountId ?? resolveDefaultSlackAccountId(cfg), + }); if ( !blocks && - countSlackTextUtf8Bytes(normalizeSlackOutboundText(accessibleContent)) > + countSlackTextUtf8Bytes(normalizeSlackOutboundText(accessibleContent, { tableMode })) > SLACK_EDIT_TEXT_MAX_BYTES ) { const editSubject = renderedPresentation.usesPresentationTextFallback diff --git a/extensions/slack/src/monitor/message-handler/dispatch.preview-fallback.test.ts b/extensions/slack/src/monitor/message-handler/dispatch.preview-fallback.test.ts index 5b1967b4a495..27b2a16cfbd3 100644 --- a/extensions/slack/src/monitor/message-handler/dispatch.preview-fallback.test.ts +++ b/extensions/slack/src/monitor/message-handler/dispatch.preview-fallback.test.ts @@ -1,7 +1,13 @@ -import type { GetReplyOptions, ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; // Slack tests cover dispatch.preview fallback plugin behavior. +import { + createTestRegistry, + resetPluginRuntimeStateForTest, + setActivePluginRegistry, +} from "openclaw/plugin-sdk/channel-test-helpers"; +import type { GetReplyOptions, ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures"; -import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { slackSetupPlugin } from "../../channel.setup.js"; const FINAL_REPLY_TEXT = "final answer"; const THREAD_TS = "thread-1"; @@ -1131,6 +1137,9 @@ describe("dispatchPreparedSlackMessage preview fallback", () => { }); beforeEach(() => { + setActivePluginRegistry( + createTestRegistry([{ pluginId: "slack", source: "test", plugin: slackSetupPlugin }]), + ); createSlackDraftStreamMock.mockReset(); deliverRepliesMock.mockReset(); finalizeSlackPreviewEditMock.mockReset(); @@ -1184,6 +1193,8 @@ describe("dispatchPreparedSlackMessage preview fallback", () => { emitSlackMessageSentHooksMock.mockClear(); }); + afterEach(() => resetPluginRuntimeStateForTest()); + it("forwards durable ingress ownership into reply options", async () => { const turnAdoptionLifecycle = { admission: "exclusive", @@ -1635,6 +1646,41 @@ describe("dispatchPreparedSlackMessage preview fallback", () => { expect(draftStream.clear).not.toHaveBeenCalled(); }); + it.each([ + ["code", "```\n| Name | Value |\n| ---- | ----- |\n| Beta | 2 |\n```"], + ["bullets", "*Beta*\n• Value: 2"], + ["off", "| Name | Value |\n| --- | --- |\n| Beta | 2 |"], + ] as const)( + "preserves %s table mode when finalizing authored preview text", + async (tables, expected) => { + const { normalizeSlackOutboundText } = + await vi.importActual("../../format.js"); + normalizeSlackOutboundTextMock.mockImplementation(normalizeSlackOutboundText); + try { + finalizeSlackPreviewEditMock.mockResolvedValueOnce(undefined); + mockedDispatchSequence = [ + { kind: "final", payload: { text: "| Name | Value |\n| --- | --- |\n| Beta | 2 |" } }, + ]; + + await dispatchPreparedSlackMessage( + createPreparedSlackMessage({ + cfg: { channels: { slack: { markdown: { tables } } } }, + }), + ); + + expect(finalizeSlackPreviewEditMock).toHaveBeenCalledOnce(); + expectMockCallArgFields(finalizeSlackPreviewEditMock, 0, "table preview edit", { + channelId: "C123", + messageId: "171234.567", + text: expected, + }); + expect(deliverRepliesMock).not.toHaveBeenCalled(); + } finally { + normalizeSlackOutboundTextMock.mockImplementation((value: string) => value.trim()); + } + }, + ); + it("finalizes native chart blocks without re-escaping accessible preview text", async () => { const draftStream = createDraftStreamStub(); const accessibleText = @@ -1732,7 +1778,9 @@ describe("dispatchPreparedSlackMessage preview fallback", () => { await dispatchPreparedSlackMessage(createPreparedSlackMessage()); expect(normalizeSlackOutboundTextMock).toHaveBeenCalledTimes(1); - expect(normalizeSlackOutboundTextMock).toHaveBeenCalledWith("**Summary**"); + expect(normalizeSlackOutboundTextMock).toHaveBeenCalledWith("**Summary**", { + tableMode: "code", + }); expectMockCallArgFields(finalizeSlackPreviewEditMock, 0, "block preview edit params", { text: "**Summary**", }); diff --git a/extensions/slack/src/monitor/message-handler/dispatch.ts b/extensions/slack/src/monitor/message-handler/dispatch.ts index 0ee08129fc20..8ebb96c1af18 100644 --- a/extensions/slack/src/monitor/message-handler/dispatch.ts +++ b/extensions/slack/src/monitor/message-handler/dispatch.ts @@ -12,6 +12,7 @@ import { deliverWithFinalizableLivePreviewAdapter, } from "openclaw/plugin-sdk/channel-outbound"; import { toErrorObject } from "openclaw/plugin-sdk/error-runtime"; +import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime"; import { buildTtsSupplementMediaPayload, getReplyPayloadTtsSupplement, @@ -233,7 +234,13 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag const previewFinalText = replyRenderPlan.mode === "single" && replyRenderPlan.textIsSlackMrkdwn ? trimmedFinalText - : normalizeSlackOutboundText((replySourceText ?? "").trim()); + : normalizeSlackOutboundText((replySourceText ?? "").trim(), { + tableMode: resolveMarkdownTableMode({ + cfg, + channel: "slack", + accountId: account.accountId, + }), + }); const previewFinalTextFitsEdit = countSlackTextUtf8Bytes(previewFinalText) <= SLACK_EDIT_TEXT_MAX_BYTES; const shouldRestoreTtsSupplementTextForPreviewFallback = diff --git a/extensions/slack/src/reply-blocks.ts b/extensions/slack/src/reply-blocks.ts index 64549507646f..d7038a43dc75 100644 --- a/extensions/slack/src/reply-blocks.ts +++ b/extensions/slack/src/reply-blocks.ts @@ -121,7 +121,7 @@ export function resolveSlackReplyDeliveryMessages(params: { }); } if (outsideText) { - messages.push({ text: outsideText }); + messages.push({ text: outsideText, authoredTextPlacement: "outside-blocks" }); } return messages; }