fix: prevent delivery-mirror re-delivery and raise Slack chunk limit (#45489)

Merged via squash.

Prepared head SHA: c7664c7b6e
Co-authored-by: theo674 <261068216+theo674@users.noreply.github.com>
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Reviewed-by: @altaywtf
This commit is contained in:
theo674
2026-03-23 17:11:19 -04:00
committed by GitHub
parent a53715e9d0
commit dbe7da7684
17 changed files with 174 additions and 18 deletions

View File

@@ -192,6 +192,11 @@ describe("slackPlugin outbound", () => {
},
};
it("advertises the 8000-character Slack default chunk limit", () => {
expect(slackOutbound.textChunkLimit).toBe(8000);
expect(slackPlugin.outbound?.textChunkLimit).toBe(8000);
});
it("uses threadId as threadTs fallback for sendText", async () => {
const sendSlack = vi.fn().mockResolvedValue({ messageId: "m-text" });
const sendText = requireSlackSendText();

View File

@@ -46,6 +46,7 @@ import {
} from "./directory-config.js";
import { resolveSlackGroupRequireMention, resolveSlackGroupToolPolicy } from "./group-policy.js";
import { isSlackInteractiveRepliesEnabled } from "./interactive-replies.js";
import { SLACK_TEXT_LIMIT } from "./limits.js";
import { normalizeAllowListLower } from "./monitor/allow-list.js";
import type { SlackProbe } from "./probe.js";
import { resolveSlackUserAllowlist } from "./resolve-users.js";
@@ -602,7 +603,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount, SlackProbe> = crea
base: {
deliveryMode: "direct",
chunker: null,
textChunkLimit: 4000,
textChunkLimit: SLACK_TEXT_LIMIT,
},
attachedResults: {
channel: "slack",

View File

@@ -98,6 +98,24 @@ describe("createSlackDraftStream", () => {
expect(warn).toHaveBeenCalledTimes(1);
});
it("allows a 4205-character preview with the default max chars", async () => {
const { stream, send, warn } = createDraftStreamHarness();
const text = "a".repeat(4205);
stream.update(text);
await stream.flush();
expect(send).toHaveBeenCalledTimes(1);
expect(send).toHaveBeenCalledWith(
"channel:C123",
text,
expect.objectContaining({
token: "xoxb-test",
}),
);
expect(warn).not.toHaveBeenCalled();
});
it("clear removes preview message when one exists", async () => {
const { stream, remove } = createDraftStreamHarness();

View File

@@ -1,8 +1,8 @@
import { createDraftStreamLoop } from "openclaw/plugin-sdk/channel-lifecycle";
import { deleteSlackMessage, editSlackMessage } from "./actions.js";
import { SLACK_TEXT_LIMIT } from "./limits.js";
import { sendMessageSlack } from "./send.js";
const SLACK_STREAM_MAX_CHARS = 4000;
const DEFAULT_THROTTLE_MS = 1000;
export type SlackDraftStream = {
@@ -29,7 +29,7 @@ export function createSlackDraftStream(params: {
edit?: typeof editSlackMessage;
remove?: typeof deleteSlackMessage;
}): SlackDraftStream {
const maxChars = Math.min(params.maxChars ?? SLACK_STREAM_MAX_CHARS, SLACK_STREAM_MAX_CHARS);
const maxChars = Math.min(params.maxChars ?? SLACK_TEXT_LIMIT, SLACK_TEXT_LIMIT);
const throttleMs = Math.max(250, params.throttleMs ?? DEFAULT_THROTTLE_MS);
const send = params.send ?? sendMessageSlack;
const edit = params.edit ?? editSlackMessage;

View File

@@ -0,0 +1 @@
export const SLACK_TEXT_LIMIT = 8000;

View File

@@ -17,6 +17,7 @@ import { resolvePinnedMainDmOwnerFromAllowlist } from "openclaw/plugin-sdk/secur
import { editSlackMessage, reactSlackMessage, removeSlackReaction } from "../../actions.js";
import { createSlackDraftStream } from "../../draft-stream.js";
import { normalizeSlackOutboundText } from "../../format.js";
import { SLACK_TEXT_LIMIT } from "../../limits.js";
import { recordSlackThreadParticipation } from "../../sent-thread-cache.js";
import {
applyAppendOnlyStreamUpdate,
@@ -375,7 +376,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
target: prepared.replyTarget,
token: ctx.botToken,
accountId: account.accountId,
maxChars: Math.min(ctx.textLimit, 4000),
maxChars: Math.min(ctx.textLimit, SLACK_TEXT_LIMIT),
resolveThreadTs: () => {
const ts = replyPlan.nextThreadTs();
if (ts) {

View File

@@ -28,6 +28,7 @@ import { normalizeStringEntries } from "openclaw/plugin-sdk/text-runtime";
import { resolveSlackAccount } from "../accounts.js";
import { resolveSlackWebClientOptions } from "../client.js";
import { normalizeSlackWebhookPath, registerSlackHttpHandler } from "../http/index.js";
import { SLACK_TEXT_LIMIT } from "../limits.js";
import { resolveSlackChannelAllowlist } from "../resolve-channels.js";
import { resolveSlackUserAllowlist } from "../resolve-users.js";
import { resolveSlackAppToken, resolveSlackBotToken } from "../token.js";
@@ -242,7 +243,9 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
const threadHistoryScope = slackCfg.thread?.historyScope ?? "thread";
const threadInheritParent = slackCfg.thread?.inheritParent ?? false;
const slashCommand = resolveSlackSlashCommandConfig(opts.slashCommand ?? slackCfg.slashCommand);
const textLimit = resolveTextChunkLimit(cfg, "slack", account.accountId);
const textLimit = resolveTextChunkLimit(cfg, "slack", account.accountId, {
fallbackLimit: SLACK_TEXT_LIMIT,
});
const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions";
const typingReaction = slackCfg.typingReaction?.trim() ?? "";
const mediaMaxBytes = (opts.mediaMaxMb ?? slackCfg.mediaMaxMb ?? 20) * 1024 * 1024;

View File

@@ -6,6 +6,7 @@ vi.mock("../send.js", () => ({
}));
let deliverReplies: typeof import("./replies.js").deliverReplies;
import { deliverSlackSlashReplies } from "./replies.js";
function baseParams(overrides?: Record<string, unknown>) {
return {
@@ -97,3 +98,23 @@ describe("deliverReplies identity passthrough", () => {
);
});
});
describe("deliverSlackSlashReplies chunking", () => {
it("keeps a 4205-character reply in a single slash response by default", async () => {
const respond = vi.fn(async () => undefined);
const text = "a".repeat(4205);
await deliverSlackSlashReplies({
replies: [{ text }],
respond,
ephemeral: true,
textLimit: 8000,
});
expect(respond).toHaveBeenCalledTimes(1);
expect(respond).toHaveBeenCalledWith({
text,
response_type: "ephemeral",
});
});
});

View File

@@ -11,6 +11,7 @@ import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { parseSlackBlocksInput } from "../blocks-input.js";
import { markdownToSlackMrkdwnChunks } from "../format.js";
import { SLACK_TEXT_LIMIT } from "../limits.js";
import { sendMessageSlack, type SlackSendIdentity } from "../send.js";
export function readSlackReplyBlocks(payload: ReplyPayload) {
@@ -188,7 +189,7 @@ export async function deliverSlackSlashReplies(params: {
chunkMode?: ChunkMode;
}) {
const messages: string[] = [];
const chunkLimit = Math.min(params.textLimit, 4000);
const chunkLimit = Math.min(params.textLimit, SLACK_TEXT_LIMIT);
for (const payload of params.replies) {
const reply = resolveSendableOutboundReplyParts(payload);
const text =

View File

@@ -19,6 +19,7 @@ import {
} from "openclaw/plugin-sdk/reply-payload";
import { parseSlackBlocksInput } from "./blocks-input.js";
import { buildSlackInteractiveBlocks, type SlackBlock } from "./blocks-render.js";
import { SLACK_TEXT_LIMIT } from "./limits.js";
import { sendMessageSlack, type SlackSendIdentity } from "./send.js";
const SLACK_MAX_BLOCKS = 50;
@@ -149,7 +150,7 @@ function resolveSlackBlocks(payload: {
export const slackOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct",
chunker: null,
textChunkLimit: 4000,
textChunkLimit: SLACK_TEXT_LIMIT,
sendPayload: async (ctx) => {
const payload = {
...ctx.payload,

View File

@@ -50,6 +50,26 @@ describe("sendMessageSlack NO_REPLY guard", () => {
});
});
describe("sendMessageSlack chunking", () => {
it("keeps 4205-character text in a single Slack post by default", async () => {
const client = createSlackSendTestClient();
const message = "a".repeat(4205);
await sendMessageSlack("channel:C123", message, {
token: "xoxb-test",
client,
});
expect(client.chat.postMessage).toHaveBeenCalledTimes(1);
expect(client.chat.postMessage).toHaveBeenCalledWith(
expect.objectContaining({
channel: "C123",
text: message,
}),
);
});
});
describe("sendMessageSlack blocks", () => {
it("posts blocks with fallback text when message is empty", async () => {
const client = createSlackSendTestClient();

View File

@@ -20,10 +20,9 @@ import { buildSlackBlocksFallbackText } from "./blocks-fallback.js";
import { validateSlackBlocksArray } from "./blocks-input.js";
import { createSlackWebClient } from "./client.js";
import { markdownToSlackMrkdwnChunks } from "./format.js";
import { SLACK_TEXT_LIMIT } from "./limits.js";
import { parseSlackTarget } from "./targets.js";
import { resolveSlackBotToken } from "./token.js";
const SLACK_TEXT_LIMIT = 4000;
const SLACK_UPLOAD_SSRF_POLICY = {
allowedHostnames: ["*.slack.com", "*.slack-edge.com", "*.slack-files.com"],
allowRfc2544BenchmarkRange: true,
@@ -296,7 +295,9 @@ export async function sendMessageSlack(
channelId,
};
}
const textLimit = resolveTextChunkLimit(cfg, "slack", account.accountId);
const textLimit = resolveTextChunkLimit(cfg, "slack", account.accountId, {
fallbackLimit: SLACK_TEXT_LIMIT,
});
const chunkLimit = Math.min(textLimit, SLACK_TEXT_LIMIT);
const tableMode = resolveMarkdownTableMode({
cfg,