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>
This commit is contained in:
Lin Z
2026-02-28 10:54:24 +08:00
committed by GitHub
parent afa7ac1f68
commit 8241145ada
4 changed files with 348 additions and 0 deletions

View File

@@ -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> = {},
): 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>(() => {
// 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"),
);
});
});

View File

@@ -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<string, { count: number; windowStartMs: number }>();
const feishuWebhookStatusCounters = new Map<string, number>();
let lastWebhookRateLimitCleanupMs = 0;
@@ -115,6 +140,95 @@ function recordWebhookStatus(
}
}
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T | null> {
let timeoutId: NodeJS.Timeout | undefined;
try {
return await Promise.race<T | null>([
promise,
new Promise<null>((resolve) => {
timeoutId = setTimeout(() => resolve(null), timeoutMs);
}),
]);
} finally {
if (timeoutId) {
clearTimeout(timeoutId);
}
}
}
export async function resolveReactionSyntheticEvent(
params: ResolveReactionSyntheticEventParams,
): Promise<FeishuMessageEvent | null> {
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<string | undefined> {
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
},
});
}

View File

@@ -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,