[BACKPORT] fix(slack): stop repeated outage notices in active threads (#122832)

This commit is contained in:
Kimi Yu
2026-08-12 15:31:13 -07:00
committed by GitHub
parent fa796bf42d
commit f4368968a8
18 changed files with 745 additions and 17 deletions
+5 -1
View File
@@ -1,11 +1,15 @@
// Slack tests cover actions.blocks plugin behavior.
import { describe, expect, it } from "vitest";
import { afterAll, describe, expect, it } from "vitest";
import { createSlackEditTestClient, createSlackSendTestClient } from "./blocks.test-helpers.js";
import { registerSlackInstallationState } from "./installation-identity-state.js";
import { countSlackTextUtf8Bytes } from "./truncate.js";
const { editSlackMessage, sendSlackMessage } = await import("./actions.js");
const SLACK_TEXT_LIMIT = 8000;
const SLACK_EDIT_TEXT_MAX_BYTES = 4000;
const workspaceInstallation = registerSlackInstallationState("default", "workspace");
afterAll(() => workspaceInstallation.release());
function readFirstChatUpdatePayload(client: ReturnType<typeof createSlackEditTestClient>): {
text?: string;
@@ -1,7 +1,12 @@
// Slack tests cover actionsownload file plugin behavior.
import type { WebClient } from "@slack/web-api";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { registerSlackInstallationState } from "./installation-identity-state.js";
const workspaceInstallation = registerSlackInstallationState("default", "workspace");
afterAll(() => workspaceInstallation.release());
const resolveSlackMedia = vi.fn();
const createSlackLookupClientMock = vi.hoisted(() => vi.fn());
+6 -1
View File
@@ -1,7 +1,12 @@
// Slack tests cover actions.read plugin behavior.
import type { WebClient } from "@slack/web-api";
import { describe, expect, it, vi } from "vitest";
import { afterAll, describe, expect, it, vi } from "vitest";
import { readSlackMessages, resolveSlackConversationName } from "./actions.js";
import { registerSlackInstallationState } from "./installation-identity-state.js";
const workspaceInstallation = registerSlackInstallationState("default", "workspace");
afterAll(() => workspaceInstallation.release());
const createSlackLookupClientMock = vi.hoisted(() =>
vi.fn(() => ({ conversations: { info: vi.fn(), replies: vi.fn(), history: vi.fn() } })),
@@ -5,8 +5,9 @@ import {
verifyChannelMessageLiveCapabilityAdapterProofs,
verifyChannelMessageLiveFinalizerProofs,
} from "openclaw/plugin-sdk/channel-outbound";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
import { slackPlugin } from "./channel.js";
import { registerSlackInstallationState } from "./installation-identity-state.js";
import { SLACK_PRESENTATION_CAPABILITIES } from "./presentation.js";
import type { OpenClawConfig } from "./runtime-api.js";
@@ -18,6 +19,9 @@ const cfg = {
},
},
} as OpenClawConfig;
const workspaceInstallation = registerSlackInstallationState("default", "workspace");
afterAll(() => workspaceInstallation.release());
type SlackMessageAdapter = NonNullable<typeof slackPlugin.message>;
type SlackMessageSender = NonNullable<SlackMessageAdapter["send"]>;
@@ -22,8 +22,11 @@ import {
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import type { ReplyDispatchKind, ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import { afterAll, afterEach, describe, it, vi } from "vitest";
import { registerSlackInstallationState } from "./installation-identity-state.js";
import type { PreparedSlackMessage } from "./monitor/message-handler/types.js";
const workspaceInstallation = registerSlackInstallationState("default", "workspace");
type RecordedWireCall = {
method: string;
target?: string;
@@ -141,6 +144,7 @@ vi.mock("./client.js", async (importOriginal) => {
import { dispatchPreparedSlackMessage } from "./monitor/message-handler/dispatch.js";
afterAll(() => {
workspaceInstallation.release();
vi.doUnmock("openclaw/plugin-sdk/channel-inbound");
vi.doUnmock("./client.js");
vi.resetModules();
@@ -0,0 +1,329 @@
import type { OpenKeyedStoreOptions } from "openclaw/plugin-sdk/plugin-state-runtime";
import { createPluginStateKeyedStoreForTests } from "openclaw/plugin-sdk/plugin-state-test-runtime";
import { setReplyPayloadMetadata } from "openclaw/plugin-sdk/reply-payload-testing";
import { resetInboundDedupe } from "openclaw/plugin-sdk/reply-runtime";
import { beforeEach, describe, expect, it } from "vitest";
import {
getSlackTestState,
resetSlackTestState,
runSlackMessageOnce,
} from "./monitor.test-helpers.js";
import { getSlackRuntime, setSlackRuntime } from "./runtime.js";
import {
clearSlackThreadParticipationCache,
hasSlackThreadParticipation,
} from "./sent-thread-cache.js";
const { monitorSlackProvider } = await import("./monitor/provider.js");
const slackTestState = getSlackTestState();
const AUTH_FAILURE = "⚠️ Model login expired on the gateway.";
const BACKEND_FAILURE = "⚠️ Codex app-server is unavailable.";
type SlackFailureTestEvent = {
type: "message";
user: string;
text: string;
ts: string;
channel: string;
channel_type: "im" | "mpim" | "channel";
thread_ts?: string;
parent_user_id?: string;
};
function makeEvent(overrides: Partial<SlackFailureTestEvent>): SlackFailureTestEvent {
return {
type: "message",
user: "U1",
text: "ordinary follow-up",
ts: "100.000001",
channel: "C1",
channel_type: "channel",
...overrides,
};
}
async function dispatchEvent(overrides: Partial<SlackFailureTestEvent>): Promise<void> {
await runSlackMessageOnce(
monitorSlackProvider,
{ event: makeEvent(overrides) },
{ awaitDispatch: true },
);
}
function mockReplySequence(...payloads: Array<{ text: string; isError?: boolean }>): void {
let runIndex = 0;
slackTestState.replyMock.mockImplementation(async (...args: unknown[]) => {
const options = args[1] as { onAgentRunStart?: (runId: string) => void } | undefined;
options?.onAgentRunStart?.(`slack-failure-notice-test-${runIndex}`);
const payload = payloads[Math.min(runIndex, payloads.length - 1)];
runIndex += 1;
return payload;
});
}
function enableAmbientChannelReplies(replyToMode: "all" | "off" = "all"): void {
slackTestState.config = {
messages: { groupChat: { visibleReplies: "automatic" } },
channels: {
slack: {
dm: { enabled: true },
dmPolicy: "open",
allowFrom: ["*"],
groupPolicy: "open",
requireMention: false,
replyToMode,
channels: { C1: { allow: true, requireMention: false } },
},
},
};
}
describe("Slack thread failure notices", () => {
beforeEach(() => {
resetInboundDedupe();
clearSlackThreadParticipationCache();
resetSlackTestState({
messages: { groupChat: { visibleReplies: "automatic" } },
channels: {
slack: {
dm: { enabled: true },
dmPolicy: "open",
allowFrom: ["*"],
groupPolicy: "open",
requireMention: true,
replyToMode: "all",
channels: { C1: { allow: true, requireMention: true } },
},
},
});
});
it("shows an explicit mention's failure and suppresses matching passive follow-ups", async () => {
mockReplySequence({ text: AUTH_FAILURE, isError: true });
await dispatchEvent({ text: "<@bot-user> please help", ts: "100.000000" });
await dispatchEvent({ ts: "100.000001", thread_ts: "100.000000", parent_user_id: "U1" });
await dispatchEvent({ ts: "100.000002", thread_ts: "100.000000", parent_user_id: "U1" });
expect(slackTestState.replyMock).toHaveBeenCalledTimes(3);
expect(slackTestState.sendMock).toHaveBeenCalledTimes(1);
});
it("announces the first failure after an established thread was working", async () => {
mockReplySequence({ text: "Working normally" }, { text: AUTH_FAILURE, isError: true });
await dispatchEvent({ text: "<@bot-user> please help", ts: "101.000000" });
await dispatchEvent({ ts: "101.000001", thread_ts: "101.000000", parent_user_id: "U1" });
await dispatchEvent({ ts: "101.000002", thread_ts: "101.000000", parent_user_id: "U1" });
expect(slackTestState.sendMock).toHaveBeenCalledTimes(2);
expect(slackTestState.sendMock.mock.calls[1]?.[1]).toBe(AUTH_FAILURE);
});
it("announces the first failure for participation restored after a restart", async () => {
const threadTs = "101.100000";
const openKeyedStore = <T>(options: OpenKeyedStoreOptions) =>
createPluginStateKeyedStoreForTests<T>("slack", options);
const persistedStore = openKeyedStore<{ repliedAt: number }>({
namespace: "slack.thread-participation",
maxEntries: 1000,
defaultTtlMs: 24 * 60 * 60 * 1000,
});
await persistedStore.register(
`default:C1:${threadTs}`,
{ repliedAt: Date.now() },
{
ttlMs: 60_000,
},
);
const runtime = getSlackRuntime();
setSlackRuntime({
...runtime,
state: {
...runtime.state,
openKeyedStore,
},
});
expect(hasSlackThreadParticipation("default", "C1", threadTs)).toBe(false);
mockReplySequence({ text: AUTH_FAILURE, isError: true });
await dispatchEvent({ ts: "101.100001", thread_ts: threadTs, parent_user_id: "U1" });
await dispatchEvent({ ts: "101.100002", thread_ts: threadTs, parent_user_id: "U1" });
expect(slackTestState.replyMock).toHaveBeenCalledTimes(2);
expect(slackTestState.sendMock).toHaveBeenCalledTimes(1);
expect(slackTestState.sendMock.mock.calls[0]?.[1]).toBe(AUTH_FAILURE);
});
it("announces a different failure after suppressing repeated copies of the first", async () => {
mockReplySequence(
{ text: "Working normally" },
{ text: AUTH_FAILURE, isError: true },
{ text: AUTH_FAILURE, isError: true },
{ text: BACKEND_FAILURE, isError: true },
);
await dispatchEvent({ text: "<@bot-user> please help", ts: "102.000000" });
await dispatchEvent({ ts: "102.000001", thread_ts: "102.000000", parent_user_id: "U1" });
await dispatchEvent({ ts: "102.000002", thread_ts: "102.000000", parent_user_id: "U1" });
await dispatchEvent({ ts: "102.000003", thread_ts: "102.000000", parent_user_id: "U1" });
expect(slackTestState.sendMock).toHaveBeenCalledTimes(3);
expect(slackTestState.sendMock.mock.calls[2]?.[1]).toBe(BACKEND_FAILURE);
});
it("announces the same failure again after a successful reply", async () => {
mockReplySequence(
{ text: "Working normally" },
{ text: AUTH_FAILURE, isError: true },
{ text: "Recovered" },
{ text: AUTH_FAILURE, isError: true },
);
await dispatchEvent({ text: "<@bot-user> please help", ts: "103.000000" });
await dispatchEvent({ ts: "103.000001", thread_ts: "103.000000", parent_user_id: "U1" });
await dispatchEvent({ ts: "103.000002", thread_ts: "103.000000", parent_user_id: "U1" });
await dispatchEvent({ ts: "103.000003", thread_ts: "103.000000", parent_user_id: "U1" });
expect(slackTestState.sendMock).toHaveBeenCalledTimes(4);
expect(slackTestState.sendMock.mock.calls[3]?.[1]).toBe(AUTH_FAILURE);
});
it("always explains the current failure when the user explicitly mentions the bot", async () => {
mockReplySequence({ text: AUTH_FAILURE, isError: true });
await dispatchEvent({ text: "<@bot-user> please help", ts: "104.000000" });
await dispatchEvent({ ts: "104.000001", thread_ts: "104.000000", parent_user_id: "U1" });
await dispatchEvent({
text: "<@bot-user> are you working now?",
ts: "104.000002",
thread_ts: "104.000000",
parent_user_id: "U1",
});
expect(slackTestState.sendMock).toHaveBeenCalledTimes(2);
});
it.each(["all", "off"] as const)(
"announces one failure for unmentioned channel messages with reply mode %s",
async (replyToMode) => {
enableAmbientChannelReplies(replyToMode);
mockReplySequence({ text: AUTH_FAILURE, isError: true });
await dispatchEvent({ ts: "105.000000" });
await dispatchEvent({ ts: "105.000001" });
expect(slackTestState.replyMock).toHaveBeenCalledTimes(2);
expect(slackTestState.sendMock).toHaveBeenCalledTimes(1);
expect(slackTestState.sendMock.mock.calls[0]?.[1]).toBe(AUTH_FAILURE);
},
);
it("announces a changed failure for unmentioned channel messages", async () => {
enableAmbientChannelReplies();
mockReplySequence(
{ text: AUTH_FAILURE, isError: true },
{ text: AUTH_FAILURE, isError: true },
{ text: BACKEND_FAILURE, isError: true },
);
await dispatchEvent({ ts: "105.010000" });
await dispatchEvent({ ts: "105.010001" });
await dispatchEvent({ ts: "105.010002" });
expect(slackTestState.sendMock).toHaveBeenCalledTimes(2);
expect(slackTestState.sendMock.mock.calls[1]?.[1]).toBe(BACKEND_FAILURE);
});
it("announces an unmentioned channel failure again after a successful reply", async () => {
enableAmbientChannelReplies();
mockReplySequence(
{ text: AUTH_FAILURE, isError: true },
{ text: AUTH_FAILURE, isError: true },
{ text: "Recovered" },
{ text: AUTH_FAILURE, isError: true },
);
await dispatchEvent({ ts: "105.020000" });
await dispatchEvent({ ts: "105.020001" });
await dispatchEvent({ ts: "105.020002" });
await dispatchEvent({ ts: "105.020003" });
expect(slackTestState.sendMock).toHaveBeenCalledTimes(3);
expect(slackTestState.sendMock.mock.calls[2]?.[1]).toBe(AUTH_FAILURE);
});
it("always answers an explicit mention after an unmentioned channel failure", async () => {
enableAmbientChannelReplies();
mockReplySequence({ text: AUTH_FAILURE, isError: true });
await dispatchEvent({ ts: "105.030000" });
await dispatchEvent({ ts: "105.030001" });
await dispatchEvent({ text: "<@bot-user> are you working now?", ts: "105.030002" });
expect(slackTestState.sendMock).toHaveBeenCalledTimes(2);
expect(slackTestState.sendMock.mock.calls[1]?.[1]).toBe(AUTH_FAILURE);
});
it("retries the same thread failure when its first Slack delivery fails", async () => {
mockReplySequence(
{ text: "Working normally" },
{ text: AUTH_FAILURE, isError: true },
{ text: AUTH_FAILURE, isError: true },
);
await dispatchEvent({ text: "<@bot-user> please help", ts: "105.040000" });
slackTestState.sendMock.mockRejectedValueOnce(new Error("Slack delivery unavailable"));
await dispatchEvent({ ts: "105.040001", thread_ts: "105.040000", parent_user_id: "U1" });
await dispatchEvent({ ts: "105.040002", thread_ts: "105.040000", parent_user_id: "U1" });
expect(slackTestState.sendMock).toHaveBeenCalledTimes(3);
expect(slackTestState.sendMock.mock.calls[2]?.[1]).toBe(AUTH_FAILURE);
});
it("does not suppress warnings for non-terminal tool failures", async () => {
const warning = setReplyPayloadMetadata(
{ text: "A tool failed, but the run completed.", isError: true },
{ nonTerminalToolErrorWarning: true },
);
mockReplySequence({ text: "Working normally" }, warning, warning);
await dispatchEvent({ text: "<@bot-user> please help", ts: "105.100000" });
await dispatchEvent({ ts: "105.100001", thread_ts: "105.100000", parent_user_id: "U1" });
await dispatchEvent({ ts: "105.100002", thread_ts: "105.100000", parent_user_id: "U1" });
expect(slackTestState.sendMock).toHaveBeenCalledTimes(3);
});
it("keeps failures visible in direct messages", async () => {
mockReplySequence({ text: AUTH_FAILURE, isError: true });
await dispatchEvent({ channel: "D1", channel_type: "im", ts: "106.000000" });
await dispatchEvent({ channel: "D1", channel_type: "im", ts: "106.000001" });
expect(slackTestState.sendMock).toHaveBeenCalledTimes(2);
});
it("keeps failures visible in Slack group direct messages", async () => {
slackTestState.config = {
messages: { groupChat: { visibleReplies: "automatic" } },
channels: {
slack: {
dm: { enabled: true, groupEnabled: true },
dmPolicy: "open",
allowFrom: ["U1"],
groupPolicy: "open",
replyToMode: "off",
},
},
};
mockReplySequence({ text: AUTH_FAILURE, isError: true });
await dispatchEvent({ channel: "G1", channel_type: "mpim", ts: "107.000000" });
await dispatchEvent({ channel: "G1", channel_type: "mpim", ts: "107.000001" });
expect(slackTestState.replyMock).toHaveBeenCalledTimes(2);
expect(slackTestState.sendMock).toHaveBeenCalledTimes(2);
});
});
@@ -2,15 +2,90 @@ import type { ChannelBotLoopProtectionFacts } from "openclaw/plugin-sdk/channel-
import { resolveChannelProgressDraftConfig } from "openclaw/plugin-sdk/channel-outbound";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { mergePairLoopGuardConfig } from "openclaw/plugin-sdk/pair-loop-guard-runtime";
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
import {
isReplyPayloadNonTerminalToolErrorWarning,
resolveSendableOutboundReplyParts,
} from "openclaw/plugin-sdk/reply-payload";
import type { ReplyDispatchKind, ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { resolveSlackReplyRenderPlan } from "../../reply-blocks.js";
import {
hasSlackThreadFailureNotice,
hasSlackThreadParticipation,
type SlackFailureNotice,
} from "../../sent-thread-cache.js";
import type { SlackMessageEvent } from "../../types.js";
import { readSlackReplyBlocks, resolveSlackThreadTs } from "../replies.js";
import { resolveSlackTimestampMs } from "./timestamp.js";
import type { PreparedSlackMessage } from "./types.js";
export type SlackFailureNoticeState = {
sawTerminalFailurePayload: boolean;
suppressedTerminalFailure?: boolean;
pendingFailureNotice?: SlackFailureNotice;
};
export function filterSlackPassiveFailure(params: {
payload: ReplyPayload;
prepared: PreparedSlackMessage;
statusThreadTs?: string;
hasVisibleReply: boolean;
state: SlackFailureNoticeState;
}): ReplyPayload | null {
const { payload, prepared, state } = params;
if (state.suppressedTerminalFailure) {
return null;
}
if (
payload.isError !== true ||
prepared.ctxPayload.ChatType !== "channel" ||
isReplyPayloadNonTerminalToolErrorWarning(payload)
) {
return payload;
}
state.sawTerminalFailurePayload = true;
if (params.hasVisibleReply) {
return payload;
}
const explicitlyAddressed =
prepared.ctxPayload.ExplicitlyMentionedBot === true ||
prepared.ctxPayload.MentionSource === "explicit_bot" ||
prepared.ctxPayload.MentionSource === "subteam" ||
prepared.ctxPayload.MentionSource === "mention_pattern" ||
prepared.ctxPayload.MentionSource === "command_bypass" ||
(prepared.ctxPayload.CommandTurn?.kind !== undefined &&
prepared.ctxPayload.CommandTurn.kind !== "normal" &&
prepared.ctxPayload.CommandTurn.authorized);
const threadTs = prepared.message.thread_ts;
const noticeThreadTs = threadTs ?? (explicitlyAddressed ? params.statusThreadTs : undefined);
const teamId = prepared.eventScope?.teamId;
const notice: SlackFailureNotice = {
accountId: prepared.account.accountId,
channelId: prepared.message.channel,
...(noticeThreadTs ? { threadTs: noticeThreadTs } : {}),
failureText: payload.text ?? "",
...(teamId ? { teamId } : {}),
};
if (
threadTs &&
!explicitlyAddressed &&
prepared.ctxPayload.MentionSource !== "implicit_thread" &&
!hasSlackThreadParticipation(notice.accountId, notice.channelId, threadTs, teamId)
) {
state.suppressedTerminalFailure = true;
logVerbose("slack: suppressed passive failure before thread participation");
return null;
}
if (!explicitlyAddressed && hasSlackThreadFailureNotice(notice)) {
state.suppressedTerminalFailure = true;
logVerbose("slack: suppressed repeated passive channel or thread failure");
return null;
}
state.pendingFailureNotice = notice;
return payload;
}
function resolveSlackMessageTimestampMs(message: SlackMessageEvent): number | undefined {
const ts = message.event_ts ?? message.ts;
return resolveSlackTimestampMs(ts);
@@ -792,6 +792,7 @@ vi.mock("openclaw/plugin-sdk/reply-history", () => ({
}));
vi.mock("openclaw/plugin-sdk/reply-payload", () => ({
isReplyPayloadNonTerminalToolErrorWarning: () => false,
buildTtsSupplementMediaPayload: (payload: {
text?: string;
mediaUrl?: string;
@@ -878,6 +879,9 @@ vi.mock("../../limits.js", () => ({
}));
vi.mock("../../sent-thread-cache.js", () => ({
clearSlackThreadFailureNotice: () => {},
hasSlackThreadParticipation: () => false,
recordSlackThreadFailureNotice: () => true,
recordSlackThreadParticipation: recordSlackThreadParticipationMock,
}));
@@ -27,14 +27,22 @@ import { normalizeSlackOutboundText } from "../../format.js";
import { SLACK_EDIT_TEXT_MAX_BYTES } from "../../limits.js";
import { emitSlackMessageSentHooks } from "../../message-sent-hook.js";
import { resolveSlackReplyRenderPlan } from "../../reply-blocks.js";
import { recordSlackThreadParticipation } from "../../sent-thread-cache.js";
import {
clearSlackThreadFailureNotice,
recordSlackThreadFailureNotice,
recordSlackThreadParticipation,
} from "../../sent-thread-cache.js";
import {
SlackStreamNotDeliveredError,
stopSlackStream,
type SlackStreamSession,
} from "../../streaming.js";
import { countSlackTextUtf8Bytes } from "../../truncate.js";
import { resolveSlackBotLoopProtection } from "./dispatch-helpers.js";
import {
filterSlackPassiveFailure,
resolveSlackBotLoopProtection,
type SlackFailureNoticeState,
} from "./dispatch-helpers.js";
import { createSlackProgressRuntime } from "./dispatch-progress.js";
import { createSlackDispatchSetup } from "./dispatch-setup.js";
import { createSlackStreamingDeliveryRuntime } from "./dispatch-streaming.js";
@@ -81,6 +89,9 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
},
});
const draftStream = progress.draftStream;
const failureNoticeThreadTs = message.thread_ts;
const failureNoticeTeamId = prepared.eventScope?.teamId;
const failureNoticeState: SlackFailureNoticeState = { sawTerminalFailurePayload: false };
const deliverSlackPayload = async (
payload: ReplyPayload,
@@ -367,6 +378,21 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
ctxPayload: prepared.ctxPayload,
dispatcherOptions: {
...replyPipeline,
// A channel transform marks intentional silence before core can synthesize an empty-reply error.
transformReplyPayload: (payload) => {
const transformed = replyPipeline.transformReplyPayload
? replyPipeline.transformReplyPayload(payload)
: payload;
return transformed
? filterSlackPassiveFailure({
payload: transformed,
prepared,
statusThreadTs,
hasVisibleReply: delivery.observedReplyDelivery || draftPreviewCommitted.value,
state: failureNoticeState,
})
: null;
},
humanDelay: resolveHumanDelayConfig(cfg, route.agentId),
},
delivery: {
@@ -551,6 +577,17 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
const result = turnResult.dispatchResult;
queuedFinal = result.queuedFinal;
counts = result.counts;
if (
!failureNoticeState.sawTerminalFailurePayload &&
prepared.ctxPayload.ChatType === "channel"
) {
clearSlackThreadFailureNotice({
accountId: account.accountId,
channelId: message.channel,
...(failureNoticeThreadTs ? { threadTs: failureNoticeThreadTs } : {}),
...(failureNoticeTeamId ? { teamId: failureNoticeTeamId } : {}),
});
}
}
} catch (err) {
dispatchError = err;
@@ -627,6 +664,10 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
},
);
if (failureNoticeState.pendingFailureNotice && anyReplyDelivered) {
recordSlackThreadFailureNotice(failureNoticeState.pendingFailureNotice);
}
if (statusReactionsEnabled) {
if (dispatchError) {
await statusReactions.setError();
@@ -317,7 +317,9 @@ describe("auth.test boot call", () => {
dmPolicy: "disabled",
groupPolicy: "open",
slashCommand: { enabled: true, name: "openclaw" },
channels: { C12345678: { allow: true, requireMention: true } },
channels: {
"team:TWORKSPACE:channel:C12345678": { allow: true, requireMention: true },
},
},
},
});
@@ -798,7 +800,9 @@ describe("connected identity health", () => {
slack: {
dmPolicy: "disabled",
groupPolicy: "open",
channels: { C12345678: { allow: true, requireMention: true } },
channels: {
"team:TWORKSPACE:channel:C12345678": { allow: true, requireMention: true },
},
},
},
});
@@ -109,7 +109,7 @@ describe("slack socket reconnect loop", () => {
);
});
it("keeps degraded identity health after a recoverable reconnect", async () => {
it("recovers identity health after a recoverable reconnect", async () => {
getSlackClient().auth.test.mockResolvedValueOnce({
app_id: "A1",
user_id: "UUSER",
@@ -152,9 +152,10 @@ describe("slack socket reconnect loop", () => {
expect(setStatus).toHaveBeenCalledWith({
connected: true,
lastConnectedAt: expect.any(Number),
healthState: "degraded",
lastError: expect.stringContaining("without bot_id"),
healthState: "healthy",
lastError: null,
});
expect(getSlackClient().auth.test).toHaveBeenCalledTimes(2);
controller.abort();
await expect(run).resolves.toBeUndefined();
});
@@ -1,12 +1,17 @@
// Slack tests cover outbound payload plugin behavior.
import { installChannelOutboundPayloadContractSuite } from "openclaw/plugin-sdk/channel-contract-testing";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import { describe, expect, it, vi } from "vitest";
import { afterAll, describe, expect, it, vi } from "vitest";
import { createSlackOutboundPayloadHarness, slackOutbound } from "../test-api.js";
import { createSlackSendTestClient } from "./blocks.test-helpers.js";
import { registerSlackInstallationState } from "./installation-identity-state.js";
import type { SlackReplyBlockSegment } from "./reply-blocks.js";
import { sendMessageSlack } from "./send.js";
const workspaceInstallation = registerSlackInstallationState("default", "workspace");
afterAll(() => workspaceInstallation.release());
function createHarness(params: {
payload: ReplyPayload;
sendResults?: Array<{ messageId: string }>;
+5 -1
View File
@@ -1,6 +1,7 @@
// Slack tests cover send.blocks plugin behavior.
import { describe, expect, it, vi } from "vitest";
import { afterAll, describe, expect, it, vi } from "vitest";
import { createSlackSendTestClient } from "./blocks.test-helpers.js";
import { registerSlackInstallationState } from "./installation-identity-state.js";
import {
clearSlackThreadParticipationCache,
hasSlackThreadParticipation,
@@ -9,6 +10,9 @@ import {
const { sendMessageSlack } = await import("./send.js");
const SLACK_TEST_CFG = { channels: { slack: { botToken: "xoxb-test" } } };
const SLACK_TEXT_LIMIT = 8000;
const workspaceInstallation = registerSlackInstallationState("default", "workspace");
afterAll(() => workspaceInstallation.release());
type MockCallSource = { mock: { calls: Array<Array<unknown>> } };
@@ -1,7 +1,12 @@
// Slack tests cover send.identity fallback plugin behavior.
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
import { createSlackSendTestClient } from "./blocks.test-helpers.js";
import { registerSlackInstallationState } from "./installation-identity-state.js";
const workspaceInstallation = registerSlackInstallationState("default", "workspace");
afterAll(() => workspaceInstallation.release());
vi.mock("openclaw/plugin-sdk/runtime-env", () => ({
logVerbose: vi.fn(),
+12 -1
View File
@@ -1,9 +1,20 @@
// Slack tests cover send.unfurl plugin behavior.
import type { WebClient } from "@slack/web-api";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { describe, expect, it, vi } from "vitest";
import { afterAll, describe, expect, it, vi } from "vitest";
import { registerSlackInstallationState } from "./installation-identity-state.js";
import { sendMessageSlack } from "./send.js";
const workspaceInstallations = ["default", "work"].map((accountId) =>
registerSlackInstallationState(accountId, "workspace"),
);
afterAll(() => {
for (const installation of workspaceInstallations) {
installation.release();
}
});
type SlackUnfurlTestClient = WebClient & {
chat: { postMessage: ReturnType<typeof vi.fn> };
conversations: { open: ReturnType<typeof vi.fn> };
+5 -1
View File
@@ -6,8 +6,9 @@ import {
} from "openclaw/plugin-sdk/error-runtime";
import type { LookupFn } from "openclaw/plugin-sdk/ssrf-runtime";
import { withServer } from "openclaw/plugin-sdk/test-env";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import "./blocks.test-helpers.js";
import { registerSlackInstallationState } from "./installation-identity-state.js";
import {
clearSlackThreadParticipationCache,
hasSlackThreadParticipation,
@@ -111,6 +112,9 @@ vi.mock("./runtime-api.js", async () => {
const { sendMessageSlack } = await import("./send.js");
const SLACK_TEST_CFG = { channels: { slack: { botToken: "xoxb-test" } } };
const workspaceInstallation = registerSlackInstallationState("default", "workspace");
afterAll(() => workspaceInstallation.release());
type UploadTestClient = WebClient & {
conversations: { open: ReturnType<typeof vi.fn<(...args: unknown[]) => Promise<unknown>>> };
@@ -3,9 +3,12 @@ import { importFreshModule } from "openclaw/plugin-sdk/test-fixtures";
import { afterEach, describe, expect, it, vi } from "vitest";
import { setSlackRuntime } from "./runtime.js";
import {
clearSlackThreadFailureNotice,
clearSlackThreadParticipationCache,
hasSlackThreadFailureNotice,
hasSlackThreadParticipation,
hasSlackThreadParticipationWithPersistence,
recordSlackThreadFailureNotice,
recordSlackThreadParticipation,
} from "./sent-thread-cache.js";
@@ -37,6 +40,150 @@ describe("slack sent-thread-cache", () => {
expect(hasSlackThreadParticipation("A1", "C123", "1700000000.000001")).toBe(true);
});
it("scopes participation by enterprise workspace without matching unscoped threads", () => {
recordSlackThreadParticipation("A1", "C123", "1700000000.000001", { teamId: "T1" });
expect(hasSlackThreadParticipation("A1", "C123", "1700000000.000001", "T1")).toBe(true);
expect(hasSlackThreadParticipation("A1", "C123", "1700000000.000001", "T2")).toBe(false);
expect(hasSlackThreadParticipation("A1", "C123", "1700000000.000001")).toBe(false);
});
it("announces a repeated thread failure only once until its message changes", () => {
const notice = {
accountId: "A1",
channelId: "C123",
threadTs: "1700000000.000001",
failureText: "Model login expired",
};
expect(recordSlackThreadFailureNotice(notice)).toBe(true);
expect(recordSlackThreadFailureNotice(notice)).toBe(false);
expect(
recordSlackThreadFailureNotice({ ...notice, failureText: "Model login\nexpired" }),
).toBe(false);
expect(
recordSlackThreadFailureNotice({ ...notice, failureText: "App server unavailable" }),
).toBe(true);
expect(recordSlackThreadFailureNotice(notice)).toBe(true);
});
it("checks a failure without marking it delivered", () => {
const notice = {
accountId: "A1",
channelId: "C123",
threadTs: "1700000000.000001",
failureText: "Model login expired",
};
expect(hasSlackThreadFailureNotice(notice)).toBe(false);
expect(hasSlackThreadFailureNotice(notice)).toBe(false);
expect(recordSlackThreadFailureNotice(notice)).toBe(true);
expect(hasSlackThreadFailureNotice(notice)).toBe(true);
expect(hasSlackThreadFailureNotice({ ...notice, failureText: "Model login\nexpired" })).toBe(
true,
);
expect(hasSlackThreadFailureNotice({ ...notice, failureText: "App server unavailable" })).toBe(
false,
);
});
it("deduplicates top-level failures per channel without mixing them with threads", () => {
const channelNotice = {
accountId: "A1",
channelId: "C123",
failureText: "Model login expired",
teamId: "T1",
};
expect(hasSlackThreadFailureNotice(channelNotice)).toBe(false);
expect(recordSlackThreadFailureNotice(channelNotice)).toBe(true);
expect(hasSlackThreadFailureNotice(channelNotice)).toBe(true);
expect(recordSlackThreadFailureNotice(channelNotice)).toBe(false);
expect(hasSlackThreadFailureNotice({ ...channelNotice, channelId: "C456" })).toBe(false);
expect(hasSlackThreadFailureNotice({ ...channelNotice, accountId: "A2" })).toBe(false);
expect(hasSlackThreadFailureNotice({ ...channelNotice, teamId: "T2" })).toBe(false);
expect(hasSlackThreadFailureNotice({ ...channelNotice, threadTs: "1700000000.000001" })).toBe(
false,
);
clearSlackThreadFailureNotice(channelNotice);
expect(hasSlackThreadFailureNotice(channelNotice)).toBe(false);
expect(recordSlackThreadFailureNotice(channelNotice)).toBe(true);
});
it("does not deduplicate failures with empty text", () => {
const notice = {
accountId: "A1",
channelId: "C123",
failureText: " ",
};
expect(hasSlackThreadFailureNotice(notice)).toBe(false);
expect(recordSlackThreadFailureNotice(notice)).toBe(false);
});
it("isolates thread failures by account, channel, thread, and enterprise workspace", () => {
const notice = {
accountId: "A1",
channelId: "C123",
threadTs: "1700000000.000001",
failureText: "Model login expired",
teamId: "T1",
};
expect(recordSlackThreadFailureNotice(notice)).toBe(true);
expect(recordSlackThreadFailureNotice({ ...notice, accountId: "A2" })).toBe(true);
expect(recordSlackThreadFailureNotice({ ...notice, channelId: "C456" })).toBe(true);
expect(recordSlackThreadFailureNotice({ ...notice, threadTs: "1700000000.000002" })).toBe(true);
expect(recordSlackThreadFailureNotice({ ...notice, teamId: "T2" })).toBe(true);
expect(recordSlackThreadFailureNotice(notice)).toBe(false);
});
it("allows the same thread failure again after a successful turn clears its notice", () => {
const notice = {
accountId: "A1",
channelId: "C123",
threadTs: "1700000000.000001",
failureText: "Model login expired",
};
expect(recordSlackThreadFailureNotice(notice)).toBe(true);
clearSlackThreadFailureNotice(notice);
expect(recordSlackThreadFailureNotice(notice)).toBe(true);
});
it("does not treat failure notices as thread participation", () => {
recordSlackThreadFailureNotice({
accountId: "A1",
channelId: "C123",
threadTs: "1700000000.000001",
failureText: "Model login expired",
});
expect(hasSlackThreadParticipation("A1", "C123", "1700000000.000001")).toBe(false);
});
it("bounds failure notices and evicts the oldest thread", () => {
const firstNotice = {
accountId: "A1",
channelId: "C123",
threadTs: "1700000000.000000",
failureText: "Model login expired",
};
expect(recordSlackThreadFailureNotice(firstNotice)).toBe(true);
for (let index = 1; index <= 1000; index += 1) {
expect(
recordSlackThreadFailureNotice({
...firstNotice,
threadTs: `1700000000.${String(index).padStart(6, "0")}`,
}),
).toBe(true);
}
expect(recordSlackThreadFailureNotice(firstNotice)).toBe(true);
});
it("ignores empty accountId, channelId, or threadTs", () => {
recordSlackThreadParticipation("", "C123", "1700000000.000001");
recordSlackThreadParticipation("A1", "", "1700000000.000001");
@@ -69,9 +216,18 @@ describe("slack sent-thread-cache", () => {
try {
cacheA.recordSlackThreadParticipation("A1", "C123", "1700000000.000001");
expect(cacheB.hasSlackThreadParticipation("A1", "C123", "1700000000.000001")).toBe(true);
const failureNotice = {
accountId: "A1",
channelId: "C123",
threadTs: "1700000000.000001",
failureText: "Model login expired",
};
expect(cacheA.recordSlackThreadFailureNotice(failureNotice)).toBe(true);
expect(cacheB.recordSlackThreadFailureNotice(failureNotice)).toBe(false);
cacheB.clearSlackThreadParticipationCache();
expect(cacheA.hasSlackThreadParticipation("A1", "C123", "1700000000.000001")).toBe(false);
expect(cacheA.recordSlackThreadFailureNotice(failureNotice)).toBe(true);
} finally {
cacheA.clearSlackThreadParticipationCache();
}
+67
View File
@@ -1,5 +1,6 @@
// Slack plugin module implements sent thread cache behavior.
import { createPersistentDedupeCache } from "openclaw/plugin-sdk/dedupe-runtime";
import { resolveGlobalSingleton } from "openclaw/plugin-sdk/global-singleton";
import { getOptionalSlackRuntime } from "./runtime.js";
/**
@@ -10,6 +11,7 @@ import { getOptionalSlackRuntime } from "./runtime.js";
const TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
const MAX_ENTRIES = 5000;
const PERSISTENT_MAX_ENTRIES = 1000;
const MAX_FAILURE_NOTICES = 1000;
const PERSISTENT_NAMESPACE = "slack.thread-participation";
type SlackThreadParticipationRecord = {
@@ -22,6 +24,7 @@ type SlackThreadParticipationRecord = {
* auto-reply gating does not diverge between prepare/dispatch call paths.
*/
const SLACK_THREAD_PARTICIPATION_KEY = Symbol.for("openclaw.slackThreadParticipation");
const SLACK_THREAD_FAILURE_NOTICES_KEY = Symbol.for("openclaw.slackThreadFailureNotices");
const threadParticipation = createPersistentDedupeCache<SlackThreadParticipationRecord>({
globalKey: SLACK_THREAD_PARTICIPATION_KEY,
ttlMs: TTL_MS,
@@ -41,6 +44,10 @@ const threadParticipation = createPersistentDedupeCache<SlackThreadParticipation
},
},
});
const threadFailureNotices = resolveGlobalSingleton(
SLACK_THREAD_FAILURE_NOTICES_KEY,
() => new Map<string, string>(),
);
function makeKey(accountId: string, channelId: string, threadTs: string, teamId?: string): string {
return `${accountId}:${teamId ? `${teamId}:` : ""}${channelId}:${threadTs}`;
@@ -88,6 +95,66 @@ export async function hasSlackThreadParticipationWithPersistence(params: {
);
}
export type SlackFailureNotice = {
accountId: string;
channelId: string;
threadTs?: string;
failureText: string;
teamId?: string;
};
function makeFailureNoticeKey(params: Omit<SlackFailureNotice, "failureText">): string {
const scope = params.threadTs ? `thread:${params.threadTs}` : "channel";
return makeKey(params.accountId, params.channelId, scope, params.teamId);
}
/** Returns whether this failure was already delivered in the thread or channel. */
export function hasSlackThreadFailureNotice(params: SlackFailureNotice): boolean {
const { accountId, channelId, failureText } = params;
const fingerprint = failureText.trim().replace(/\s+/gu, " ");
if (!accountId || !channelId || !fingerprint) {
return false;
}
return threadFailureNotices.get(makeFailureNoticeKey(params)) === fingerprint;
}
/** Records a failure after it was delivered in the thread or channel. */
export function recordSlackThreadFailureNotice(params: SlackFailureNotice): boolean {
const { accountId, channelId, failureText } = params;
const fingerprint = failureText.trim().replace(/\s+/gu, " ");
if (!accountId || !channelId || !fingerprint) {
return false;
}
const key = makeFailureNoticeKey(params);
if (threadFailureNotices.get(key) === fingerprint) {
return false;
}
threadFailureNotices.delete(key);
threadFailureNotices.set(key, fingerprint);
if (threadFailureNotices.size > MAX_FAILURE_NOTICES) {
const oldestKey = threadFailureNotices.keys().next().value;
if (oldestKey !== undefined) {
threadFailureNotices.delete(oldestKey);
}
}
return true;
}
/** Clears a thread or channel outage notice after a healthy model turn completes. */
export function clearSlackThreadFailureNotice(params: {
accountId: string;
channelId: string;
threadTs?: string;
teamId?: string;
}): void {
const { accountId, channelId } = params;
if (!accountId || !channelId) {
return;
}
threadFailureNotices.delete(makeFailureNoticeKey(params));
}
export function clearSlackThreadParticipationCache(): void {
threadParticipation.clearForTest();
threadFailureNotices.clear();
}