Skip to content

Commit 8241145

Browse files
feat(feishu): add reaction event support (created/deleted) (#16716) thanks @schumilin
Verified: - pnpm install --frozen-lockfile - pnpm build - pnpm check - pnpm test:macmini Co-authored-by: schumilin <[email protected]> Co-authored-by: Tak Hoffman <[email protected]>
1 parent afa7ac1 commit 8241145

File tree

4 files changed

+348
-0
lines changed

4 files changed

+348
-0
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai
1212
- Android/Gateway capability refresh: add live Android capability integration coverage and node canvas capability refresh wiring, plus runtime hardening for A2UI readiness retries, scoped canvas URL normalization, debug diagnostics JSON, and JavaScript MIME delivery. (#28388) Thanks @obviyus.
1313
- Feishu/Doc permissions: support optional owner permission grant fields on `feishu_doc` create and report permission metadata only when the grant call succeeds, with regression coverage for success/failure/omitted-owner paths. (#28295) Thanks @zhoulongchao77.
1414
- Feishu/Docx tables + uploads: add `feishu_doc` actions for Docx table creation/cell writing (`create_table`, `write_table_cells`, `create_table_with_values`) and image/file uploads (`upload_image`, `upload_file`) with stricter create/upload error handling for missing `document_id` and placeholder cleanup failures. (#20304) Thanks @xuhao1.
15+
- Feishu/Reactions: add inbound `im.message.reaction.created_v1` handling, route verified reactions through synthetic inbound turns, and harden verification with timeout + fail-closed filtering so non-bot or unverified reactions are dropped. (#16716) Thanks @schumilin.
1516
- Memory/LanceDB: support custom OpenAI `baseUrl` and embedding dimensions for LanceDB memory. (#17874) Thanks @rish2jain and @vincentkoc.
1617

1718
### Fixes
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
import type { ClawdbotConfig } from "openclaw/plugin-sdk";
2+
import { describe, expect, it, vi } from "vitest";
3+
import { resolveReactionSyntheticEvent, type FeishuReactionCreatedEvent } from "./monitor.js";
4+
5+
const cfg = {} as ClawdbotConfig;
6+
7+
function makeReactionEvent(
8+
overrides: Partial<FeishuReactionCreatedEvent> = {},
9+
): FeishuReactionCreatedEvent {
10+
return {
11+
message_id: "om_msg1",
12+
reaction_type: { emoji_type: "THUMBSUP" },
13+
operator_type: "user",
14+
user_id: { open_id: "ou_user1" },
15+
...overrides,
16+
};
17+
}
18+
19+
describe("resolveReactionSyntheticEvent", () => {
20+
it("filters app self-reactions", async () => {
21+
const event = makeReactionEvent({ operator_type: "app" });
22+
const result = await resolveReactionSyntheticEvent({
23+
cfg,
24+
accountId: "default",
25+
event,
26+
botOpenId: "ou_bot",
27+
});
28+
expect(result).toBeNull();
29+
});
30+
31+
it("filters Typing reactions", async () => {
32+
const event = makeReactionEvent({ reaction_type: { emoji_type: "Typing" } });
33+
const result = await resolveReactionSyntheticEvent({
34+
cfg,
35+
accountId: "default",
36+
event,
37+
botOpenId: "ou_bot",
38+
});
39+
expect(result).toBeNull();
40+
});
41+
42+
it("fails closed when bot open_id is unavailable", async () => {
43+
const event = makeReactionEvent();
44+
const result = await resolveReactionSyntheticEvent({
45+
cfg,
46+
accountId: "default",
47+
event,
48+
});
49+
expect(result).toBeNull();
50+
});
51+
52+
it("filters reactions on non-bot messages", async () => {
53+
const event = makeReactionEvent();
54+
const result = await resolveReactionSyntheticEvent({
55+
cfg,
56+
accountId: "default",
57+
event,
58+
botOpenId: "ou_bot",
59+
fetchMessage: async () => ({
60+
messageId: "om_msg1",
61+
chatId: "oc_group",
62+
senderOpenId: "ou_other",
63+
senderType: "user",
64+
content: "hello",
65+
contentType: "text",
66+
}),
67+
});
68+
expect(result).toBeNull();
69+
});
70+
71+
it("drops unverified reactions when sender verification times out", async () => {
72+
const event = makeReactionEvent();
73+
const result = await resolveReactionSyntheticEvent({
74+
cfg,
75+
accountId: "default",
76+
event,
77+
botOpenId: "ou_bot",
78+
verificationTimeoutMs: 1,
79+
fetchMessage: async () =>
80+
await new Promise<never>(() => {
81+
// Never resolves
82+
}),
83+
});
84+
expect(result).toBeNull();
85+
});
86+
87+
it("uses event chat context when provided", async () => {
88+
const event = makeReactionEvent({
89+
chat_id: "oc_group_from_event",
90+
chat_type: "group",
91+
});
92+
const result = await resolveReactionSyntheticEvent({
93+
cfg,
94+
accountId: "default",
95+
event,
96+
botOpenId: "ou_bot",
97+
fetchMessage: async () => ({
98+
messageId: "om_msg1",
99+
chatId: "oc_group_from_lookup",
100+
senderOpenId: "ou_bot",
101+
content: "hello",
102+
contentType: "text",
103+
}),
104+
uuid: () => "fixed-uuid",
105+
});
106+
107+
expect(result).toEqual({
108+
sender: {
109+
sender_id: { open_id: "ou_user1" },
110+
sender_type: "user",
111+
},
112+
message: {
113+
message_id: "om_msg1:reaction:THUMBSUP:fixed-uuid",
114+
chat_id: "oc_group_from_event",
115+
chat_type: "group",
116+
message_type: "text",
117+
content: JSON.stringify({
118+
text: "[reacted with THUMBSUP to message om_msg1]",
119+
}),
120+
},
121+
});
122+
});
123+
124+
it("falls back to reacted message chat_id when event chat_id is absent", async () => {
125+
const event = makeReactionEvent();
126+
const result = await resolveReactionSyntheticEvent({
127+
cfg,
128+
accountId: "default",
129+
event,
130+
botOpenId: "ou_bot",
131+
fetchMessage: async () => ({
132+
messageId: "om_msg1",
133+
chatId: "oc_group_from_lookup",
134+
senderOpenId: "ou_bot",
135+
content: "hello",
136+
contentType: "text",
137+
}),
138+
uuid: () => "fixed-uuid",
139+
});
140+
141+
expect(result?.message.chat_id).toBe("oc_group_from_lookup");
142+
expect(result?.message.chat_type).toBe("p2p");
143+
});
144+
145+
it("falls back to sender p2p chat when lookup returns empty chat_id", async () => {
146+
const event = makeReactionEvent();
147+
const result = await resolveReactionSyntheticEvent({
148+
cfg,
149+
accountId: "default",
150+
event,
151+
botOpenId: "ou_bot",
152+
fetchMessage: async () => ({
153+
messageId: "om_msg1",
154+
chatId: "",
155+
senderOpenId: "ou_bot",
156+
content: "hello",
157+
contentType: "text",
158+
}),
159+
uuid: () => "fixed-uuid",
160+
});
161+
162+
expect(result?.message.chat_id).toBe("p2p:ou_user1");
163+
expect(result?.message.chat_type).toBe("p2p");
164+
});
165+
166+
it("logs and drops reactions when lookup throws", async () => {
167+
const log = vi.fn();
168+
const event = makeReactionEvent();
169+
const result = await resolveReactionSyntheticEvent({
170+
cfg,
171+
accountId: "acct1",
172+
event,
173+
botOpenId: "ou_bot",
174+
fetchMessage: async () => {
175+
throw new Error("boom");
176+
},
177+
logger: log,
178+
});
179+
expect(result).toBeNull();
180+
expect(log).toHaveBeenCalledWith(
181+
expect.stringContaining("ignoring reaction on non-bot/unverified message om_msg1"),
182+
);
183+
});
184+
});

extensions/feishu/src/monitor.ts

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import * as crypto from "crypto";
12
import * as http from "http";
23
import * as Lark from "@larksuiteoapi/node-sdk";
34
import {
@@ -10,6 +11,7 @@ import { resolveFeishuAccount, listEnabledFeishuAccounts } from "./accounts.js";
1011
import { handleFeishuMessage, type FeishuMessageEvent, type FeishuBotAddedEvent } from "./bot.js";
1112
import { createFeishuWSClient, createEventDispatcher } from "./client.js";
1213
import { probeFeishu } from "./probe.js";
14+
import { getMessageFeishu } from "./send.js";
1315
import type { ResolvedFeishuAccount } from "./types.js";
1416

1517
export type MonitorFeishuOpts = {
@@ -29,6 +31,29 @@ const FEISHU_WEBHOOK_RATE_LIMIT_WINDOW_MS = 60_000;
2931
const FEISHU_WEBHOOK_RATE_LIMIT_MAX_REQUESTS = 120;
3032
const FEISHU_WEBHOOK_RATE_LIMIT_MAX_TRACKED_KEYS = 4_096;
3133
const FEISHU_WEBHOOK_COUNTER_LOG_EVERY = 25;
34+
const FEISHU_REACTION_VERIFY_TIMEOUT_MS = 1_500;
35+
36+
export type FeishuReactionCreatedEvent = {
37+
message_id: string;
38+
chat_id?: string;
39+
chat_type?: "p2p" | "group";
40+
reaction_type?: { emoji_type?: string };
41+
operator_type?: string;
42+
user_id?: { open_id?: string };
43+
action_time?: string;
44+
};
45+
46+
type ResolveReactionSyntheticEventParams = {
47+
cfg: ClawdbotConfig;
48+
accountId: string;
49+
event: FeishuReactionCreatedEvent;
50+
botOpenId?: string;
51+
fetchMessage?: typeof getMessageFeishu;
52+
verificationTimeoutMs?: number;
53+
logger?: (message: string) => void;
54+
uuid?: () => string;
55+
};
56+
3257
const feishuWebhookRateLimits = new Map<string, { count: number; windowStartMs: number }>();
3358
const feishuWebhookStatusCounters = new Map<string, number>();
3459
let lastWebhookRateLimitCleanupMs = 0;
@@ -115,6 +140,95 @@ function recordWebhookStatus(
115140
}
116141
}
117142

143+
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T | null> {
144+
let timeoutId: NodeJS.Timeout | undefined;
145+
try {
146+
return await Promise.race<T | null>([
147+
promise,
148+
new Promise<null>((resolve) => {
149+
timeoutId = setTimeout(() => resolve(null), timeoutMs);
150+
}),
151+
]);
152+
} finally {
153+
if (timeoutId) {
154+
clearTimeout(timeoutId);
155+
}
156+
}
157+
}
158+
159+
export async function resolveReactionSyntheticEvent(
160+
params: ResolveReactionSyntheticEventParams,
161+
): Promise<FeishuMessageEvent | null> {
162+
const {
163+
cfg,
164+
accountId,
165+
event,
166+
botOpenId,
167+
fetchMessage = getMessageFeishu,
168+
verificationTimeoutMs = FEISHU_REACTION_VERIFY_TIMEOUT_MS,
169+
logger,
170+
uuid = () => crypto.randomUUID(),
171+
} = params;
172+
173+
const emoji = event.reaction_type?.emoji_type;
174+
const messageId = event.message_id;
175+
const senderId = event.user_id?.open_id;
176+
if (!emoji || !messageId || !senderId) {
177+
return null;
178+
}
179+
180+
// Skip bot self-reactions
181+
if (event.operator_type === "app" || senderId === botOpenId) {
182+
return null;
183+
}
184+
185+
// Skip typing indicator emoji
186+
if (emoji === "Typing") {
187+
return null;
188+
}
189+
190+
// Fail closed if bot identity cannot be resolved; otherwise reactions on any
191+
// message can leak into the agent.
192+
if (!botOpenId) {
193+
logger?.(
194+
`feishu[${accountId}]: bot open_id unavailable, skipping reaction ${emoji} on ${messageId}`,
195+
);
196+
return null;
197+
}
198+
199+
const reactedMsg = await withTimeout(
200+
fetchMessage({ cfg, messageId, accountId }),
201+
verificationTimeoutMs,
202+
).catch(() => null);
203+
const isBotMessage = reactedMsg?.senderType === "app" || reactedMsg?.senderOpenId === botOpenId;
204+
if (!reactedMsg || !isBotMessage) {
205+
logger?.(
206+
`feishu[${accountId}]: ignoring reaction on non-bot/unverified message ${messageId} ` +
207+
`(sender: ${reactedMsg?.senderOpenId ?? "unknown"})`,
208+
);
209+
return null;
210+
}
211+
212+
const syntheticChatIdRaw = event.chat_id ?? reactedMsg.chatId;
213+
const syntheticChatId = syntheticChatIdRaw?.trim() ? syntheticChatIdRaw : `p2p:${senderId}`;
214+
const syntheticChatType: "p2p" | "group" = event.chat_type ?? "p2p";
215+
return {
216+
sender: {
217+
sender_id: { open_id: senderId },
218+
sender_type: "user",
219+
},
220+
message: {
221+
message_id: `${messageId}:reaction:${emoji}:${uuid()}`,
222+
chat_id: syntheticChatId,
223+
chat_type: syntheticChatType,
224+
message_type: "text",
225+
content: JSON.stringify({
226+
text: `[reacted with ${emoji} to message ${messageId}]`,
227+
}),
228+
},
229+
};
230+
}
231+
118232
async function fetchBotOpenId(account: ResolvedFeishuAccount): Promise<string | undefined> {
119233
try {
120234
const result = await probeFeishu(account);
@@ -185,6 +299,53 @@ function registerEventHandlers(
185299
error(`feishu[${accountId}]: error handling bot removed event: ${String(err)}`);
186300
}
187301
},
302+
"im.message.reaction.created_v1": async (data) => {
303+
const processReaction = async () => {
304+
const event = data as FeishuReactionCreatedEvent;
305+
const myBotId = botOpenIds.get(accountId);
306+
const syntheticEvent = await resolveReactionSyntheticEvent({
307+
cfg,
308+
accountId,
309+
event,
310+
botOpenId: myBotId,
311+
logger: log,
312+
});
313+
if (!syntheticEvent) {
314+
return;
315+
}
316+
const promise = handleFeishuMessage({
317+
cfg,
318+
event: syntheticEvent,
319+
botOpenId: myBotId,
320+
runtime,
321+
chatHistories,
322+
accountId,
323+
});
324+
if (fireAndForget) {
325+
promise.catch((err) => {
326+
error(`feishu[${accountId}]: error handling reaction: ${String(err)}`);
327+
});
328+
return;
329+
}
330+
await promise;
331+
};
332+
333+
if (fireAndForget) {
334+
void processReaction().catch((err) => {
335+
error(`feishu[${accountId}]: error handling reaction event: ${String(err)}`);
336+
});
337+
return;
338+
}
339+
340+
try {
341+
await processReaction();
342+
} catch (err) {
343+
error(`feishu[${accountId}]: error handling reaction event: ${String(err)}`);
344+
}
345+
},
346+
"im.message.reaction.deleted_v1": async () => {
347+
// Ignore reaction removals
348+
},
188349
});
189350
}
190351

0 commit comments

Comments
 (0)