fix(slack): ignore no_reaction in remove helpers (#76320)

Summary:
- The PR adds a Slack `no_reaction` guard to `removeSlackReaction`, routes `removeOwnSlackReactions` through that helper, adds reaction tests, and records the fix in the changelog.
- Reproducibility: yes. A mocked Slack `WebClient` whose `reactions.remove` rejects with `{ data: { error: "no ... es current-main propagation in `removeSlackReaction` and the list/remove race in `removeOwnSlackReactions`.

ClawSweeper fixups:
- Included follow-up commit: fix(clawsweeper): address review for automerge-openclaw-openclaw-7630…

Validation:
- ClawSweeper review passed for head 1211ce06d3.
- Required merge gates passed before the squash merge.

Prepared head SHA: 1211ce06d3
Review: https://github.com/openclaw/openclaw/pull/76320#issuecomment-4364991477

Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: HollyChou <128659251+Hollychou924@users.noreply.github.com>
This commit is contained in:
clawsweeper[bot]
2026-05-03 00:05:23 +00:00
committed by GitHub
parent 4bc6b9d7cf
commit cf46dc54ff
3 changed files with 104 additions and 10 deletions

View File

@@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Gateway/responses: emit every client tool call from `/v1/responses` JSON and SSE responses when the agent invokes multiple client tools in a single turn, so multi-tool plans, graph orchestration calls, and similar batched flows no longer drop every call but the last. Fixes #52288. Thanks @CharZhou and @bonelli.
- Slack/reactions: treat missing no_reaction remove responses as idempotent success and route own-reaction cleanup through the remove helper, so concurrent cleanup no longer surfaces Slack race errors. Fixes #50733. (#76304) Thanks @martingarramon and @Hollychou924.
- Control UI/Gateway: avoid full session-list reloads for locally applied message-phase session updates, carry known session keys through transcript-file update events, and defer media provider listing when explicit generation model config is present. Refs #76236, #76203, #76188, #76107, and #76166. Thanks @BunsDev.
- Install/update: prune the obsolete `plugin-runtime-deps` state directory during packaged postinstall so upgrades from pre-2026.5.2 releases reclaim old bundled-plugin dependency caches without touching external plugin installs.
- Auto-reply/queue: treat reset-triggered `/new` and `/reset` turns as interrupt runs across active-run queue handling, so steer/followup modes cannot delay a fresh session behind existing work. Fixes #74093. (#74144) Thanks @ruji9527 and @yelog.

View File

@@ -1,15 +1,29 @@
import type { WebClient } from "@slack/web-api";
import { describe, expect, it, vi } from "vitest";
import { reactSlackMessage } from "./actions.js";
import { reactSlackMessage, removeOwnSlackReactions, removeSlackReaction } from "./actions.js";
function createClient() {
return {
auth: {
test: vi.fn(async () => ({ user_id: "UBOT" })),
},
reactions: {
add: vi.fn(async () => ({})),
get: vi.fn(async () => ({
message: {
reactions: [],
},
})),
remove: vi.fn(async () => ({})),
},
} as unknown as WebClient & {
auth: {
test: ReturnType<typeof vi.fn>;
};
reactions: {
add: ReturnType<typeof vi.fn>;
get: ReturnType<typeof vi.fn>;
remove: ReturnType<typeof vi.fn>;
};
};
}
@@ -58,3 +72,76 @@ describe("reactSlackMessage", () => {
});
});
});
describe("removeSlackReaction", () => {
it("treats no_reaction as idempotent success", async () => {
const client = createClient();
client.reactions.remove.mockRejectedValueOnce(slackPlatformError("no_reaction"));
await expect(
removeSlackReaction("C1", "123.456", ":white_check_mark:", {
client,
token: "xoxb-test",
}),
).resolves.toBeUndefined();
expect(client.reactions.remove).toHaveBeenCalledWith({
channel: "C1",
timestamp: "123.456",
name: "white_check_mark",
});
});
it("propagates unrelated reaction remove errors", async () => {
const client = createClient();
client.reactions.remove.mockRejectedValueOnce(slackPlatformError("invalid_name"));
await expect(
removeSlackReaction("C1", "123.456", "not-an-emoji", {
client,
token: "xoxb-test",
}),
).rejects.toMatchObject({
data: {
error: "invalid_name",
},
});
});
});
describe("removeOwnSlackReactions", () => {
it("removes own reactions through the idempotent remove helper", async () => {
const client = createClient();
client.reactions.get.mockResolvedValueOnce({
message: {
reactions: [
{ name: "thumbsup", users: ["UBOT", "U1"] },
{ name: "eyes", users: ["U2", "UBOT"] },
{ name: "wave", users: ["U2"] },
],
},
});
client.reactions.remove
.mockRejectedValueOnce(slackPlatformError("no_reaction"))
.mockResolvedValueOnce({});
await expect(
removeOwnSlackReactions("C1", "123.456", {
client,
token: "xoxb-test",
}),
).resolves.toEqual(["thumbsup", "eyes"]);
expect(client.reactions.remove).toHaveBeenCalledTimes(2);
expect(client.reactions.remove).toHaveBeenNthCalledWith(1, {
channel: "C1",
timestamp: "123.456",
name: "thumbsup",
});
expect(client.reactions.remove).toHaveBeenNthCalledWith(2, {
channel: "C1",
timestamp: "123.456",
name: "eyes",
});
});
});

View File

@@ -132,11 +132,18 @@ export async function removeSlackReaction(
opts: SlackActionClientOpts = {},
) {
const client = await getClient(opts, "write");
await client.reactions.remove({
channel: channelId,
timestamp: messageId,
name: normalizeEmoji(emoji),
});
try {
await client.reactions.remove({
channel: channelId,
timestamp: messageId,
name: normalizeEmoji(emoji),
});
} catch (err) {
if (hasSlackPlatformError(err, "no_reaction")) {
return;
}
throw err;
}
}
export async function removeOwnSlackReactions(
@@ -163,10 +170,9 @@ export async function removeOwnSlackReactions(
}
await Promise.all(
Array.from(toRemove, (name) =>
client.reactions.remove({
channel: channelId,
timestamp: messageId,
name,
removeSlackReaction(channelId, messageId, name, {
...opts,
client,
}),
),
);