diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e312edb7..16e5acc2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,7 @@ Docs: https://docs.clawd.bot - Auth: skip auth profiles in cooldown during initial selection and rotation. (#1316) Thanks @odrobnik. - Agents/TUI: honor user-pinned auth profiles during cooldown and preserve search picker ranking. (#1432) Thanks @tobiasbischoff. - Docs: fix gog auth services example to include docs scope. (#1454) Thanks @zerone0x. +- Slack: read thread replies for message reads when threadId is provided (replies-only). (#1450) Thanks @rodrigouroz. - macOS: prefer linked channels in gateway summary to avoid false “not linked” status. - Providers: improve GitHub Copilot integration (enterprise support, base URL, and auth flow alignment). diff --git a/src/agents/tools/slack-actions.test.ts b/src/agents/tools/slack-actions.test.ts index 92f20e66a..9cfa7c056 100644 --- a/src/agents/tools/slack-actions.test.ts +++ b/src/agents/tools/slack-actions.test.ts @@ -357,6 +357,20 @@ describe("handleSlackAction", () => { expect(payload.messages[0].timestampUtc).toBe(new Date(expectedMs).toISOString()); }); + it("passes threadId through to readSlackMessages", async () => { + const cfg = { channels: { slack: { botToken: "tok" } } } as ClawdbotConfig; + readSlackMessages.mockClear(); + readSlackMessages.mockResolvedValueOnce({ messages: [], hasMore: false }); + + await handleSlackAction( + { action: "readMessages", channelId: "C1", threadId: "12345.6789" }, + cfg, + ); + + const [, opts] = readSlackMessages.mock.calls[0] ?? []; + expect(opts?.threadId).toBe("12345.6789"); + }); + it("adds normalized timestamps to pin payloads", async () => { const cfg = { channels: { slack: { botToken: "tok" } } } as ClawdbotConfig; listSlackPins.mockResolvedValueOnce([ diff --git a/src/agents/tools/slack-actions.ts b/src/agents/tools/slack-actions.ts index 73706b2a4..7771c19d4 100644 --- a/src/agents/tools/slack-actions.ts +++ b/src/agents/tools/slack-actions.ts @@ -214,11 +214,13 @@ export async function handleSlackAction( typeof limitRaw === "number" && Number.isFinite(limitRaw) ? limitRaw : undefined; const before = readStringParam(params, "before"); const after = readStringParam(params, "after"); + const threadId = readStringParam(params, "threadId"); const result = await readSlackMessages(channelId, { ...readOpts, limit, before: before ?? undefined, after: after ?? undefined, + threadId: threadId ?? undefined, }); const messages = result.messages.map((message) => withNormalizedTimestamp( diff --git a/src/channels/plugins/slack.actions.test.ts b/src/channels/plugins/slack.actions.test.ts new file mode 100644 index 000000000..3591e895c --- /dev/null +++ b/src/channels/plugins/slack.actions.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { ClawdbotConfig } from "../../config/config.js"; +import { createSlackActions } from "./slack.actions.js"; + +const handleSlackAction = vi.fn(async () => ({ details: { ok: true } })); + +vi.mock("../../agents/tools/slack-actions.js", () => ({ + handleSlackAction: (...args: unknown[]) => handleSlackAction(...args), +})); + +describe("slack actions adapter", () => { + it("forwards threadId for read", async () => { + const cfg = { channels: { slack: { botToken: "tok" } } } as ClawdbotConfig; + const actions = createSlackActions("slack"); + + await actions.handleAction?.({ + channel: "slack", + action: "read", + cfg, + params: { + channelId: "C1", + threadId: "171234.567", + }, + }); + + const [params] = handleSlackAction.mock.calls[0] ?? []; + expect(params).toMatchObject({ + action: "readMessages", + channelId: "C1", + threadId: "171234.567", + }); + }); +}); diff --git a/src/channels/plugins/slack.actions.ts b/src/channels/plugins/slack.actions.ts index 295e6634c..ca8aa6fb8 100644 --- a/src/channels/plugins/slack.actions.ts +++ b/src/channels/plugins/slack.actions.ts @@ -133,6 +133,7 @@ export function createSlackActions(providerId: string): ChannelMessageActionAdap limit, before: readStringParam(params, "before"), after: readStringParam(params, "after"), + threadId: readStringParam(params, "threadId"), accountId: accountId ?? undefined, }, cfg, diff --git a/src/slack/actions.read.test.ts b/src/slack/actions.read.test.ts new file mode 100644 index 000000000..cfd72d61c --- /dev/null +++ b/src/slack/actions.read.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { WebClient } from "@slack/web-api"; + +import { readSlackMessages } from "./actions.js"; + +function createClient() { + return { + conversations: { + replies: vi.fn(async () => ({ messages: [], has_more: false })), + history: vi.fn(async () => ({ messages: [], has_more: false })), + }, + } as unknown as WebClient & { + conversations: { + replies: ReturnType; + history: ReturnType; + }; + }; +} + +describe("readSlackMessages", () => { + it("uses conversations.replies and drops the parent message", async () => { + const client = createClient(); + client.conversations.replies.mockResolvedValueOnce({ + messages: [{ ts: "171234.567" }, { ts: "171234.890" }, { ts: "171235.000" }], + has_more: true, + }); + + const result = await readSlackMessages("C1", { + client, + threadId: "171234.567", + token: "xoxb-test", + }); + + expect(client.conversations.replies).toHaveBeenCalledWith({ + channel: "C1", + ts: "171234.567", + limit: undefined, + latest: undefined, + oldest: undefined, + }); + expect(client.conversations.history).not.toHaveBeenCalled(); + expect(result.messages.map((message) => message.ts)).toEqual(["171234.890", "171235.000"]); + }); + + it("uses conversations.history when threadId is missing", async () => { + const client = createClient(); + client.conversations.history.mockResolvedValueOnce({ + messages: [{ ts: "1" }], + has_more: false, + }); + + const result = await readSlackMessages("C1", { + client, + limit: 20, + token: "xoxb-test", + }); + + expect(client.conversations.history).toHaveBeenCalledWith({ + channel: "C1", + limit: 20, + latest: undefined, + oldest: undefined, + }); + expect(client.conversations.replies).not.toHaveBeenCalled(); + expect(result.messages.map((message) => message.ts)).toEqual(["1"]); + }); +}); diff --git a/src/slack/actions.ts b/src/slack/actions.ts index ef840cba2..d045bda3b 100644 --- a/src/slack/actions.ts +++ b/src/slack/actions.ts @@ -186,9 +186,29 @@ export async function readSlackMessages( limit?: number; before?: string; after?: string; + threadId?: string; } = {}, ): Promise<{ messages: SlackMessageSummary[]; hasMore: boolean }> { const client = await getClient(opts); + + // 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, + }); + 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), + }; + } + const result = await client.conversations.history({ channel: channelId, limit: opts.limit,