fix: slack mention-gating thread participation

This commit is contained in:
Bek
2026-05-04 23:28:27 -04:00
committed by Peter Steinberger
parent 712aa96a8f
commit cf3ce08b91
7 changed files with 81 additions and 21 deletions

View File

@@ -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-<uid>` 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.

View File

@@ -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,

View File

@@ -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;

View File

@@ -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 });

View File

@@ -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();

View File

@@ -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: {

View File

@@ -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 () => {