diff --git a/CHANGELOG.md b/CHANGELOG.md index bf4858f22b1..55b4cb6295d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,7 @@ Docs: https://docs.openclaw.ai - WhatsApp/onboarding: canonicalize setup and pairing allowlist entries to WhatsApp's digit-only phone ids while still accepting E.164, JID, and `whatsapp:` inputs, so personal-phone allowlists match WhatsApp Web sender ids after setup. Thanks @vincentkoc. - Gateway/startup: load provider plugins that own explicitly configured image, video, or music generation defaults so generation tools become live after gateway restart instead of remaining catalog-only. Fixes #77244. Thanks @buyuangtampan, @Nikoxx99, and @vincentkoc. - Slack/subagents: keep resumed parent `message.send` calls in the originating Slack thread when ambient session thread context is present, and suppress successful silent child completion rows from follow-up findings. Thanks @bek91. +- Slack/mentions: record thread participation for successful visible threaded Slack sends, including message-tool and media delivery paths, so unmentioned replies in bot-participated threads can bypass mention gating as documented. Fixes #77648. Thanks @bek91. - Infra/Windows: skip the POSIX `/tmp/openclaw` preferred path on Windows in `resolvePreferredOpenClawTmpDir` so log files, TTS temp files, and other writes land in `%TEMP%\openclaw-` instead of `C:\tmp\openclaw`. Fixes #60713. Thanks @juan-flores077. - Gateway/diagnostics: make stuck-session recovery outcome-driven and generation-guarded, add `diagnostics.stuckSessionAbortMs`, and emit structured recovery requested/completed events so stale or skipped recovery no longer looks like a successful abort. - Media/Windows: open saved attachment temp files read/write before fsync so Windows WebChat and `chat.send` media offloads no longer fail with EPERM during durability flush. (#76593) Thanks @qq230849622-a11y. diff --git a/extensions/slack/src/action-runtime.test.ts b/extensions/slack/src/action-runtime.test.ts index cc0dcac4e70..ff7a8423cea 100644 --- a/extensions/slack/src/action-runtime.test.ts +++ b/extensions/slack/src/action-runtime.test.ts @@ -16,7 +16,6 @@ const reactSlackMessage = vi.fn(async (..._args: unknown[]) => ({})); const readSlackMessages = vi.fn(async (..._args: unknown[]) => ({})); const removeOwnSlackReactions = vi.fn(async (..._args: unknown[]) => ["thumbsup"]); const removeSlackReaction = vi.fn(async (..._args: unknown[]) => ({})); -const recordSlackThreadParticipation = vi.fn(); const sendSlackMessage = vi.fn(async (..._args: unknown[]) => ({ channelId: "C123" })); const unpinSlackMessage = vi.fn(async (..._args: unknown[]) => ({})); @@ -103,7 +102,6 @@ describe("handleSlackAction", () => { pinSlackMessage, reactSlackMessage, readSlackMessages, - recordSlackThreadParticipation, removeOwnSlackReactions, removeSlackReaction, sendSlackMessage, diff --git a/extensions/slack/src/action-runtime.ts b/extensions/slack/src/action-runtime.ts index 28e798fc5d0..86dce489abf 100644 --- a/extensions/slack/src/action-runtime.ts +++ b/extensions/slack/src/action-runtime.ts @@ -12,7 +12,6 @@ import { type OpenClawConfig, withNormalizedTimestamp, } from "./runtime-api.js"; -import { recordSlackThreadParticipation } from "./sent-thread-cache.js"; import { parseSlackTarget, resolveSlackChannelId } from "./targets.js"; const messagingActions = new Set([ @@ -78,7 +77,6 @@ export const slackActionRuntime = { pinSlackMessage: createLazySlackAction("pinSlackMessage"), reactSlackMessage: createLazySlackAction("reactSlackMessage"), readSlackMessages: createLazySlackAction("readSlackMessages"), - recordSlackThreadParticipation, removeOwnSlackReactions: createLazySlackAction("removeOwnSlackReactions"), removeSlackReaction: createLazySlackAction("removeSlackReaction"), sendSlackMessage: createLazySlackAction("sendSlackMessage"), @@ -273,14 +271,6 @@ export async function handleSlackAction( blocks, }); - if (threadTs && result.channelId && account.accountId) { - slackActionRuntime.recordSlackThreadParticipation( - account.accountId, - result.channelId, - threadTs, - ); - } - // Keep "first" mode consistent even when the agent explicitly provided // threadTs: once we send a message to the current channel, consider the // first reply "used" so later tool calls don't auto-thread again. @@ -318,14 +308,6 @@ export async function handleSlackAction( ...(title ? { uploadTitle: title } : {}), }); - if (threadTs && result.channelId && account.accountId) { - slackActionRuntime.recordSlackThreadParticipation( - account.accountId, - result.channelId, - threadTs, - ); - } - if (context?.hasRepliedRef && context.currentChannelId) { if (sameSlackChannelTarget(to, context.currentChannelId)) { context.hasRepliedRef.value = true; diff --git a/extensions/slack/src/outbound-delivery.test.ts b/extensions/slack/src/outbound-delivery.test.ts index c894d7b7e3a..601f239c75d 100644 --- a/extensions/slack/src/outbound-delivery.test.ts +++ b/extensions/slack/src/outbound-delivery.test.ts @@ -92,6 +92,25 @@ describe("slack outbound shared hook wiring", () => { expect(sendMessageSlackMock).toHaveBeenCalledTimes(1); }); + it("passes replyToId as Slack threadTs for threaded outbound delivery", async () => { + await deliverOutboundPayloads({ + cfg, + channel: "slack", + to: "C123", + payloads: [{ text: "hello" }], + accountId: "default", + replyToId: "1712000000.000001", + }); + + expect(sendMessageSlackMock).toHaveBeenCalledWith( + "C123", + "hello", + expect.objectContaining({ + threadTs: "1712000000.000001", + }), + ); + }); + it("respects cancel from the shared hook without a second adapter pass", async () => { const hookRegistry = createEmptyPluginRegistry(); const handler = vi.fn().mockResolvedValue({ cancel: true }); diff --git a/extensions/slack/src/send.blocks.test.ts b/extensions/slack/src/send.blocks.test.ts index 5f085a60029..6ecb1520014 100644 --- a/extensions/slack/src/send.blocks.test.ts +++ b/extensions/slack/src/send.blocks.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; import { createSlackSendTestClient, installSlackBlockTestMocks } from "./blocks.test-helpers.js"; +import { + clearSlackThreadParticipationCache, + hasSlackThreadParticipation, +} from "./sent-thread-cache.js"; installSlackBlockTestMocks(); const { sendMessageSlack } = await import("./send.js"); @@ -67,6 +71,49 @@ describe("sendMessageSlack NO_REPLY guard", () => { }); }); +describe("sendMessageSlack thread participation", () => { + it("records participation after a successful threaded send", async () => { + clearSlackThreadParticipationCache(); + const client = createSlackSendTestClient(); + + await sendMessageSlack("channel:C123", "hello thread", { + token: "xoxb-test", + cfg: SLACK_TEST_CFG, + client, + threadTs: "1712345678.123456", + }); + + expect(hasSlackThreadParticipation("default", "C123", "1712345678.123456")).toBe(true); + }); + + it("does not record participation for unthreaded sends", async () => { + clearSlackThreadParticipationCache(); + const client = createSlackSendTestClient(); + + await sendMessageSlack("channel:C123", "hello channel", { + token: "xoxb-test", + cfg: SLACK_TEST_CFG, + client, + }); + + expect(hasSlackThreadParticipation("default", "C123", "1712345678.123456")).toBe(false); + }); + + it("does not record participation for invalid thread ids", async () => { + clearSlackThreadParticipationCache(); + const client = createSlackSendTestClient(); + + await sendMessageSlack("channel:C123", "hello invalid thread", { + token: "xoxb-test", + cfg: SLACK_TEST_CFG, + client, + threadTs: "not-a-slack-thread", + }); + + expect(hasSlackThreadParticipation("default", "C123", "not-a-slack-thread")).toBe(false); + }); +}); + describe("sendMessageSlack chunking", () => { it("keeps 4205-character text in a single Slack post by default", async () => { const client = createSlackSendTestClient(); diff --git a/extensions/slack/src/send.ts b/extensions/slack/src/send.ts index 3839cc91209..ceb145e225b 100644 --- a/extensions/slack/src/send.ts +++ b/extensions/slack/src/send.ts @@ -24,7 +24,9 @@ import { createSlackTokenCacheKey, getSlackWriteClient } from "./client.js"; import { markdownToSlackMrkdwnChunks } from "./format.js"; import { SLACK_TEXT_LIMIT } from "./limits.js"; import { loadOutboundMediaFromUrl } from "./runtime-api.js"; +import { recordSlackThreadParticipation } from "./sent-thread-cache.js"; import { parseSlackTarget } from "./targets.js"; +import { normalizeSlackThreadTsCandidate } from "./thread-ts.js"; import { resolveSlackBotToken } from "./token.js"; import { truncateSlackText } from "./truncate.js"; const SLACK_UPLOAD_SSRF_POLICY = { @@ -535,7 +537,7 @@ export async function sendMessageSlack( recipient, threadTs: opts.threadTs, }); - return await runQueuedSlackSend(queueKey, () => + const result = await runQueuedSlackSend(queueKey, () => sendMessageSlackQueued({ trimmedMessage, opts, @@ -546,6 +548,11 @@ export async function sendMessageSlack( blocks, }), ); + const threadTs = normalizeSlackThreadTsCandidate(opts.threadTs); + if (threadTs && result.channelId && account.accountId) { + recordSlackThreadParticipation(account.accountId, result.channelId, threadTs); + } + return result; } async function sendMessageSlackQueued(params: { diff --git a/extensions/slack/src/send.upload.test.ts b/extensions/slack/src/send.upload.test.ts index db2502e1056..ddbcb2d05fc 100644 --- a/extensions/slack/src/send.upload.test.ts +++ b/extensions/slack/src/send.upload.test.ts @@ -1,6 +1,10 @@ import type { WebClient } from "@slack/web-api"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { installSlackBlockTestMocks } from "./blocks.test-helpers.js"; +import { + clearSlackThreadParticipationCache, + hasSlackThreadParticipation, +} from "./sent-thread-cache.js"; // --- Module mocks (must precede dynamic import) --- installSlackBlockTestMocks(); @@ -96,6 +100,7 @@ describe("sendMessageSlack file upload with user IDs", () => { loadOutboundMediaFromUrlMock.mockClear(); clearSlackDmChannelCache(); clearSlackSendQueuesForTest(); + clearSlackThreadParticipationCache(); }); afterEach(() => { @@ -297,6 +302,7 @@ describe("sendMessageSlack file upload with user IDs", () => { thread_ts: "171.222", }), ); + expect(hasSlackThreadParticipation("default", "C123CHAN", "171.222")).toBe(true); }); it("uses explicit upload filename and title overrides when provided", async () => {