diff --git a/CHANGELOG.md b/CHANGELOG.md index e6711b49316..d160cb67504 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Slack/Extension: forward `message read` `threadId` to `readMessages` and use delivery-context `threadId` as outbound `thread_ts` fallback so extension replies/reads stay in the correct Slack thread. (#22216, #22485, #23836) Thanks @vincentkoc, @lan17 and @dorukardahan. - Config/Memory: allow `"mistral"` in `agents.defaults.memorySearch.provider` and `agents.defaults.memorySearch.fallback` schema validation. (#14934) Thanks @ThomsenDrake. - Security/Feishu: enforce ID-only allowlist matching for DM/group sender authorization, normalize Feishu ID prefixes during checks, and ignore mutable display names so display-name collisions cannot satisfy allowlist entries. This ships in the next npm release. Thanks @jiseoung for reporting. - Feishu/Commands: in group chats, command authorization now falls back to top-level `channels.feishu.allowFrom` when per-group `allowFrom` is not set, so `/command` no longer gets blocked by an unintended empty allowlist. (#23756) diff --git a/extensions/slack/src/channel.test.ts b/extensions/slack/src/channel.test.ts new file mode 100644 index 00000000000..60e760c9950 --- /dev/null +++ b/extensions/slack/src/channel.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it, vi } from "vitest"; + +const handleSlackActionMock = vi.fn(); + +vi.mock("./runtime.js", () => ({ + getSlackRuntime: () => ({ + channel: { + slack: { + handleSlackAction: handleSlackActionMock, + }, + }, + }), +})); + +import { slackPlugin } from "./channel.js"; + +describe("slackPlugin actions", () => { + it("forwards read threadId to Slack action handler", async () => { + handleSlackActionMock.mockResolvedValueOnce({ messages: [], hasMore: false }); + const handleAction = slackPlugin.actions?.handleAction; + expect(handleAction).toBeDefined(); + + await handleAction!({ + action: "read", + channel: "slack", + accountId: "default", + cfg: {}, + params: { + channelId: "C123", + threadId: "1712345678.123456", + }, + }); + + expect(handleSlackActionMock).toHaveBeenCalledWith( + expect.objectContaining({ + action: "readMessages", + channelId: "C123", + threadId: "1712345678.123456", + }), + {}, + undefined, + ); + }); +}); + +describe("slackPlugin outbound", () => { + const cfg = { + channels: { + slack: { + botToken: "xoxb-test", + appToken: "xapp-test", + }, + }, + }; + + it("uses threadId as threadTs fallback for sendText", async () => { + const sendSlack = vi.fn().mockResolvedValue({ messageId: "m-text" }); + const sendText = slackPlugin.outbound?.sendText; + expect(sendText).toBeDefined(); + + const result = await sendText!({ + cfg, + to: "C123", + text: "hello", + accountId: "default", + threadId: "1712345678.123456", + deps: { sendSlack }, + }); + + expect(sendSlack).toHaveBeenCalledWith( + "C123", + "hello", + expect.objectContaining({ + threadTs: "1712345678.123456", + }), + ); + expect(result).toEqual({ channel: "slack", messageId: "m-text" }); + }); + + it("prefers replyToId over threadId for sendMedia", async () => { + const sendSlack = vi.fn().mockResolvedValue({ messageId: "m-media" }); + const sendMedia = slackPlugin.outbound?.sendMedia; + expect(sendMedia).toBeDefined(); + + const result = await sendMedia!({ + cfg, + to: "C999", + text: "caption", + mediaUrl: "https://example.com/image.png", + accountId: "default", + replyToId: "1712000000.000001", + threadId: "1712345678.123456", + deps: { sendSlack }, + }); + + expect(sendSlack).toHaveBeenCalledWith( + "C999", + "caption", + expect.objectContaining({ + mediaUrl: "https://example.com/image.png", + threadTs: "1712000000.000001", + }), + ); + expect(result).toEqual({ channel: "slack", messageId: "m-media" }); + }); +}); diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index 88bb40ca495..003fd895774 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -245,6 +245,7 @@ export const slackPlugin: ChannelPlugin = { await handleSlackMessageAction({ providerId: meta.id, ctx, + includeReadThreadId: true, invoke: async (action, cfg, toolContext) => await getSlackRuntime().channel.slack.handleSlackAction(action, cfg, toolContext), }), @@ -324,28 +325,30 @@ export const slackPlugin: ChannelPlugin = { deliveryMode: "direct", chunker: null, textChunkLimit: 4000, - sendText: async ({ to, text, accountId, deps, replyToId, cfg }) => { + sendText: async ({ to, text, accountId, deps, replyToId, threadId, cfg }) => { const send = deps?.sendSlack ?? getSlackRuntime().channel.slack.sendMessageSlack; const account = resolveSlackAccount({ cfg, accountId }); const token = getTokenForOperation(account, "write"); const botToken = account.botToken?.trim(); const tokenOverride = token && token !== botToken ? token : undefined; + const threadTsValue = replyToId ?? threadId; const result = await send(to, text, { - threadTs: replyToId ?? undefined, + threadTs: threadTsValue != null ? String(threadTsValue) : undefined, accountId: accountId ?? undefined, ...(tokenOverride ? { token: tokenOverride } : {}), }); return { channel: "slack", ...result }; }, - sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId, cfg }) => { + sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId, threadId, cfg }) => { const send = deps?.sendSlack ?? getSlackRuntime().channel.slack.sendMessageSlack; const account = resolveSlackAccount({ cfg, accountId }); const token = getTokenForOperation(account, "write"); const botToken = account.botToken?.trim(); const tokenOverride = token && token !== botToken ? token : undefined; + const threadTsValue = replyToId ?? threadId; const result = await send(to, text, { mediaUrl, - threadTs: replyToId ?? undefined, + threadTs: threadTsValue != null ? String(threadTsValue) : undefined, accountId: accountId ?? undefined, ...(tokenOverride ? { token: tokenOverride } : {}), });