mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 21:07:01 -06:00
fix(slack): preserve table formatting in edits and previews (#130979)
This commit is contained in:
committed by
GitHub
parent
dd2d66c26f
commit
dc0b41c0db
@@ -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();
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<typeof import("../../format.js")>("../../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**",
|
||||
});
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -121,7 +121,7 @@ export function resolveSlackReplyDeliveryMessages(params: {
|
||||
});
|
||||
}
|
||||
if (outsideText) {
|
||||
messages.push({ text: outsideText });
|
||||
messages.push({ text: outsideText, authoredTextPlacement: "outside-blocks" });
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user