From 8241145ada402f1e4914a38dab19bb3c397a991e Mon Sep 17 00:00:00 2001 From: Lin Z Date: Sat, 28 Feb 2026 10:54:24 +0800 Subject: [PATCH] feat(feishu): add reaction event support (created/deleted) (openclaw#16716) thanks @schumilin Verified: - pnpm install --frozen-lockfile - pnpm build - pnpm check - pnpm test:macmini Co-authored-by: schumilin <2003498+schumilin@users.noreply.github.com> Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> --- CHANGELOG.md | 1 + .../feishu/src/monitor.reaction.test.ts | 184 ++++++++++++++++++ extensions/feishu/src/monitor.ts | 161 +++++++++++++++ extensions/feishu/src/send.ts | 2 + 4 files changed, 348 insertions(+) create mode 100644 extensions/feishu/src/monitor.reaction.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ad979b8e80..a400c03d690 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai - 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. - 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. - 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. +- 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. - Memory/LanceDB: support custom OpenAI `baseUrl` and embedding dimensions for LanceDB memory. (#17874) Thanks @rish2jain and @vincentkoc. ### Fixes diff --git a/extensions/feishu/src/monitor.reaction.test.ts b/extensions/feishu/src/monitor.reaction.test.ts new file mode 100644 index 00000000000..d4d6914621b --- /dev/null +++ b/extensions/feishu/src/monitor.reaction.test.ts @@ -0,0 +1,184 @@ +import type { ClawdbotConfig } from "openclaw/plugin-sdk"; +import { describe, expect, it, vi } from "vitest"; +import { resolveReactionSyntheticEvent, type FeishuReactionCreatedEvent } from "./monitor.js"; + +const cfg = {} as ClawdbotConfig; + +function makeReactionEvent( + overrides: Partial = {}, +): FeishuReactionCreatedEvent { + return { + message_id: "om_msg1", + reaction_type: { emoji_type: "THUMBSUP" }, + operator_type: "user", + user_id: { open_id: "ou_user1" }, + ...overrides, + }; +} + +describe("resolveReactionSyntheticEvent", () => { + it("filters app self-reactions", async () => { + const event = makeReactionEvent({ operator_type: "app" }); + const result = await resolveReactionSyntheticEvent({ + cfg, + accountId: "default", + event, + botOpenId: "ou_bot", + }); + expect(result).toBeNull(); + }); + + it("filters Typing reactions", async () => { + const event = makeReactionEvent({ reaction_type: { emoji_type: "Typing" } }); + const result = await resolveReactionSyntheticEvent({ + cfg, + accountId: "default", + event, + botOpenId: "ou_bot", + }); + expect(result).toBeNull(); + }); + + it("fails closed when bot open_id is unavailable", async () => { + const event = makeReactionEvent(); + const result = await resolveReactionSyntheticEvent({ + cfg, + accountId: "default", + event, + }); + expect(result).toBeNull(); + }); + + it("filters reactions on non-bot messages", async () => { + const event = makeReactionEvent(); + const result = await resolveReactionSyntheticEvent({ + cfg, + accountId: "default", + event, + botOpenId: "ou_bot", + fetchMessage: async () => ({ + messageId: "om_msg1", + chatId: "oc_group", + senderOpenId: "ou_other", + senderType: "user", + content: "hello", + contentType: "text", + }), + }); + expect(result).toBeNull(); + }); + + it("drops unverified reactions when sender verification times out", async () => { + const event = makeReactionEvent(); + const result = await resolveReactionSyntheticEvent({ + cfg, + accountId: "default", + event, + botOpenId: "ou_bot", + verificationTimeoutMs: 1, + fetchMessage: async () => + await new Promise(() => { + // Never resolves + }), + }); + expect(result).toBeNull(); + }); + + it("uses event chat context when provided", async () => { + const event = makeReactionEvent({ + chat_id: "oc_group_from_event", + chat_type: "group", + }); + const result = await resolveReactionSyntheticEvent({ + cfg, + accountId: "default", + event, + botOpenId: "ou_bot", + fetchMessage: async () => ({ + messageId: "om_msg1", + chatId: "oc_group_from_lookup", + senderOpenId: "ou_bot", + content: "hello", + contentType: "text", + }), + uuid: () => "fixed-uuid", + }); + + expect(result).toEqual({ + sender: { + sender_id: { open_id: "ou_user1" }, + sender_type: "user", + }, + message: { + message_id: "om_msg1:reaction:THUMBSUP:fixed-uuid", + chat_id: "oc_group_from_event", + chat_type: "group", + message_type: "text", + content: JSON.stringify({ + text: "[reacted with THUMBSUP to message om_msg1]", + }), + }, + }); + }); + + it("falls back to reacted message chat_id when event chat_id is absent", async () => { + const event = makeReactionEvent(); + const result = await resolveReactionSyntheticEvent({ + cfg, + accountId: "default", + event, + botOpenId: "ou_bot", + fetchMessage: async () => ({ + messageId: "om_msg1", + chatId: "oc_group_from_lookup", + senderOpenId: "ou_bot", + content: "hello", + contentType: "text", + }), + uuid: () => "fixed-uuid", + }); + + expect(result?.message.chat_id).toBe("oc_group_from_lookup"); + expect(result?.message.chat_type).toBe("p2p"); + }); + + it("falls back to sender p2p chat when lookup returns empty chat_id", async () => { + const event = makeReactionEvent(); + const result = await resolveReactionSyntheticEvent({ + cfg, + accountId: "default", + event, + botOpenId: "ou_bot", + fetchMessage: async () => ({ + messageId: "om_msg1", + chatId: "", + senderOpenId: "ou_bot", + content: "hello", + contentType: "text", + }), + uuid: () => "fixed-uuid", + }); + + expect(result?.message.chat_id).toBe("p2p:ou_user1"); + expect(result?.message.chat_type).toBe("p2p"); + }); + + it("logs and drops reactions when lookup throws", async () => { + const log = vi.fn(); + const event = makeReactionEvent(); + const result = await resolveReactionSyntheticEvent({ + cfg, + accountId: "acct1", + event, + botOpenId: "ou_bot", + fetchMessage: async () => { + throw new Error("boom"); + }, + logger: log, + }); + expect(result).toBeNull(); + expect(log).toHaveBeenCalledWith( + expect.stringContaining("ignoring reaction on non-bot/unverified message om_msg1"), + ); + }); +}); diff --git a/extensions/feishu/src/monitor.ts b/extensions/feishu/src/monitor.ts index 574f472a19a..1400d6728e7 100644 --- a/extensions/feishu/src/monitor.ts +++ b/extensions/feishu/src/monitor.ts @@ -1,3 +1,4 @@ +import * as crypto from "crypto"; import * as http from "http"; import * as Lark from "@larksuiteoapi/node-sdk"; import { @@ -10,6 +11,7 @@ import { resolveFeishuAccount, listEnabledFeishuAccounts } from "./accounts.js"; import { handleFeishuMessage, type FeishuMessageEvent, type FeishuBotAddedEvent } from "./bot.js"; import { createFeishuWSClient, createEventDispatcher } from "./client.js"; import { probeFeishu } from "./probe.js"; +import { getMessageFeishu } from "./send.js"; import type { ResolvedFeishuAccount } from "./types.js"; export type MonitorFeishuOpts = { @@ -29,6 +31,29 @@ const FEISHU_WEBHOOK_RATE_LIMIT_WINDOW_MS = 60_000; const FEISHU_WEBHOOK_RATE_LIMIT_MAX_REQUESTS = 120; const FEISHU_WEBHOOK_RATE_LIMIT_MAX_TRACKED_KEYS = 4_096; const FEISHU_WEBHOOK_COUNTER_LOG_EVERY = 25; +const FEISHU_REACTION_VERIFY_TIMEOUT_MS = 1_500; + +export type FeishuReactionCreatedEvent = { + message_id: string; + chat_id?: string; + chat_type?: "p2p" | "group"; + reaction_type?: { emoji_type?: string }; + operator_type?: string; + user_id?: { open_id?: string }; + action_time?: string; +}; + +type ResolveReactionSyntheticEventParams = { + cfg: ClawdbotConfig; + accountId: string; + event: FeishuReactionCreatedEvent; + botOpenId?: string; + fetchMessage?: typeof getMessageFeishu; + verificationTimeoutMs?: number; + logger?: (message: string) => void; + uuid?: () => string; +}; + const feishuWebhookRateLimits = new Map(); const feishuWebhookStatusCounters = new Map(); let lastWebhookRateLimitCleanupMs = 0; @@ -115,6 +140,95 @@ function recordWebhookStatus( } } +async function withTimeout(promise: Promise, timeoutMs: number): Promise { + let timeoutId: NodeJS.Timeout | undefined; + try { + return await Promise.race([ + promise, + new Promise((resolve) => { + timeoutId = setTimeout(() => resolve(null), timeoutMs); + }), + ]); + } finally { + if (timeoutId) { + clearTimeout(timeoutId); + } + } +} + +export async function resolveReactionSyntheticEvent( + params: ResolveReactionSyntheticEventParams, +): Promise { + const { + cfg, + accountId, + event, + botOpenId, + fetchMessage = getMessageFeishu, + verificationTimeoutMs = FEISHU_REACTION_VERIFY_TIMEOUT_MS, + logger, + uuid = () => crypto.randomUUID(), + } = params; + + const emoji = event.reaction_type?.emoji_type; + const messageId = event.message_id; + const senderId = event.user_id?.open_id; + if (!emoji || !messageId || !senderId) { + return null; + } + + // Skip bot self-reactions + if (event.operator_type === "app" || senderId === botOpenId) { + return null; + } + + // Skip typing indicator emoji + if (emoji === "Typing") { + return null; + } + + // Fail closed if bot identity cannot be resolved; otherwise reactions on any + // message can leak into the agent. + if (!botOpenId) { + logger?.( + `feishu[${accountId}]: bot open_id unavailable, skipping reaction ${emoji} on ${messageId}`, + ); + return null; + } + + const reactedMsg = await withTimeout( + fetchMessage({ cfg, messageId, accountId }), + verificationTimeoutMs, + ).catch(() => null); + const isBotMessage = reactedMsg?.senderType === "app" || reactedMsg?.senderOpenId === botOpenId; + if (!reactedMsg || !isBotMessage) { + logger?.( + `feishu[${accountId}]: ignoring reaction on non-bot/unverified message ${messageId} ` + + `(sender: ${reactedMsg?.senderOpenId ?? "unknown"})`, + ); + return null; + } + + const syntheticChatIdRaw = event.chat_id ?? reactedMsg.chatId; + const syntheticChatId = syntheticChatIdRaw?.trim() ? syntheticChatIdRaw : `p2p:${senderId}`; + const syntheticChatType: "p2p" | "group" = event.chat_type ?? "p2p"; + return { + sender: { + sender_id: { open_id: senderId }, + sender_type: "user", + }, + message: { + message_id: `${messageId}:reaction:${emoji}:${uuid()}`, + chat_id: syntheticChatId, + chat_type: syntheticChatType, + message_type: "text", + content: JSON.stringify({ + text: `[reacted with ${emoji} to message ${messageId}]`, + }), + }, + }; +} + async function fetchBotOpenId(account: ResolvedFeishuAccount): Promise { try { const result = await probeFeishu(account); @@ -185,6 +299,53 @@ function registerEventHandlers( error(`feishu[${accountId}]: error handling bot removed event: ${String(err)}`); } }, + "im.message.reaction.created_v1": async (data) => { + const processReaction = async () => { + const event = data as FeishuReactionCreatedEvent; + const myBotId = botOpenIds.get(accountId); + const syntheticEvent = await resolveReactionSyntheticEvent({ + cfg, + accountId, + event, + botOpenId: myBotId, + logger: log, + }); + if (!syntheticEvent) { + return; + } + const promise = handleFeishuMessage({ + cfg, + event: syntheticEvent, + botOpenId: myBotId, + runtime, + chatHistories, + accountId, + }); + if (fireAndForget) { + promise.catch((err) => { + error(`feishu[${accountId}]: error handling reaction: ${String(err)}`); + }); + return; + } + await promise; + }; + + if (fireAndForget) { + void processReaction().catch((err) => { + error(`feishu[${accountId}]: error handling reaction event: ${String(err)}`); + }); + return; + } + + try { + await processReaction(); + } catch (err) { + error(`feishu[${accountId}]: error handling reaction event: ${String(err)}`); + } + }, + "im.message.reaction.deleted_v1": async () => { + // Ignore reaction removals + }, }); } diff --git a/extensions/feishu/src/send.ts b/extensions/feishu/src/send.ts index fefc698c916..b271aecd602 100644 --- a/extensions/feishu/src/send.ts +++ b/extensions/feishu/src/send.ts @@ -13,6 +13,7 @@ export type FeishuMessageInfo = { chatId: string; senderId?: string; senderOpenId?: string; + senderType?: string; content: string; contentType: string; createTime?: number; @@ -82,6 +83,7 @@ export async function getMessageFeishu(params: { chatId: item.chat_id ?? "", senderId: item.sender?.id, senderOpenId: item.sender?.id_type === "open_id" ? item.sender?.id : undefined, + senderType: item.sender?.sender_type, content, contentType: item.msg_type ?? "text", createTime: item.create_time ? parseInt(item.create_time, 10) : undefined,