Skip to content

Commit 6470a23

Browse files
committed
fix(slack): ignore duplicate reaction adds
1 parent b54c642 commit 6470a23

4 files changed

Lines changed: 89 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ Docs: https://docs.openclaw.ai
2424
### Fixes
2525

2626
- Models/UI: hide unauthenticated providers from the default Web chat, `/models`, and model setup pickers while keeping explicit full-catalog browse paths through `view: "all"`, `/models <provider> all`, and `models list --all`. Fixes #74423. Thanks @guarismo and @SymbolStar.
27+
- Slack/reactions: treat duplicate `already_reacted` responses as idempotent success so repeated agent reaction adds no longer surface as tool failures. Fixes #69005. Thanks @shipitsteven and @martingarramon.
2728
- Slack/tools: expose `fileId` in the shared message tool schema so `download-file` can receive Slack attachment IDs from inbound placeholders. Fixes #45574. Thanks @chadvegas.
2829
- Exec: reject invalid per-call `host` values instead of silently falling back to the default target, so hostname-like values fail before commands run. Fixes #74426. Thanks @scr00ge-00 and @vyctorbrzezowski.
2930
- Google/Gemini: send non-empty placeholder content when a Gemini run is triggered with empty or filtered user content, avoiding `contents is not specified` API errors. Thanks @CaoYuhaoCarl.

extensions/slack/src/action-runtime.test.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ describe("handleSlackAction", () => {
115115
{ name: "raw channel id", channelId: "C1" },
116116
{ name: "channel: prefixed id", channelId: "channel:C1" },
117117
])("adds reactions for $name", async ({ channelId }) => {
118-
await handleSlackAction(
118+
const result = await handleSlackAction(
119119
{
120120
action: "react",
121121
channelId,
@@ -130,6 +130,10 @@ describe("handleSlackAction", () => {
130130
"✅",
131131
expect.objectContaining({ cfg: expect.any(Object) }),
132132
);
133+
expect(JSON.parse((result.content?.[0] as { type: "text"; text: string }).text)).toEqual({
134+
ok: true,
135+
added: "✅",
136+
});
133137
});
134138

135139
it("removes reactions on empty emoji", async () => {
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import type { WebClient } from "@slack/web-api";
2+
import { describe, expect, it, vi } from "vitest";
3+
import { reactSlackMessage } from "./actions.js";
4+
5+
function createClient() {
6+
return {
7+
reactions: {
8+
add: vi.fn(async () => ({})),
9+
},
10+
} as unknown as WebClient & {
11+
reactions: {
12+
add: ReturnType<typeof vi.fn>;
13+
};
14+
};
15+
}
16+
17+
function slackPlatformError(error: string) {
18+
return Object.assign(new Error(`An API error occurred: ${error}`), {
19+
data: {
20+
ok: false,
21+
error,
22+
},
23+
});
24+
}
25+
26+
describe("reactSlackMessage", () => {
27+
it("treats already_reacted as idempotent success", async () => {
28+
const client = createClient();
29+
client.reactions.add.mockRejectedValueOnce(slackPlatformError("already_reacted"));
30+
31+
await expect(
32+
reactSlackMessage("C1", "123.456", ":white_check_mark:", {
33+
client,
34+
token: "xoxb-test",
35+
}),
36+
).resolves.toBeUndefined();
37+
38+
expect(client.reactions.add).toHaveBeenCalledWith({
39+
channel: "C1",
40+
timestamp: "123.456",
41+
name: "white_check_mark",
42+
});
43+
});
44+
45+
it("propagates unrelated reaction add errors", async () => {
46+
const client = createClient();
47+
client.reactions.add.mockRejectedValueOnce(slackPlatformError("invalid_name"));
48+
49+
await expect(
50+
reactSlackMessage("C1", "123.456", "not-an-emoji", {
51+
client,
52+
token: "xoxb-test",
53+
}),
54+
).rejects.toMatchObject({
55+
data: {
56+
error: "invalid_name",
57+
},
58+
});
59+
});
60+
});

extensions/slack/src/actions.ts

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,17 @@ function normalizeEmoji(raw: string) {
7777
return trimmed.replace(/^:+|:+$/g, "");
7878
}
7979

80+
function hasSlackPlatformError(err: unknown, code: string): boolean {
81+
if (!err || typeof err !== "object") {
82+
return false;
83+
}
84+
const data = (err as { data?: unknown }).data;
85+
if (!data || typeof data !== "object") {
86+
return false;
87+
}
88+
return (data as { error?: unknown }).error === code;
89+
}
90+
8091
async function getClient(opts: SlackActionClientOpts = {}, mode: "read" | "write" = "read") {
8192
if (opts.client) {
8293
return opts.client;
@@ -100,11 +111,18 @@ export async function reactSlackMessage(
100111
opts: SlackActionClientOpts = {},
101112
) {
102113
const client = await getClient(opts, "write");
103-
await client.reactions.add({
104-
channel: channelId,
105-
timestamp: messageId,
106-
name: normalizeEmoji(emoji),
107-
});
114+
try {
115+
await client.reactions.add({
116+
channel: channelId,
117+
timestamp: messageId,
118+
name: normalizeEmoji(emoji),
119+
});
120+
} catch (err) {
121+
if (hasSlackPlatformError(err, "already_reacted")) {
122+
return;
123+
}
124+
throw err;
125+
}
108126
}
109127

110128
export async function removeSlackReaction(

0 commit comments

Comments
 (0)