refactor: migrate bundled plugins to message lifecycle

This commit is contained in:
Peter Steinberger
2026-05-06 01:40:53 +01:00
parent 2ead1502c9
commit 05eda57b3c
223 changed files with 8568 additions and 1354 deletions

View File

@@ -41,6 +41,7 @@ export function createSlackActions(
return {
describeMessageTool: describeSlackMessageTool,
extractToolSend: ({ args }) => extractSlackToolSend(args),
prepareSendPayload: ({ ctx, payload }) => (ctx.action === "send" ? payload : null),
handleAction: async (ctx) => {
return await handleSlackMessageAction({
providerId,

View File

@@ -0,0 +1,187 @@
import {
verifyChannelMessageAdapterCapabilityProofs,
verifyChannelMessageLiveCapabilityAdapterProofs,
verifyChannelMessageLiveFinalizerProofs,
} from "openclaw/plugin-sdk/channel-message";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { slackPlugin } from "./channel.js";
import type { OpenClawConfig } from "./runtime-api.js";
const cfg = {
channels: {
slack: {
botToken: "xoxb-test",
appToken: "xapp-test",
},
},
} as OpenClawConfig;
describe("slack channel message adapter", () => {
const sendSlack = vi.fn();
beforeEach(() => {
sendSlack.mockReset();
sendSlack.mockResolvedValue({ messageId: "msg-1", channelId: "C123" });
});
it("backs declared durable-final capabilities with outbound send proofs", async () => {
const adapter = slackPlugin.message;
expect(adapter).toBeDefined();
const proveText = async () => {
sendSlack.mockClear();
const result = await adapter!.send!.text!({
cfg,
to: "C123",
text: "hello",
accountId: "default",
deps: { sendSlack },
});
expect(sendSlack).toHaveBeenLastCalledWith(
"C123",
"hello",
expect.objectContaining({ accountId: "default" }),
);
expect(result.receipt.platformMessageIds).toEqual(["msg-1"]);
expect(result.receipt.parts[0]?.kind).toBe("text");
};
const proveMedia = async () => {
sendSlack.mockClear();
const result = await adapter!.send!.media!({
cfg,
to: "C123",
text: "caption",
mediaUrl: "https://example.com/a.png",
mediaLocalRoots: ["/tmp/media"],
accountId: "default",
deps: { sendSlack },
});
expect(sendSlack).toHaveBeenLastCalledWith(
"C123",
"caption",
expect.objectContaining({
accountId: "default",
mediaUrl: "https://example.com/a.png",
mediaLocalRoots: ["/tmp/media"],
}),
);
expect(result.receipt.parts[0]?.kind).toBe("media");
};
const provePayload = async () => {
sendSlack.mockClear();
const result = await adapter!.send!.payload!({
cfg,
to: "C123",
text: "payload",
payload: { text: "payload" },
accountId: "default",
deps: { sendSlack },
});
expect(sendSlack).toHaveBeenLastCalledWith(
"C123",
"payload",
expect.objectContaining({ accountId: "default" }),
);
expect(result.receipt.platformMessageIds).toEqual(["msg-1"]);
};
const proveReplyThread = async () => {
sendSlack.mockClear();
const result = await adapter!.send!.text!({
cfg,
to: "C123",
text: "threaded",
accountId: "default",
replyToId: "1712000000.000001",
threadId: "1712345678.123456",
deps: { sendSlack },
});
expect(sendSlack).toHaveBeenLastCalledWith(
"C123",
"threaded",
expect.objectContaining({
accountId: "default",
threadTs: "1712000000.000001",
}),
);
expect(result.receipt.replyToId).toBe("1712000000.000001");
};
const proveThreadFallback = async () => {
sendSlack.mockClear();
const result = await adapter!.send!.text!({
cfg,
to: "C123",
text: "threaded",
accountId: "default",
threadId: "1712345678.123456",
deps: { sendSlack },
});
expect(sendSlack).toHaveBeenLastCalledWith(
"C123",
"threaded",
expect.objectContaining({
accountId: "default",
threadTs: "1712345678.123456",
}),
);
expect(result.receipt.threadId).toBe("1712345678.123456");
};
await verifyChannelMessageAdapterCapabilityProofs({
adapterName: "slackMessageAdapter",
adapter: adapter!,
proofs: {
text: proveText,
media: proveMedia,
payload: provePayload,
replyTo: proveReplyThread,
thread: proveThreadFallback,
messageSendingHooks: () => {
expect(adapter!.send!.text).toBeTypeOf("function");
},
},
});
});
it("backs declared live preview finalizer capabilities with adapter proofs", async () => {
const adapter = slackPlugin.message;
await verifyChannelMessageLiveCapabilityAdapterProofs({
adapterName: "slackMessageAdapter",
adapter: adapter!,
proofs: {
draftPreview: () => {
expect(adapter!.live?.finalizer?.capabilities?.discardPending).toBe(true);
},
previewFinalization: () => {
expect(adapter!.live?.finalizer?.capabilities?.finalEdit).toBe(true);
},
progressUpdates: () => {
expect(adapter!.live?.capabilities?.draftPreview).toBe(true);
},
nativeStreaming: () => {
expect(adapter!.live?.capabilities?.previewFinalization).toBe(true);
},
},
});
await verifyChannelMessageLiveFinalizerProofs({
adapterName: "slackMessageAdapter",
adapter: adapter!,
proofs: {
finalEdit: () => {
expect(adapter!.live?.capabilities?.previewFinalization).toBe(true);
},
normalFallback: () => {
expect(adapter!.send!.text).toBeTypeOf("function");
},
discardPending: () => {
expect(adapter!.live?.capabilities?.draftPreview).toBe(true);
},
},
});
});
});

View File

@@ -8,7 +8,12 @@ import {
buildThreadAwareOutboundSessionRoute,
createChatChannelPlugin,
} from "openclaw/plugin-sdk/channel-core";
import { createChannelMessageAdapterFromOutbound } from "openclaw/plugin-sdk/channel-message";
import { createPairingPrefixStripper } from "openclaw/plugin-sdk/channel-pairing";
import {
createAttachedChannelResultAdapter,
type ChannelOutboundAdapter,
} from "openclaw/plugin-sdk/channel-send-result";
import {
createChannelDirectoryAdapter,
createRuntimeDirectoryLiveAdapter,
@@ -357,6 +362,121 @@ const resolveSlackAllowlistNames = createAccountScopedAllowlistNameResolver({
(await loadSlackResolveUsersModule()).resolveSlackUserAllowlist({ token, entries }),
});
const slackChannelOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct",
chunker: null,
textChunkLimit: SLACK_TEXT_LIMIT,
deliveryCapabilities: {
durableFinal: {
text: true,
media: true,
payload: true,
replyTo: true,
thread: true,
messageSendingHooks: true,
},
},
shouldTreatDeliveredTextAsVisible: shouldTreatSlackDeliveredTextAsVisible,
shouldSuppressLocalPayloadPrompt: ({ cfg, accountId, payload }) =>
shouldSuppressLocalSlackExecApprovalPrompt({
cfg,
accountId,
payload,
}),
sendPayload: async (ctx) => {
const { send, threadTsValue, tokenOverride } = await resolveSlackSendContext({
cfg: ctx.cfg,
accountId: ctx.accountId ?? undefined,
deps: ctx.deps,
replyToId: ctx.replyToId,
threadId: ctx.threadId,
});
const { slackOutbound } = await loadSlackOutboundAdapterModule();
return await slackOutbound.sendPayload!({
...ctx,
replyToId: threadTsValue,
threadId: null,
deps: {
...ctx.deps,
slack: async (
to: Parameters<SlackSendFn>[0],
text: Parameters<SlackSendFn>[1],
opts: Parameters<SlackSendFn>[2],
) =>
await send(to, text, {
...opts,
...(tokenOverride ? { token: tokenOverride } : {}),
}),
},
});
},
...createAttachedChannelResultAdapter({
channel: "slack",
sendText: async ({ to, text, accountId, deps, replyToId, threadId, cfg }) => {
const { send, threadTsValue, tokenOverride } = await resolveSlackSendContext({
cfg,
accountId: accountId ?? undefined,
deps,
replyToId,
threadId,
});
return await send(to, text, {
cfg,
threadTs: threadTsValue,
accountId: accountId ?? undefined,
...(tokenOverride ? { token: tokenOverride } : {}),
});
},
sendMedia: async ({
to,
text,
mediaUrl,
mediaLocalRoots,
accountId,
deps,
replyToId,
threadId,
cfg,
}) => {
const { send, threadTsValue, tokenOverride } = await resolveSlackSendContext({
cfg,
accountId: accountId ?? undefined,
deps,
replyToId,
threadId,
});
return await send(to, text, {
cfg,
mediaUrl,
mediaLocalRoots,
threadTs: threadTsValue,
accountId: accountId ?? undefined,
...(tokenOverride ? { token: tokenOverride } : {}),
});
},
}),
};
const slackMessageAdapter = createChannelMessageAdapterFromOutbound({
id: "slack",
outbound: slackChannelOutbound,
live: {
capabilities: {
draftPreview: true,
previewFinalization: true,
progressUpdates: true,
nativeStreaming: true,
},
finalizer: {
capabilities: {
finalEdit: true,
normalFallback: true,
discardPending: true,
},
},
},
});
export const slackPlugin: ChannelPlugin<ResolvedSlackAccount, SlackProbe> = createChatChannelPlugin<
ResolvedSlackAccount,
SlackProbe
@@ -490,6 +610,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount, SlackProbe> = crea
await resolveSlackHandleAction()
)(action, cfg as OpenClawConfig, toolContext as SlackActionContext | undefined),
}),
message: slackMessageAdapter,
status: createComputedAccountStatusAdapter<ResolvedSlackAccount, SlackProbe>({
defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID),
buildChannelSummary: async ({ snapshot }) => {
@@ -626,90 +747,5 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount, SlackProbe> = crea
threadId: null,
}),
},
outbound: {
base: {
deliveryMode: "direct",
chunker: null,
textChunkLimit: SLACK_TEXT_LIMIT,
shouldTreatDeliveredTextAsVisible: shouldTreatSlackDeliveredTextAsVisible,
shouldSuppressLocalPayloadPrompt: ({ cfg, accountId, payload }) =>
shouldSuppressLocalSlackExecApprovalPrompt({
cfg,
accountId,
payload,
}),
sendPayload: async (ctx) => {
const { send, threadTsValue, tokenOverride } = await resolveSlackSendContext({
cfg: ctx.cfg,
accountId: ctx.accountId ?? undefined,
deps: ctx.deps,
replyToId: ctx.replyToId,
threadId: ctx.threadId,
});
const { slackOutbound } = await loadSlackOutboundAdapterModule();
return await slackOutbound.sendPayload!({
...ctx,
replyToId: threadTsValue,
threadId: null,
deps: {
...ctx.deps,
slack: async (
to: Parameters<SlackSendFn>[0],
text: Parameters<SlackSendFn>[1],
opts: Parameters<SlackSendFn>[2],
) =>
await send(to, text, {
...opts,
...(tokenOverride ? { token: tokenOverride } : {}),
}),
},
});
},
},
attachedResults: {
channel: "slack",
sendText: async ({ to, text, accountId, deps, replyToId, threadId, cfg }) => {
const { send, threadTsValue, tokenOverride } = await resolveSlackSendContext({
cfg,
accountId: accountId ?? undefined,
deps,
replyToId,
threadId,
});
return await send(to, text, {
cfg,
threadTs: threadTsValue,
accountId: accountId ?? undefined,
...(tokenOverride ? { token: tokenOverride } : {}),
});
},
sendMedia: async ({
to,
text,
mediaUrl,
mediaLocalRoots,
accountId,
deps,
replyToId,
threadId,
cfg,
}) => {
const { send, threadTsValue, tokenOverride } = await resolveSlackSendContext({
cfg,
accountId: accountId ?? undefined,
deps,
replyToId,
threadId,
});
return await send(to, text, {
cfg,
mediaUrl,
mediaLocalRoots,
threadTs: threadTsValue,
accountId: accountId ?? undefined,
...(tokenOverride ? { token: tokenOverride } : {}),
});
},
},
},
outbound: slackChannelOutbound,
});

