From a22f06504376e7203a2bec8fde094b7cdcb06c02 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 2 May 2026 03:24:01 +0100 Subject: [PATCH] fix(slack): support exact message reads --- CHANGELOG.md | 1 + docs/cli/message.md | 3 +- extensions/slack/src/action-runtime.test.ts | 23 ++++++++ extensions/slack/src/action-runtime.ts | 2 + extensions/slack/src/actions.read.test.ts | 55 +++++++++++++++++++ extensions/slack/src/actions.ts | 44 ++++++++++----- extensions/slack/src/channel.test.ts | 2 + .../slack/src/message-action-dispatch.test.ts | 26 +++++++++ .../slack/src/message-action-dispatch.ts | 1 + src/agents/tools/message-tool.test.ts | 26 +++++++++ src/agents/tools/message-tool.ts | 4 +- .../message/register.read-edit-delete.ts | 2 + 12 files changed, 173 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ace21f40a2..cc2fe7b2f23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ Docs: https://docs.openclaw.ai - macOS/Voice Wake: send wake-word and Push-to-Talk transcripts through the selected macOS session target instead of always falling back to main WebChat. Fixes #51040. Thanks @carl-jeffrolc. - Providers/xAI: give Grok `web_search` a 60s default timeout, harden malformed xAI Responses parsing, and return structured timeout errors instead of aborting the tool call. Fixes #58063 and #58733. Thanks @dnishimura, @marvcasasola-svg, and @Nanako0129. - Providers/configure: preserve the existing default model when adding or reauthing a provider whose plugin returns a default-model config patch. Fixes #50268. Thanks @rixcorp-oc. +- Slack/message tool: let `read` fetch an exact Slack message timestamp, including a specific thread reply when paired with `threadId`, instead of returning only the parent thread or recent channel history. Fixes #53943. Thanks @zomars. - Heartbeat: strip legacy `[TOOL_CALL]...[/TOOL_CALL]` and `[TOOL_RESULT]...[/TOOL_RESULT]` pseudo-call blocks from heartbeat replies before channel delivery. Fixes #54138. Thanks @Deniable9570. - macOS/Voice Wake: send wake-word and Push-to-Talk transcripts through the selected macOS session target instead of always falling back to main WebChat. Fixes #51040. Thanks @carl-jeffrolc. - Providers/xAI: give Grok `web_search` a 60s default timeout, harden malformed xAI Responses parsing, and return structured timeout errors instead of aborting the tool call. Fixes #58063 and #58733. Thanks @dnishimura, @marvcasasola-svg, and @Nanako0129. diff --git a/docs/cli/message.md b/docs/cli/message.md index 4cbd4a59263..76c42c3e23a 100644 --- a/docs/cli/message.md +++ b/docs/cli/message.md @@ -101,7 +101,8 @@ Name lookup: - `read` - Channels: Discord/Slack/Matrix - Required: `--target` - - Optional: `--limit`, `--before`, `--after` + - Optional: `--limit`, `--message-id`, `--before`, `--after` + - Slack only: `--message-id` reads a specific Slack message timestamp; combine with `--thread-id` to read an exact thread reply. - Discord only: `--around` - `edit` diff --git a/extensions/slack/src/action-runtime.test.ts b/extensions/slack/src/action-runtime.test.ts index 29ad2a610e5..191218b63c4 100644 --- a/extensions/slack/src/action-runtime.test.ts +++ b/extensions/slack/src/action-runtime.test.ts @@ -689,6 +689,29 @@ describe("handleSlackAction", () => { ); }); + it("passes messageId through to readSlackMessages", async () => { + readSlackMessages.mockResolvedValueOnce({ messages: [], hasMore: false }); + + await handleSlackAction( + { + action: "readMessages", + channelId: "C1", + threadId: "1712345678.123456", + messageId: "1712345678.654321", + }, + slackConfig(), + ); + + expect(readSlackMessages).toHaveBeenCalledWith( + "C1", + expect.objectContaining({ + cfg: expect.any(Object), + threadId: "1712345678.123456", + messageId: "1712345678.654321", + }), + ); + }); + it("adds normalized timestamps to pin payloads", async () => { listSlackPins.mockResolvedValueOnce([{ message: { ts: "1712345678.123456", text: "pin" } }]); diff --git a/extensions/slack/src/action-runtime.ts b/extensions/slack/src/action-runtime.ts index 991ad245ce8..23dd5d23389 100644 --- a/extensions/slack/src/action-runtime.ts +++ b/extensions/slack/src/action-runtime.ts @@ -366,12 +366,14 @@ export async function handleSlackAction( const before = readStringParam(params, "before"); const after = readStringParam(params, "after"); const threadId = readStringParam(params, "threadId"); + const messageId = readStringParam(params, "messageId"); const result = await slackActionRuntime.readSlackMessages(channelId, { ...readOpts, limit, before: before ?? undefined, after: after ?? undefined, threadId: threadId ?? undefined, + messageId: messageId ?? undefined, }); const messages = result.messages.map((message) => withNormalizedTimestamp( diff --git a/extensions/slack/src/actions.read.test.ts b/extensions/slack/src/actions.read.test.ts index af9f61a3fa2..2a68833c0e0 100644 --- a/extensions/slack/src/actions.read.test.ts +++ b/extensions/slack/src/actions.read.test.ts @@ -41,6 +41,35 @@ describe("readSlackMessages", () => { expect(result.messages.map((message) => message.ts)).toEqual(["171234.890", "171235.000"]); }); + it("filters a specific thread reply by messageId", async () => { + const client = createClient(); + client.conversations.replies.mockResolvedValueOnce({ + messages: [{ ts: "171234.567" }, { ts: "171234.890", text: "reply" }], + has_more: true, + }); + + const result = await readSlackMessages("C1", { + client, + threadId: "171234.567", + messageId: "171234.890", + limit: 20, + token: "xoxb-test", + }); + + expect(client.conversations.replies).toHaveBeenCalledWith({ + channel: "C1", + ts: "171234.567", + limit: 1, + inclusive: true, + latest: "171234.890", + oldest: undefined, + }); + expect(result).toEqual({ + messages: [{ ts: "171234.890", text: "reply" }], + hasMore: false, + }); + }); + it("uses conversations.history when threadId is missing", async () => { const client = createClient(); client.conversations.history.mockResolvedValueOnce({ @@ -63,4 +92,30 @@ describe("readSlackMessages", () => { expect(client.conversations.replies).not.toHaveBeenCalled(); expect(result.messages.map((message) => message.ts)).toEqual(["1"]); }); + + it("filters a specific channel message by messageId", async () => { + const client = createClient(); + client.conversations.history.mockResolvedValueOnce({ + messages: [{ ts: "171234.890", text: "exact" }, { ts: "171234.891" }], + has_more: true, + }); + + const result = await readSlackMessages("C1", { + client, + messageId: "171234.890", + token: "xoxb-test", + }); + + expect(client.conversations.history).toHaveBeenCalledWith({ + channel: "C1", + limit: 1, + inclusive: true, + latest: "171234.890", + oldest: undefined, + }); + expect(result).toEqual({ + messages: [{ ts: "171234.890", text: "exact" }], + hasMore: false, + }); + }); }); diff --git a/extensions/slack/src/actions.ts b/extensions/slack/src/actions.ts index 58ca16e9b9f..b52c4b4aac7 100644 --- a/extensions/slack/src/actions.ts +++ b/extensions/slack/src/actions.ts @@ -257,37 +257,55 @@ export async function readSlackMessages( before?: string; after?: string; threadId?: string; + messageId?: string; } = {}, ): Promise<{ messages: SlackMessageSummary[]; hasMore: boolean }> { const client = await getClient(opts); + const exactMessageId = opts.messageId?.trim(); + const readLimit = exactMessageId ? 1 : opts.limit; + const exactBounds = exactMessageId + ? { + inclusive: true, + latest: exactMessageId, + oldest: undefined, + } + : { + latest: opts.before, + oldest: opts.after, + }; // Use conversations.replies for thread messages, conversations.history for channel messages. if (opts.threadId) { const result = await client.conversations.replies({ channel: channelId, ts: opts.threadId, - limit: opts.limit, - latest: opts.before, - oldest: opts.after, + limit: readLimit, + ...exactBounds, + }); + const messages = ((result.messages ?? []) as SlackMessageSummary[]).filter((message) => { + if (exactMessageId) { + return message.ts === exactMessageId; + } + // conversations.replies includes the parent message; drop it for replies-only reads. + return message.ts !== opts.threadId; }); return { - // conversations.replies includes the parent message; drop it for replies-only reads. - messages: (result.messages ?? []).filter( - (message) => (message as SlackMessageSummary)?.ts !== opts.threadId, - ) as SlackMessageSummary[], - hasMore: Boolean(result.has_more), + messages, + hasMore: exactMessageId ? false : Boolean(result.has_more), }; } const result = await client.conversations.history({ channel: channelId, - limit: opts.limit, - latest: opts.before, - oldest: opts.after, + limit: readLimit, + ...exactBounds, }); + const messages = ((result.messages ?? []) as SlackMessageSummary[]).filter( + (message) => !exactMessageId || message.ts === exactMessageId, + ); return { - messages: (result.messages ?? []) as SlackMessageSummary[], - hasMore: Boolean(result.has_more), + messages, + hasMore: exactMessageId ? false : Boolean(result.has_more), }; } diff --git a/extensions/slack/src/channel.test.ts b/extensions/slack/src/channel.test.ts index 16f6424c482..924fe1cdf5f 100644 --- a/extensions/slack/src/channel.test.ts +++ b/extensions/slack/src/channel.test.ts @@ -268,6 +268,7 @@ describe("slackPlugin actions", () => { params: { channelId: "C123", threadId: "1712345678.123456", + messageId: "1712345678.654321", }, }); @@ -276,6 +277,7 @@ describe("slackPlugin actions", () => { action: "readMessages", channelId: "C123", threadId: "1712345678.123456", + messageId: "1712345678.654321", }), {}, undefined, diff --git a/extensions/slack/src/message-action-dispatch.test.ts b/extensions/slack/src/message-action-dispatch.test.ts index 8d95c5cccc2..7b7442f8f04 100644 --- a/extensions/slack/src/message-action-dispatch.test.ts +++ b/extensions/slack/src/message-action-dispatch.test.ts @@ -194,6 +194,32 @@ describe("handleSlackMessageAction", () => { ); }); + it("forwards messageId for read actions", async () => { + const invoke = createInvokeSpy(); + + await handleSlackMessageAction({ + providerId: "slack", + ctx: { + action: "read", + cfg: {}, + params: { + channelId: "C1", + messageId: "1712345678.654321", + }, + } as never, + invoke: invoke as never, + }); + + expect(invoke).toHaveBeenCalledWith( + expect.objectContaining({ + action: "readMessages", + channelId: "C1", + messageId: "1712345678.654321", + }), + {}, + ); + }); + it("requires filePath, path, or media for upload-file", async () => { await expect( handleSlackMessageAction({ diff --git a/extensions/slack/src/message-action-dispatch.ts b/extensions/slack/src/message-action-dispatch.ts index 81a8e8ef53d..f85539d88c1 100644 --- a/extensions/slack/src/message-action-dispatch.ts +++ b/extensions/slack/src/message-action-dispatch.ts @@ -122,6 +122,7 @@ export async function handleSlackMessageAction(params: { limit, before: readStringParam(actionParams, "before"), after: readStringParam(actionParams, "after"), + messageId: readStringParam(actionParams, "messageId"), accountId, }; if (includeReadThreadId) { diff --git a/src/agents/tools/message-tool.test.ts b/src/agents/tools/message-tool.test.ts index 9023a3e6748..54fa1b90b0a 100644 --- a/src/agents/tools/message-tool.test.ts +++ b/src/agents/tools/message-tool.test.ts @@ -778,6 +778,32 @@ describe("message tool schema scoping", () => { expect(getActionEnum(properties)).toContain("download-file"); expect(properties.fileId).toMatchObject({ type: "string" }); }); + + it("advertises messageId for read actions", () => { + const slackReadPlugin = createChannelPlugin({ + id: "slack", + label: "Slack", + docsPath: "/channels/slack", + blurb: "Slack test plugin.", + actions: ["read"], + }); + + setActivePluginRegistry( + createTestRegistry([{ pluginId: "slack", source: "test", plugin: slackReadPlugin }]), + ); + + const tool = createMessageTool({ + config: {} as never, + currentChannelProvider: "slack", + }); + const properties = getToolProperties(tool); + + expect(getActionEnum(properties)).toContain("read"); + expect(properties.messageId).toMatchObject({ + type: "string", + description: expect.stringContaining("read"), + }); + }); }); describe("message tool description", () => { diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts index 00fefa22260..c0e843c5dcb 100644 --- a/src/agents/tools/message-tool.ts +++ b/src/agents/tools/message-tool.ts @@ -168,14 +168,14 @@ function buildReactionSchema() { messageId: Type.Optional( Type.String({ description: - "Target message id for reaction. If omitted, defaults to the current inbound message id when available.", + "Target message id for read, reaction, edit, delete, pin, or unpin. If omitted for reaction-like actions, defaults to the current inbound message id when available.", }), ), message_id: Type.Optional( Type.String({ // Intentional duplicate alias for tool-schema discoverability in LLMs. description: - "snake_case alias of messageId. If omitted, defaults to the current inbound message id when available.", + "snake_case alias of messageId. If omitted for reaction-like actions, defaults to the current inbound message id when available.", }), ), emoji: Type.Optional(Type.String()), diff --git a/src/cli/program/message/register.read-edit-delete.ts b/src/cli/program/message/register.read-edit-delete.ts index 403449d1456..658dc35eadf 100644 --- a/src/cli/program/message/register.read-edit-delete.ts +++ b/src/cli/program/message/register.read-edit-delete.ts @@ -12,9 +12,11 @@ export function registerMessageReadEditDeleteCommands( ), ) .option("--limit ", "Result limit") + .option("--message-id ", "Read a specific message id") .option("--before ", "Read/search before id") .option("--after ", "Read/search after id") .option("--around ", "Read around id") + .option("--thread-id ", "Thread id (Slack thread timestamp)") .option("--include-thread", "Include thread replies (Discord)", false) .action(async (opts) => { await helpers.runMessageAction("read", opts);