mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-18 19:04:45 +00:00
fix(slack): normalize direct interactive sends
Co-authored-by: Kazuhiko Kazama <kazamak@gmail.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user