mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 03:45:46 -06:00
[BACKPORT] fix(slack): stop repeated outage notices in active threads (#122832)
This commit is contained in:
@@ -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());
|
||||
|
||||
@@ -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 }>;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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> };
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user