fix(slack): normalize direct interactive sends

Co-authored-by: Kazuhiko Kazama <kazamak@gmail.com>
This commit is contained in:
Peter Steinberger
2026-05-10 16:28:08 +01:00
parent 9b20b2f3ba
commit da3ce0a1b6
9 changed files with 161 additions and 4 deletions

View File

@@ -44,6 +44,7 @@ Docs: https://docs.openclaw.ai
- Slack: add scoped message-tool formatting hints so agents use Markdown for plain sends and direct mrkdwn for Block Kit fields. Fixes #34609. (#50979) Thanks @carrotRakko.
- Slack: describe `download-file` file ids separately from message timestamps and return a targeted recovery error when agents pass `messageId` instead of `fileId`. (#74155) Thanks @jarvis-ai-gregmoser.
- Slack: retain processed room messages for `requireMention=false` channels so always-on Slack rooms keep recent conversation context between turns. (#38658) Thanks @syedamaann.
- Slack: compile interactive reply directives for direct outbound sends without bypassing the `interactiveReplies` capability gate, preserving Block Kit for Slack CLI and cron deliveries. (#78220) Thanks @kazamak.
- Gateway/agents: keep structured reasons when active-run queueing fails and deprecate the legacy boolean queue helper, so steering and subagent wake diagnostics distinguish completed, non-streaming, and compacting runs. Fixes #80156. Thanks @markus-lassfolk.
- Agents/UI: compact exec and tool progress rows by hiding redundant shell tool names, replacing known workspace paths with short context markers, and preserving Discord trace scrubbing for compact command lines.
- ACPX: run and await the embedded ACP backend startup probe by default so the gateway `ready` signal no longer fires before the acpx runtime has either become usable or reported a probe failure; set `OPENCLAW_ACPX_RUNTIME_STARTUP_PROBE=0` to restore lazy startup. Fixes #79596. Thanks @bzelones.

View File

@@ -1,2 +1,2 @@
0fff629c16bf8d215832c7526a1cf78877a4a97e696cfd1d932023905ae4cce9 plugin-sdk-api-baseline.json
5f0862e41455f46c0f68ca8ccd322a76a7b5f4ff50c3dd743904705c0c943cba plugin-sdk-api-baseline.jsonl
5219fe6237bdf573740840ef3c29631a8465abb73dd60128fb94589384ae2a96 plugin-sdk-api-baseline.json
f63c9723859bb900cf636e5e3fc1349a48563e279ca09e01a7ffafad9efe72f8 plugin-sdk-api-baseline.jsonl

View File

@@ -821,6 +821,40 @@ describe("slackPlugin outbound", () => {
expect(result).toEqual({ channel: "slack", messageId: "m-media-local" });
});
it("normalizes slack button directives for direct outbound delivery", () => {
const normalized = slackPlugin.outbound?.normalizePayload?.({
cfg: {
channels: {
slack: {
botToken: "xoxb-test",
appToken: "xapp-test",
capabilities: { interactiveReplies: true },
},
},
},
accountId: "default",
payload: {
text: "Slack interactive minimal test\n[[slack_buttons: Test:test-value]]",
},
});
expect(normalized).toEqual({
text: "Slack interactive minimal test",
interactive: {
blocks: [
{
type: "text",
text: "Slack interactive minimal test",
},
{
type: "buttons",
buttons: [{ label: "Test", value: "test-value" }],
},
],
},
});
});
it("sends block payload media first, then the final block message", async () => {
const sendSlack = vi
.fn()

View File

@@ -378,6 +378,10 @@ const slackChannelOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct",
chunker: null,
textChunkLimit: SLACK_TEXT_LIMIT,
normalizePayload: ({ payload, cfg, accountId }) =>
isSlackInteractiveRepliesEnabled({ cfg, accountId })
? compileSlackInteractiveReplies(payload)
: payload,
deliveryCapabilities: {
durableFinal: {
text: true,

View File

@@ -90,6 +90,12 @@ export type ChannelOutboundChunkContext = {
formatting?: OutboundDeliveryFormattingOptions;
};
export type ChannelOutboundNormalizePayloadParams = {
payload: ReplyPayload;
cfg: OpenClawConfig;
accountId?: string | null;
};
export type ChannelOutboundAdapter = {
deliveryMode: "direct" | "gateway" | "hybrid";
chunker?: ((text: string, limit: number, ctx?: ChannelOutboundChunkContext) => string[]) | null;
@@ -101,7 +107,7 @@ export type ChannelOutboundAdapter = {
pollMaxOptions?: number;
supportsPollDurationSeconds?: boolean;
supportsAnonymousPolls?: boolean;
normalizePayload?: (params: { payload: ReplyPayload }) => ReplyPayload | null;
normalizePayload?: (params: ChannelOutboundNormalizePayloadParams) => ReplyPayload | null;
sendTextOnlyErrorPayloads?: boolean;
shouldSkipPlainTextSanitization?: (params: { payload: ReplyPayload }) => boolean;
resolveEffectiveTextChunkLimit?: (params: {

View File

@@ -86,6 +86,49 @@ describe("createChannelOutboundRuntimeSend", () => {
);
});
it("routes block sends through payload delivery", async () => {
const sendPayload = vi.fn(async () => ({ channel: "slack", messageId: "slack-blocks" }));
const sendText = vi.fn();
mocks.loadChannelOutboundAdapter.mockResolvedValue({
sendPayload,
sendText,
});
const { createChannelOutboundRuntimeSend } = await import("./channel-outbound-send.js");
const runtimeSend = createChannelOutboundRuntimeSend({
channelId: "slack" as never,
unavailableMessage: "unavailable",
});
const blocks = [
{
type: "actions",
elements: [{ type: "button", text: { type: "plain_text", text: "OK" }, value: "ok" }],
},
];
await runtimeSend.sendMessage("C123", "fallback", {
cfg: {},
accountId: "default",
blocks,
});
expect(sendPayload).toHaveBeenCalledWith(
expect.objectContaining({
accountId: "default",
cfg: {},
payload: {
channelData: {
slack: { blocks },
},
text: "fallback",
},
text: "fallback",
to: "C123",
}),
);
expect(sendText).not.toHaveBeenCalled();
});
it("accepts plugin outbound thread and reply aliases", async () => {
const sendText = vi.fn(async () => ({ channel: "matrix", messageId: "$reply" }));
mocks.loadChannelOutboundAdapter.mockResolvedValue({

View File

@@ -7,6 +7,7 @@ import { normalizeOptionalString } from "../../shared/string-coerce.js";
type RuntimeSendOpts = {
cfg?: OpenClawConfig;
blocks?: unknown;
mediaUrl?: string;
mediaAccess?: OutboundMediaAccess;
mediaLocalRoots?: readonly string[];
@@ -58,6 +59,19 @@ export function createChannelOutboundRuntimeSend(params: {
gatewayClientScopes: opts.gatewayClientScopes,
});
const hasMedia = Boolean(opts.mediaUrl);
if (opts.blocks && outbound?.sendPayload) {
return await outbound.sendPayload({
...buildContext(),
payload: {
text,
channelData: {
[params.channelId]: {
blocks: opts.blocks,
},
},
},
});
}
if (hasMedia && outbound?.sendMedia) {
return await outbound.sendMedia(buildContext());
}

View File

@@ -1515,6 +1515,56 @@ describe("deliverOutboundPayloads", () => {
expect(deliveredPayload?.channelData).toStrictEqual({ copiedText: "visible" });
});
it("passes delivery config and account context to adapter payload normalization", async () => {
const normalizePayload = vi.fn(({ payload }) => ({
...payload,
channelData: { normalized: true },
}));
const sendPayload = vi.fn().mockResolvedValue({
channel: "matrix" as const,
messageId: "context",
roomId: "!room",
});
const cfg = { channels: { matrix: { enabled: true } } } as unknown as OpenClawConfig;
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "matrix",
source: "test",
plugin: createOutboundTestPlugin({
id: "matrix",
outbound: {
deliveryMode: "direct",
normalizePayload,
sendText: vi.fn(),
sendMedia: vi.fn(),
sendPayload,
},
}),
},
]),
);
await deliverOutboundPayloads({
cfg,
channel: "matrix",
to: "!room",
accountId: "workspace-a",
payloads: [{ text: "visible" }],
});
expect(normalizePayload).toHaveBeenCalledWith({
accountId: "workspace-a",
cfg,
payload: expect.objectContaining({ text: "visible" }),
});
expect(sendPayload).toHaveBeenCalledWith(
expect.objectContaining({
payload: expect.objectContaining({ channelData: { normalized: true } }),
}),
);
});
it("strips internal runtime scaffolding copied into rendered and normalized nested payloads", async () => {
const sendPayload = vi.fn().mockResolvedValue({
channel: "matrix" as const,

View File

@@ -372,7 +372,12 @@ function createPluginHandler(
? (payload) => outbound.sanitizeText!({ text: payload.text ?? "", payload })
: undefined,
normalizePayload: outbound?.normalizePayload
? (payload) => outbound.normalizePayload!({ payload })
? (payload) =>
outbound.normalizePayload!({
payload,
cfg: params.cfg,
accountId: params.accountId,
})
: undefined,
sendTextOnlyErrorPayloads: outbound?.sendTextOnlyErrorPayloads === true,
renderPresentation: outbound?.renderPresentation