View File

@@ -1,3 +1,4 @@
import { createMessageReceiptFromOutboundResults } from "openclaw/plugin-sdk/channel-message";
import { describe, expect, it, vi } from "vitest";
import { createSlackDraftStream } from "./draft-stream.js";
@@ -9,6 +10,17 @@ type DraftWarnFn = NonNullable<DraftStreamParams["warn"]>;
const TEST_CFG = {};
function slackDraftSendResult(messageId: string, channelId = "C123") {
return {
channelId,
messageId,
receipt: createMessageReceiptFromOutboundResults({
results: [{ channel: "slack", messageId, channelId }],
kind: "preview",
}),
};
}
function createDraftStreamHarness(
params: {
maxChars?: number;
@@ -18,12 +30,7 @@ function createDraftStreamHarness(
warn?: DraftWarnFn;
} = {},
) {
const send =
params.send ??
vi.fn<DraftSendFn>(async () => ({
channelId: "C123",
messageId: "111.222",
}));
const send = params.send ?? vi.fn<DraftSendFn>(async () => slackDraftSendResult("111.222"));
const edit = params.edit ?? vi.fn<DraftEditFn>(async () => {});
const remove = params.remove ?? vi.fn<DraftRemoveFn>(async () => {});
const warn = params.warn ?? vi.fn<DraftWarnFn>();
@@ -96,8 +103,8 @@ describe("createSlackDraftStream", () => {
it("supports forceNewMessage for subsequent assistant messages", async () => {
const send = vi
.fn<DraftSendFn>()
.mockResolvedValueOnce({ channelId: "C123", messageId: "111.222" })
.mockResolvedValueOnce({ channelId: "C123", messageId: "333.444" });
.mockResolvedValueOnce(slackDraftSendResult("111.222"))
.mockResolvedValueOnce(slackDraftSendResult("333.444"));
const { stream, edit } = createDraftStreamHarness({ send });
stream.update("first");

View File

@@ -208,47 +208,51 @@ vi.mock("../conversation.runtime.js", () => ({
recordInboundSession: vi.fn(async () => undefined),
}));
vi.mock("openclaw/plugin-sdk/channel-reply-pipeline", () => ({
createChannelReplyPipeline: (params: {
typing?: {
start: () => Promise<void>;
stop?: () => Promise<void>;
onStartError: (err: unknown) => void;
onStopError?: (err: unknown) => void;
};
}) => {
capturedTyping = params.typing;
return {
...(params.typing
? {
typingCallbacks: {
onReplyStart: params.typing.start,
onIdle: () => {
void params.typing?.stop?.();
vi.mock("openclaw/plugin-sdk/channel-message", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/channel-message")>();
return {
...actual,
createChannelMessageReplyPipeline: (params: {
typing?: {
start: () => Promise<void>;
stop?: () => Promise<void>;
onStartError: (err: unknown) => void;
onStopError?: (err: unknown) => void;
};
}) => {
capturedTyping = params.typing;
return {
...(params.typing
? {
typingCallbacks: {
onReplyStart: params.typing.start,
onIdle: () => {
void params.typing?.stop?.();
},
},
},
}
: {}),
onModelSelected: undefined,
};
},
resolveChannelSourceReplyDeliveryMode: (params: {
cfg?: { messages?: { groupChat?: { visibleReplies?: string } } };
ctx?: { ChatType?: string };
requested?: "automatic" | "message_tool_only";
}) => {
if (params.requested) {
return params.requested;
}
const chatType = params.ctx?.ChatType;
if (chatType === "group" || chatType === "channel") {
return params.cfg?.messages?.groupChat?.visibleReplies === "automatic"
? "automatic"
: "message_tool_only";
}
return "automatic";
},
}));
}
: {}),
onModelSelected: undefined,
};
},
resolveChannelMessageSourceReplyDeliveryMode: (params: {
cfg?: { messages?: { groupChat?: { visibleReplies?: string } } };
ctx?: { ChatType?: string };
requested?: "automatic" | "message_tool_only";
}) => {
if (params.requested) {
return params.requested;
}
const chatType = params.ctx?.ChatType;
if (chatType === "group" || chatType === "channel") {
return params.cfg?.messages?.groupChat?.visibleReplies === "automatic"
? "automatic"
: "message_tool_only";
}
return "automatic";
},
};
});
vi.mock("openclaw/plugin-sdk/channel-streaming", () => ({
buildChannelProgressDraftLine: (params: {

View File

@@ -7,11 +7,12 @@ import {
removeAckReactionAfterReply,
type StatusReactionAdapter,
} from "openclaw/plugin-sdk/channel-feedback";
import { deliverFinalizableDraftPreview } from "openclaw/plugin-sdk/channel-lifecycle";
import {
createChannelReplyPipeline,
resolveChannelSourceReplyDeliveryMode,
} from "openclaw/plugin-sdk/channel-reply-pipeline";
createChannelMessageReplyPipeline,
defineFinalizableLivePreviewAdapter,
deliverWithFinalizableLivePreviewAdapter,
resolveChannelMessageSourceReplyDeliveryMode,
} from "openclaw/plugin-sdk/channel-message";
import {
buildChannelProgressDraftLine,
buildChannelProgressDraftLineForEntry,
@@ -294,7 +295,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
message,
replyToMode: prepared.replyToMode,
});
const sourceReplyDeliveryMode = resolveChannelSourceReplyDeliveryMode({
const sourceReplyDeliveryMode = resolveChannelMessageSourceReplyDeliveryMode({
cfg,
ctx: prepared.ctxPayload,
});
@@ -369,7 +370,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
const typingTarget = statusThreadTs ? `${message.channel}/${statusThreadTs}` : message.channel;
const typingReaction = ctx.typingReaction;
const { onModelSelected, ...replyPipeline } = createChannelReplyPipeline({
const { onModelSelected, ...replyPipeline } = createChannelMessageReplyPipeline({
cfg,
agentId: route.agentId,
channel: "slack",
@@ -783,72 +784,76 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
const slackBlocks = readSlackReplyBlocks(payload);
const trimmedFinalText = reply.trimmedText;
const result = await deliverFinalizableDraftPreview({
const result = await deliverWithFinalizableLivePreviewAdapter({
kind: info.kind,
payload,
draft: draftStream
? {
flush: draftStream.flush,
clear: draftStream.clear,
discardPending: draftStream.discardPending,
seal: draftStream.seal,
id: () => {
const channelId = draftStream.channelId();
const messageId = draftStream.messageId();
return channelId && messageId ? { channelId, messageId } : undefined;
},
adapter: defineFinalizableLivePreviewAdapter({
draft: draftStream
? {
flush: draftStream.flush,
clear: draftStream.clear,
discardPending: draftStream.discardPending,
seal: draftStream.seal,
id: () => {
const channelId = draftStream.channelId();
const messageId = draftStream.messageId();
return channelId && messageId ? { channelId, messageId } : undefined;
},
}
: undefined,
buildFinalEdit: () => {
if (
!previewStreamingEnabled ||
reply.hasMedia ||
payload.isError ||
(trimmedFinalText.length === 0 && !slackBlocks?.length)
) {
return undefined;
}
: undefined,
buildFinalEdit: () => {
if (
!previewStreamingEnabled ||
reply.hasMedia ||
payload.isError ||
(trimmedFinalText.length === 0 && !slackBlocks?.length)
) {
return undefined;
}
return {
text: normalizeSlackOutboundText(trimmedFinalText),
blocks: slackBlocks,
threadTs: usedReplyThreadTs ?? statusThreadTs,
};
},
editFinal: async (preview, edit) => {
if (deliveryTracker.hasDelivered({ kind: info.kind, payload, threadTs: edit.threadTs })) {
return;
}
await finalizeSlackPreviewEdit({
client: ctx.app.client,
token: ctx.botToken,
accountId: account.accountId,
channelId: preview.channelId,
messageId: preview.messageId,
text: edit.text,
...(edit.blocks?.length ? { blocks: edit.blocks } : {}),
threadTs: edit.threadTs,
});
},
return {
text: normalizeSlackOutboundText(trimmedFinalText),
blocks: slackBlocks,
threadTs: usedReplyThreadTs ?? statusThreadTs,
};
},
editFinal: async (preview, edit) => {
if (
deliveryTracker.hasDelivered({ kind: info.kind, payload, threadTs: edit.threadTs })
) {
return;
}
await finalizeSlackPreviewEdit({
client: ctx.app.client,
token: ctx.botToken,
accountId: account.accountId,
channelId: preview.channelId,
messageId: preview.messageId,
text: edit.text,
...(edit.blocks?.length ? { blocks: edit.blocks } : {}),
threadTs: edit.threadTs,
});
},
onPreviewFinalized: (_preview) => {
const finalThreadTs = usedReplyThreadTs ?? statusThreadTs;
observedReplyDelivery = true;
replyPlan.markSent();
deliveryTracker.markDelivered({ kind: info.kind, payload, threadTs: finalThreadTs });
},
logPreviewEditFailure: (err) => {
logVerbose(
`slack: preview final edit failed; falling back to standard send (${formatErrorMessage(err)})`,
);
},
}),
deliverNormally: async () => {
await deliverNormally({
payload,
kind: info.kind,
});
},
onPreviewFinalized: (_preview) => {
const finalThreadTs = usedReplyThreadTs ?? statusThreadTs;
observedReplyDelivery = true;
replyPlan.markSent();
deliveryTracker.markDelivered({ kind: info.kind, payload, threadTs: finalThreadTs });
},
logPreviewEditFailure: (err) => {
logVerbose(
`slack: preview final edit failed; falling back to standard send (${formatErrorMessage(err)})`,
);
},
});
if (result === "preview-finalized") {
if (result.kind === "preview-finalized") {
return;
}
},

View File

@@ -12,7 +12,7 @@ import {
resolveEnvelopeFormatOptions,
resolveInboundMentionDecision,
} from "openclaw/plugin-sdk/channel-inbound";
import { resolveChannelSourceReplyDeliveryMode } from "openclaw/plugin-sdk/channel-reply-pipeline";
import { resolveChannelMessageSourceReplyDeliveryMode } from "openclaw/plugin-sdk/channel-message";
import { hasControlCommand } from "openclaw/plugin-sdk/command-detection";
import { resolveControlCommandGate } from "openclaw/plugin-sdk/command-gating";
import { shouldHandleTextCommands } from "openclaw/plugin-sdk/command-surface";
@@ -561,7 +561,7 @@ export async function prepareSlackMessage(params: {
});
const ackReactionValue = ackReaction ?? "";
const sourceRepliesAreToolOnly =
resolveChannelSourceReplyDeliveryMode({ cfg, ctx: { ChatType: chatType } }) ===
resolveChannelMessageSourceReplyDeliveryMode({ cfg, ctx: { ChatType: chatType } }) ===
"message_tool_only";
const statusReactionsExplicitlyEnabled = cfg.messages?.statusReactions?.enabled === true;
const shouldAckReaction = () =>

View File

@@ -1,6 +1,6 @@
import type { SlackActionMiddlewareArgs, SlackCommandMiddlewareArgs } from "@slack/bolt";
import { resolveDefaultModelForAgent } from "openclaw/plugin-sdk/agent-runtime";
import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
import { createChannelMessageReplyPipeline } from "openclaw/plugin-sdk/channel-message";
import {
formatCommandArgMenuTitle,
resolveStoredModelOverride,
@@ -728,7 +728,7 @@ export async function registerSlackMonitorSlashCommands(params: {
),
});
const { onModelSelected, ...replyPipeline } = createChannelReplyPipeline({
const { onModelSelected, ...replyPipeline } = createChannelMessageReplyPipeline({
cfg,
agentId: route.agentId,
channel: "slack",

View File

@@ -32,6 +32,7 @@ describe("sendMessageSlack NO_REPLY guard", () => {
expect(client.chat.postMessage).not.toHaveBeenCalled();
expect(result.messageId).toBe("suppressed");
expect(result.receipt.platformMessageIds).toEqual([]);
});
it("suppresses NO_REPLY with surrounding whitespace", async () => {
@@ -170,7 +171,18 @@ describe("sendMessageSlack blocks", () => {
blocks: [{ type: "divider" }],
}),
);
expect(result).toEqual({ messageId: "171234.567", channelId: "C123" });
expect(result).toMatchObject({ messageId: "171234.567", channelId: "C123" });
expect(result.receipt).toMatchObject({
primaryPlatformMessageId: "171234.567",
platformMessageIds: ["171234.567"],
parts: [
expect.objectContaining({
platformMessageId: "171234.567",
kind: "card",
raw: expect.objectContaining({ channel: "slack", channelId: "C123" }),
}),
],
});
});
it("posts user-target block messages directly without conversations.open", async () => {
@@ -191,7 +203,8 @@ describe("sendMessageSlack blocks", () => {
text: "Shared a Block Kit message",
}),
);
expect(result).toEqual({ messageId: "171234.567", channelId: "U123" });
expect(result).toMatchObject({ messageId: "171234.567", channelId: "U123" });
expect(result.receipt.platformMessageIds).toEqual(["171234.567"]);
});
it("retries Slack postMessage DNS request errors without enabling broad write retries", async () => {
@@ -207,7 +220,13 @@ describe("sendMessageSlack blocks", () => {
});
expect(client.chat.postMessage).toHaveBeenCalledTimes(2);
expect(result).toEqual({ messageId: "171234.999", channelId: "C123" });
expect(result).toMatchObject({ messageId: "171234.999", channelId: "C123" });
expect(result.receipt.parts[0]).toEqual(
expect.objectContaining({
platformMessageId: "171234.999",
kind: "text",
}),
);
});
it("retries Slack conversations.open DNS request errors for threaded DMs", async () => {
@@ -227,7 +246,8 @@ describe("sendMessageSlack blocks", () => {
expect(client.chat.postMessage).toHaveBeenCalledWith(
expect.objectContaining({ channel: "D123", thread_ts: "171234.100" }),
);
expect(result).toEqual({ messageId: "171234.567", channelId: "D123" });
expect(result).toMatchObject({ messageId: "171234.567", channelId: "D123" });
expect(result.receipt.threadId).toBe("171234.100");
});
it("does not retry Slack platform errors", async () => {

View File

@@ -1,4 +1,10 @@
import { type Block, type KnownBlock, type WebClient } from "@slack/web-api";
import {
createMessageReceiptFromOutboundResults,
type MessageReceipt,
type MessageReceiptPartKind,
type MessageReceiptSourceResult,
} from "openclaw/plugin-sdk/channel-message";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
import { withTrustedEnvProxyGuardedFetchMode } from "openclaw/plugin-sdk/fetch-runtime";
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime";
@@ -284,8 +290,34 @@ async function postSlackMessageBestEffort(params: {
export type SlackSendResult = {
messageId: string;
channelId: string;
receipt: MessageReceipt;
};
function createSlackSendReceipt(params: {
platformMessageIds: readonly string[];
channelId?: string;
kind: MessageReceiptPartKind;
threadTs?: string;
}): MessageReceipt {
const platformMessageIds = params.platformMessageIds
.map((messageId) => messageId.trim())
.filter((messageId) => messageId && messageId !== "unknown" && messageId !== "suppressed");
return createMessageReceiptFromOutboundResults({
results: platformMessageIds.map((messageId) => {
const result: MessageReceiptSourceResult = {
channel: "slack",
messageId,
};
if (params.channelId) {
result.channelId = params.channelId;
}
return result;
}),
kind: params.kind,
threadId: params.threadTs,
});
}
function resolveToken(params: {
explicit?: string;
accountId: string;
@@ -513,7 +545,11 @@ export async function sendMessageSlack(
const trimmedMessage = normalizeOptionalString(message) ?? "";
if (isSilentReplyText(trimmedMessage) && !opts.mediaUrl && !opts.blocks) {
logVerbose("slack send: suppressed NO_REPLY token before API call");
return { messageId: "suppressed", channelId: "" };
return {
messageId: "suppressed",
channelId: "",
receipt: createSlackSendReceipt({ platformMessageIds: [], kind: "unknown" }),
};
}
const blocks = opts.blocks == null ? undefined : validateSlackBlocksArray(opts.blocks);
if (!trimmedMessage && !opts.mediaUrl && !blocks) {
@@ -609,9 +645,16 @@ async function sendMessageSlackQueuedInner(params: {
identity: opts.identity,
blocks,
});
const messageId = response.ts ?? "unknown";
return {
messageId: response.ts ?? "unknown",
messageId,
channelId,
receipt: createSlackSendReceipt({
platformMessageIds: [messageId],
channelId,
kind: "card",
threadTs: opts.threadTs,
}),
};
}
const textLimit = resolveTextChunkLimit(cfg, "slack", account.accountId, {
@@ -637,6 +680,7 @@ async function sendMessageSlackQueuedInner(params: {
? account.config.mediaMaxMb * 1024 * 1024
: undefined;
const sentMessageIds: string[] = [];
let lastMessageId = "";
if (opts.mediaUrl) {
const [firstChunk, ...rest] = resolvedChunks;
@@ -653,6 +697,7 @@ async function sendMessageSlackQueuedInner(params: {
threadTs: opts.threadTs,
maxBytes: mediaMaxBytes,
});
sentMessageIds.push(lastMessageId);
for (const chunk of rest) {
const response = await postSlackMessageBestEffort({
client,
@@ -662,6 +707,9 @@ async function sendMessageSlackQueuedInner(params: {
identity: opts.identity,
});
lastMessageId = response.ts ?? lastMessageId;
if (response.ts) {
sentMessageIds.push(response.ts);
}
}
} else {
for (const chunk of resolvedChunks.length ? resolvedChunks : [""]) {
@@ -673,11 +721,21 @@ async function sendMessageSlackQueuedInner(params: {
identity: opts.identity,
});
lastMessageId = response.ts ?? lastMessageId;
if (response.ts) {
sentMessageIds.push(response.ts);
}
}
}
const messageId = lastMessageId || "unknown";
return {
messageId: lastMessageId || "unknown",
messageId,
channelId,
receipt: createSlackSendReceipt({
platformMessageIds: sentMessageIds.length ? sentMessageIds : [messageId],
channelId,
kind: opts.mediaUrl ? "media" : "text",
threadTs: opts.threadTs,
}),
};
}

View File

@@ -206,8 +206,22 @@ describe("sendMessageSlack file upload with user IDs", () => {
expect(client.chat.postMessage).toHaveBeenCalledTimes(1);
resolveFirst();
await expect(first).resolves.toEqual({ channelId: "C123CHAN", messageId: "1.000" });
await expect(second).resolves.toEqual({ channelId: "C123CHAN", messageId: "2.000" });
await expect(first).resolves.toMatchObject({
channelId: "C123CHAN",
messageId: "1.000",
receipt: expect.objectContaining({
primaryPlatformMessageId: "1.000",
platformMessageIds: ["1.000"],
}),
});
await expect(second).resolves.toMatchObject({
channelId: "C123CHAN",
messageId: "2.000",
receipt: expect.objectContaining({
primaryPlatformMessageId: "2.000",
platformMessageIds: ["2.000"],
}),
});
expect(client.chat.postMessage).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ text: "second" }),
@@ -236,7 +250,7 @@ describe("sendMessageSlack file upload with user IDs", () => {
it("sends file directly to channel without conversations.open", async () => {
const client = createUploadTestClient();
await sendMessageSlack("channel:C123CHAN", "chart", {
const result = await sendMessageSlack("channel:C123CHAN", "chart", {
token: "xoxb-test",
cfg: SLACK_TEST_CFG,
client,
@@ -247,6 +261,17 @@ describe("sendMessageSlack file upload with user IDs", () => {
expect(client.files.completeUploadExternal).toHaveBeenCalledWith(
expect.objectContaining({ channel_id: "C123CHAN" }),
);
expect(result.receipt).toMatchObject({
primaryPlatformMessageId: "F001",
platformMessageIds: ["F001"],
parts: [
expect.objectContaining({
platformMessageId: "F001",
kind: "media",
raw: expect.objectContaining({ channel: "slack", channelId: "C123CHAN" }),
}),
],
});
});
it("resolves mention-style user ID before file upload", async () => {
@@ -270,7 +295,7 @@ describe("sendMessageSlack file upload with user IDs", () => {
it("uploads bytes to the presigned URL and completes with thread+caption", async () => {
const client = createUploadTestClient();
await sendMessageSlack("channel:C123CHAN", "caption", {
const result = await sendMessageSlack("channel:C123CHAN", "caption", {
token: "xoxb-test",
cfg: SLACK_TEST_CFG,
client,
@@ -303,6 +328,7 @@ describe("sendMessageSlack file upload with user IDs", () => {
}),
);
expect(hasSlackThreadParticipation("default", "C123CHAN", "171.222")).toBe(true);
expect(result.receipt.threadId).toBe("171.222");
});
it("uses explicit upload filename and title overrides when provided", async () => {