mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 21:10:43 +00:00
refactor: migrate bundled plugins to message lifecycle
This commit is contained in:
@@ -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,
|
||||
|
||||
187
extensions/slack/src/channel.message-adapter.test.ts
Normal file
187
extensions/slack/src/channel.message-adapter.test.ts
Normal 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);
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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 = () =>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user