mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 19:00:45 +00:00
fix: slack mention-gating thread participation
This commit is contained in:
@@ -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.
|
- 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.
|
- 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/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.
|
- 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.
|
- 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.
|
- 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.
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ const reactSlackMessage = vi.fn(async (..._args: unknown[]) => ({}));
|
|||||||
const readSlackMessages = vi.fn(async (..._args: unknown[]) => ({}));
|
const readSlackMessages = vi.fn(async (..._args: unknown[]) => ({}));
|
||||||
const removeOwnSlackReactions = vi.fn(async (..._args: unknown[]) => ["thumbsup"]);
|
const removeOwnSlackReactions = vi.fn(async (..._args: unknown[]) => ["thumbsup"]);
|
||||||
const removeSlackReaction = vi.fn(async (..._args: unknown[]) => ({}));
|
const removeSlackReaction = vi.fn(async (..._args: unknown[]) => ({}));
|
||||||
const recordSlackThreadParticipation = vi.fn();
|
|
||||||
const sendSlackMessage = vi.fn(async (..._args: unknown[]) => ({ channelId: "C123" }));
|
const sendSlackMessage = vi.fn(async (..._args: unknown[]) => ({ channelId: "C123" }));
|
||||||
const unpinSlackMessage = vi.fn(async (..._args: unknown[]) => ({}));
|
const unpinSlackMessage = vi.fn(async (..._args: unknown[]) => ({}));
|
||||||
|
|
||||||
@@ -103,7 +102,6 @@ describe("handleSlackAction", () => {
|
|||||||
pinSlackMessage,
|
pinSlackMessage,
|
||||||
reactSlackMessage,
|
reactSlackMessage,
|
||||||
readSlackMessages,
|
readSlackMessages,
|
||||||
recordSlackThreadParticipation,
|
|
||||||
removeOwnSlackReactions,
|
removeOwnSlackReactions,
|
||||||
removeSlackReaction,
|
removeSlackReaction,
|
||||||
sendSlackMessage,
|
sendSlackMessage,
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import {
|
|||||||
type OpenClawConfig,
|
type OpenClawConfig,
|
||||||
withNormalizedTimestamp,
|
withNormalizedTimestamp,
|
||||||
} from "./runtime-api.js";
|
} from "./runtime-api.js";
|
||||||
import { recordSlackThreadParticipation } from "./sent-thread-cache.js";
|
|
||||||
import { parseSlackTarget, resolveSlackChannelId } from "./targets.js";
|
import { parseSlackTarget, resolveSlackChannelId } from "./targets.js";
|
||||||
|
|
||||||
const messagingActions = new Set([
|
const messagingActions = new Set([
|
||||||
@@ -78,7 +77,6 @@ export const slackActionRuntime = {
|
|||||||
pinSlackMessage: createLazySlackAction("pinSlackMessage"),
|
pinSlackMessage: createLazySlackAction("pinSlackMessage"),
|
||||||
reactSlackMessage: createLazySlackAction("reactSlackMessage"),
|
reactSlackMessage: createLazySlackAction("reactSlackMessage"),
|
||||||
readSlackMessages: createLazySlackAction("readSlackMessages"),
|
readSlackMessages: createLazySlackAction("readSlackMessages"),
|
||||||
recordSlackThreadParticipation,
|
|
||||||
removeOwnSlackReactions: createLazySlackAction("removeOwnSlackReactions"),
|
removeOwnSlackReactions: createLazySlackAction("removeOwnSlackReactions"),
|
||||||
removeSlackReaction: createLazySlackAction("removeSlackReaction"),
|
removeSlackReaction: createLazySlackAction("removeSlackReaction"),
|
||||||
sendSlackMessage: createLazySlackAction("sendSlackMessage"),
|
sendSlackMessage: createLazySlackAction("sendSlackMessage"),
|
||||||
@@ -273,14 +271,6 @@ export async function handleSlackAction(
|
|||||||
blocks,
|
blocks,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (threadTs && result.channelId && account.accountId) {
|
|
||||||
slackActionRuntime.recordSlackThreadParticipation(
|
|
||||||
account.accountId,
|
|
||||||
result.channelId,
|
|
||||||
threadTs,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep "first" mode consistent even when the agent explicitly provided
|
// Keep "first" mode consistent even when the agent explicitly provided
|
||||||
// threadTs: once we send a message to the current channel, consider the
|
// threadTs: once we send a message to the current channel, consider the
|
||||||
// first reply "used" so later tool calls don't auto-thread again.
|
// first reply "used" so later tool calls don't auto-thread again.
|
||||||
@@ -318,14 +308,6 @@ export async function handleSlackAction(
|
|||||||
...(title ? { uploadTitle: title } : {}),
|
...(title ? { uploadTitle: title } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (threadTs && result.channelId && account.accountId) {
|
|
||||||
slackActionRuntime.recordSlackThreadParticipation(
|
|
||||||
account.accountId,
|
|
||||||
result.channelId,
|
|
||||||
threadTs,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (context?.hasRepliedRef && context.currentChannelId) {
|
if (context?.hasRepliedRef && context.currentChannelId) {
|
||||||
if (sameSlackChannelTarget(to, context.currentChannelId)) {
|
if (sameSlackChannelTarget(to, context.currentChannelId)) {
|
||||||
context.hasRepliedRef.value = true;
|
context.hasRepliedRef.value = true;
|
||||||
|
|||||||
@@ -92,6 +92,25 @@ describe("slack outbound shared hook wiring", () => {
|
|||||||
expect(sendMessageSlackMock).toHaveBeenCalledTimes(1);
|
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 () => {
|
it("respects cancel from the shared hook without a second adapter pass", async () => {
|
||||||
const hookRegistry = createEmptyPluginRegistry();
|
const hookRegistry = createEmptyPluginRegistry();
|
||||||
const handler = vi.fn().mockResolvedValue({ cancel: true });
|
const handler = vi.fn().mockResolvedValue({ cancel: true });
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { createSlackSendTestClient, installSlackBlockTestMocks } from "./blocks.test-helpers.js";
|
import { createSlackSendTestClient, installSlackBlockTestMocks } from "./blocks.test-helpers.js";
|
||||||
|
import {
|
||||||
|
clearSlackThreadParticipationCache,
|
||||||
|
hasSlackThreadParticipation,
|
||||||
|
} from "./sent-thread-cache.js";
|
||||||
|
|
||||||
installSlackBlockTestMocks();
|
installSlackBlockTestMocks();
|
||||||
const { sendMessageSlack } = await import("./send.js");
|
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", () => {
|
describe("sendMessageSlack chunking", () => {
|
||||||
it("keeps 4205-character text in a single Slack post by default", async () => {
|
it("keeps 4205-character text in a single Slack post by default", async () => {
|
||||||
const client = createSlackSendTestClient();
|
const client = createSlackSendTestClient();
|
||||||
|
|||||||
@@ -24,7 +24,9 @@ import { createSlackTokenCacheKey, getSlackWriteClient } from "./client.js";
|
|||||||
import { markdownToSlackMrkdwnChunks } from "./format.js";
|
import { markdownToSlackMrkdwnChunks } from "./format.js";
|
||||||
import { SLACK_TEXT_LIMIT } from "./limits.js";
|
import { SLACK_TEXT_LIMIT } from "./limits.js";
|
||||||
import { loadOutboundMediaFromUrl } from "./runtime-api.js";
|
import { loadOutboundMediaFromUrl } from "./runtime-api.js";
|
||||||
|
import { recordSlackThreadParticipation } from "./sent-thread-cache.js";
|
||||||
import { parseSlackTarget } from "./targets.js";
|
import { parseSlackTarget } from "./targets.js";
|
||||||
|
import { normalizeSlackThreadTsCandidate } from "./thread-ts.js";
|
||||||
import { resolveSlackBotToken } from "./token.js";
|
import { resolveSlackBotToken } from "./token.js";
|
||||||
import { truncateSlackText } from "./truncate.js";
|
import { truncateSlackText } from "./truncate.js";
|
||||||
const SLACK_UPLOAD_SSRF_POLICY = {
|
const SLACK_UPLOAD_SSRF_POLICY = {
|
||||||
@@ -535,7 +537,7 @@ export async function sendMessageSlack(
|
|||||||
recipient,
|
recipient,
|
||||||
threadTs: opts.threadTs,
|
threadTs: opts.threadTs,
|
||||||
});
|
});
|
||||||
return await runQueuedSlackSend(queueKey, () =>
|
const result = await runQueuedSlackSend(queueKey, () =>
|
||||||
sendMessageSlackQueued({
|
sendMessageSlackQueued({
|
||||||
trimmedMessage,
|
trimmedMessage,
|
||||||
opts,
|
opts,
|
||||||
@@ -546,6 +548,11 @@ export async function sendMessageSlack(
|
|||||||
blocks,
|
blocks,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
const threadTs = normalizeSlackThreadTsCandidate(opts.threadTs);
|
||||||
|
if (threadTs && result.channelId && account.accountId) {
|
||||||
|
recordSlackThreadParticipation(account.accountId, result.channelId, threadTs);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendMessageSlackQueued(params: {
|
async function sendMessageSlackQueued(params: {
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import type { WebClient } from "@slack/web-api";
|
import type { WebClient } from "@slack/web-api";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { installSlackBlockTestMocks } from "./blocks.test-helpers.js";
|
import { installSlackBlockTestMocks } from "./blocks.test-helpers.js";
|
||||||
|
import {
|
||||||
|
clearSlackThreadParticipationCache,
|
||||||
|
hasSlackThreadParticipation,
|
||||||
|
} from "./sent-thread-cache.js";
|
||||||
|
|
||||||
// --- Module mocks (must precede dynamic import) ---
|
// --- Module mocks (must precede dynamic import) ---
|
||||||
installSlackBlockTestMocks();
|
installSlackBlockTestMocks();
|
||||||
@@ -96,6 +100,7 @@ describe("sendMessageSlack file upload with user IDs", () => {
|
|||||||
loadOutboundMediaFromUrlMock.mockClear();
|
loadOutboundMediaFromUrlMock.mockClear();
|
||||||
clearSlackDmChannelCache();
|
clearSlackDmChannelCache();
|
||||||
clearSlackSendQueuesForTest();
|
clearSlackSendQueuesForTest();
|
||||||
|
clearSlackThreadParticipationCache();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -297,6 +302,7 @@ describe("sendMessageSlack file upload with user IDs", () => {
|
|||||||
thread_ts: "171.222",
|
thread_ts: "171.222",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
expect(hasSlackThreadParticipation("default", "C123CHAN", "171.222")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses explicit upload filename and title overrides when provided", async () => {
|
it("uses explicit upload filename and title overrides when provided", async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user