Add Feishu reactions and card action support (#46692)

* Add Feishu reactions and card action support

* Tighten Feishu action handling
This commit is contained in:
Tak Hoffman
2026-03-14 20:25:02 -05:00
committed by GitHub
parent 946c24d674
commit f4dbd78afd
9 changed files with 599 additions and 129 deletions

View File

@@ -149,7 +149,11 @@ Lark国际版请使用 https://open.larksuite.com/app并在配置中设
**事件订阅** 页面:
1. 选择 **使用长连接接收事件**WebSocket 模式)
2. 添加事件:`im.message.receive_v1`(接收消息)
2. 添加事件:
- `im.message.receive_v1`
- `im.message.reaction.created_v1`
- `im.message.reaction.deleted_v1`
- `application.bot.menu_v6`
⚠️ **注意**:如果网关未启动或渠道未添加,长连接设置将保存失败。
@@ -435,7 +439,7 @@ openclaw pairing list feishu
| `/reset` | 重置对话会话 |
| `/model` | 查看/切换模型 |
> 注意:飞书目前不支持原生命令菜单,命令需要以文本形式发送
飞书机器人菜单建议直接在飞书开放平台的机器人能力页面配置。OpenClaw 当前支持接收 `application.bot.menu_v6` 事件,并把点击事件转换成普通文本命令(例如 `/menu <eventKey>`)继续走现有消息路由,但不通过渠道配置自动创建或同步菜单
## 网关管理命令
@@ -526,7 +530,11 @@ openclaw pairing list feishu
channels: {
feishu: {
streaming: true, // 启用流式卡片输出(默认 true
blockStreaming: true, // 启用块级流式(默认 true
blockStreamingCoalesce: {
enabled: true,
minDelayMs: 50,
maxDelayMs: 250,
},
},
},
}
@@ -534,6 +542,40 @@ openclaw pairing list feishu
如需禁用流式输出(等待完整回复后一次性发送),可设置 `streaming: false`。
### 交互式卡片
OpenClaw 默认会在需要时发送 Markdown 卡片;如果你需要完整的 Feishu 原生交互式卡片,也可以显式发送原始 `card` payload。
- 默认路径:文本自动渲染或 Markdown 卡片
- 显式卡片:通过消息动作的 `card` 参数发送原始交互卡片
- 更新卡片:同一消息支持后续 patch/update
卡片按钮回调当前走文本回退路径:
- 若 `action.value.text` 存在,则作为入站文本继续处理
- 若 `action.value.command` 存在,则作为命令文本继续处理
- 其他对象值会序列化为 JSON 文本
这样可以保持与现有消息/命令路由兼容,而不要求下游先理解 Feishu 专有的交互 payload。
### 表情反应
飞书渠道现已完整支持表情反应生命周期:
- 接收 `reaction created`
- 接收 `reaction deleted`
- 主动添加反应
- 主动删除自身反应
- 查询消息上的反应列表
是否把入站反应转成内部消息,可通过 `reactionNotifications` 控制:
| 值 | 行为 |
| ----- | ---------------------------- |
| `off` | 不生成反应通知 |
| `own` | 仅当反应发生在机器人消息上时 |
| `all` | 所有可验证的反应都生成通知 |
### 消息引用
在群聊中,机器人的回复可以引用用户发送的原始消息,让对话上下文更加清晰。
@@ -653,14 +695,19 @@ openclaw pairing list feishu
| `channels.feishu.accounts.<id>.domain` | 单账号 API 域名覆盖 | `feishu` |
| `channels.feishu.dmPolicy` | 私聊策略 | `pairing` |
| `channels.feishu.allowFrom` | 私聊白名单open_id 列表) | - |
| `channels.feishu.groupPolicy` | 群组策略 | `open` |
| `channels.feishu.groupPolicy` | 群组策略 | `allowlist` |
| `channels.feishu.groupAllowFrom` | 群组白名单 | - |
| `channels.feishu.groups.<chat_id>.requireMention` | 是否需要 @提及 | `true` |
| `channels.feishu.groups.<chat_id>.enabled` | 是否启用该群组 | `true` |
| `channels.feishu.replyInThread` | 群聊回复是否进入飞书话题线程 | `disabled` |
| `channels.feishu.groupSessionScope` | 群聊会话隔离粒度 | `group` |
| `channels.feishu.textChunkLimit` | 消息分块大小 | `2000` |
| `channels.feishu.mediaMaxMb` | 媒体大小限制 | `30` |
| `channels.feishu.streaming` | 启用流式卡片输出 | `true` |
| `channels.feishu.blockStreaming` | 启用块级流式 | `true` |
| `channels.feishu.blockStreamingCoalesce.enabled` | 启用块级流式合并 | `true` |
| `channels.feishu.typingIndicator` | 发送“正在输入”状态 | `true` |
| `channels.feishu.resolveSenderNames` | 拉取发送者名称 | `true` |
| `channels.feishu.reactionNotifications` | 入站反应通知策略 | `own` |
---

View File

@@ -20,6 +20,20 @@ export type FeishuCardActionEvent = {
};
};
function buildCardActionTextFallback(event: FeishuCardActionEvent): string {
const actionValue = event.action.value;
if (typeof actionValue === "object" && actionValue !== null) {
if ("text" in actionValue && typeof actionValue.text === "string") {
return actionValue.text;
}
if ("command" in actionValue && typeof actionValue.command === "string") {
return actionValue.command;
}
return JSON.stringify(actionValue);
}
return String(actionValue);
}
export async function handleFeishuCardAction(params: {
cfg: ClawdbotConfig;
event: FeishuCardActionEvent;
@@ -30,21 +44,7 @@ export async function handleFeishuCardAction(params: {
const { cfg, event, runtime, accountId } = params;
const account = resolveFeishuAccount({ cfg, accountId });
const log = runtime?.log ?? console.log;
// Extract action value
const actionValue = event.action.value;
let content = "";
if (typeof actionValue === "object" && actionValue !== null) {
if ("text" in actionValue && typeof actionValue.text === "string") {
content = actionValue.text;
} else if ("command" in actionValue && typeof actionValue.command === "string") {
content = actionValue.command;
} else {
content = JSON.stringify(actionValue);
}
} else {
content = String(actionValue);
}
const content = buildCardActionTextFallback(event);
// Construct a synthetic message event
const messageEvent: FeishuMessageEvent = {

View File

@@ -2,11 +2,18 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/feishu";
import { describe, expect, it, vi } from "vitest";
const probeFeishuMock = vi.hoisted(() => vi.fn());
const listReactionsFeishuMock = vi.hoisted(() => vi.fn());
vi.mock("./probe.js", () => ({
probeFeishu: probeFeishuMock,
}));
vi.mock("./reactions.js", () => ({
addReactionFeishu: vi.fn(),
listReactionsFeishu: listReactionsFeishuMock,
removeReactionFeishu: vi.fn(),
}));
import { feishuPlugin } from "./channel.js";
describe("feishuPlugin.status.probeAccount", () => {
@@ -46,3 +53,114 @@ describe("feishuPlugin.status.probeAccount", () => {
expect(result).toMatchObject({ ok: true, appId: "cli_main" });
});
});
describe("feishuPlugin actions", () => {
const cfg = {
channels: {
feishu: {
enabled: true,
appId: "cli_main",
appSecret: "secret_main",
actions: {
reactions: true,
},
},
},
} as OpenClawConfig;
it("does not advertise reactions when disabled via actions config", () => {
const disabledCfg = {
channels: {
feishu: {
enabled: true,
appId: "cli_main",
appSecret: "secret_main",
actions: {
reactions: false,
},
},
},
} as OpenClawConfig;
expect(feishuPlugin.actions?.listActions?.({ cfg: disabledCfg })).toEqual([]);
});
it("advertises reactions when any enabled configured account allows them", () => {
const cfg = {
channels: {
feishu: {
enabled: true,
defaultAccount: "main",
actions: {
reactions: false,
},
accounts: {
main: {
appId: "cli_main",
appSecret: "secret_main",
enabled: true,
actions: {
reactions: false,
},
},
secondary: {
appId: "cli_secondary",
appSecret: "secret_secondary",
enabled: true,
actions: {
reactions: true,
},
},
},
},
},
} as OpenClawConfig;
expect(feishuPlugin.actions?.listActions?.({ cfg })).toEqual(["react", "reactions"]);
});
it("requires clearAll=true before removing all bot reactions", async () => {
await expect(
feishuPlugin.actions?.handleAction?.({
action: "react",
params: { messageId: "om_msg1" },
cfg,
accountId: undefined,
} as never),
).rejects.toThrow(
"Emoji is required to add a Feishu reaction. Set clearAll=true to remove all bot reactions.",
);
});
it("throws for unsupported Feishu send actions without card payload", async () => {
await expect(
feishuPlugin.actions?.handleAction?.({
action: "send",
params: { to: "chat:oc_group_1", message: "hello" },
cfg,
accountId: undefined,
} as never),
).rejects.toThrow('Unsupported Feishu action: "send"');
});
it("allows explicit clearAll=true when removing all bot reactions", async () => {
listReactionsFeishuMock.mockResolvedValueOnce([
{ reactionId: "r1", operatorType: "app" },
{ reactionId: "r2", operatorType: "app" },
]);
const result = await feishuPlugin.actions?.handleAction?.({
action: "react",
params: { messageId: "om_msg1", clearAll: true },
cfg,
accountId: undefined,
} as never);
expect(listReactionsFeishuMock).toHaveBeenCalledWith({
cfg,
messageId: "om_msg1",
accountId: undefined,
});
expect(result?.details).toMatchObject({ ok: true, removed: 2 });
});
});

View File

@@ -5,18 +5,23 @@ import {
} from "openclaw/plugin-sdk/compat";
import type { ChannelMeta, ChannelPlugin, ClawdbotConfig } from "openclaw/plugin-sdk/feishu";
import {
buildChannelConfigSchema,
buildProbeChannelStatusSummary,
createActionGate,
buildRuntimeAccountStatusSnapshot,
createDefaultChannelRuntimeState,
DEFAULT_ACCOUNT_ID,
PAIRING_APPROVED_MESSAGE,
} from "openclaw/plugin-sdk/feishu";
import type { ChannelMessageActionName } from "openclaw/plugin-sdk/feishu";
import {
resolveFeishuAccount,
resolveFeishuCredentials,
listFeishuAccountIds,
listEnabledFeishuAccounts,
resolveDefaultFeishuAccountId,
} from "./accounts.js";
import { FeishuConfigSchema } from "./config-schema.js";
import {
listFeishuDirectoryPeers,
listFeishuDirectoryGroups,
@@ -27,7 +32,8 @@ import { feishuOnboardingAdapter } from "./onboarding.js";
import { feishuOutbound } from "./outbound.js";
import { resolveFeishuGroupToolPolicy } from "./policy.js";
import { probeFeishu } from "./probe.js";
import { sendMessageFeishu } from "./send.js";
import { addReactionFeishu, listReactionsFeishu, removeReactionFeishu } from "./reactions.js";
import { sendCardFeishu, sendMessageFeishu } from "./send.js";
import { normalizeFeishuTarget, looksLikeFeishuId, formatFeishuTarget } from "./targets.js";
import type { ResolvedFeishuAccount, FeishuConfig } from "./types.js";
@@ -42,22 +48,6 @@ const meta: ChannelMeta = {
order: 70,
};
const secretInputJsonSchema = {
oneOf: [
{ type: "string" },
{
type: "object",
additionalProperties: false,
required: ["source", "provider", "id"],
properties: {
source: { type: "string", enum: ["env", "file", "exec"] },
provider: { type: "string", minLength: 1 },
id: { type: "string", minLength: 1 },
},
},
],
} as const;
function setFeishuNamedAccountEnabled(
cfg: ClawdbotConfig,
accountId: string,
@@ -82,6 +72,32 @@ function setFeishuNamedAccountEnabled(
};
}
function isFeishuReactionsActionEnabled(params: {
cfg: ClawdbotConfig;
account: ResolvedFeishuAccount;
}): boolean {
if (!params.account.enabled || !params.account.configured) {
return false;
}
const gate = createActionGate(
(params.account.config.actions ??
(params.cfg.channels?.feishu as { actions?: unknown } | undefined)?.actions) as Record<
string,
boolean | undefined
>,
);
return gate("reactions");
}
function areAnyFeishuReactionActionsEnabled(cfg: ClawdbotConfig): boolean {
for (const account of listEnabledFeishuAccounts(cfg)) {
if (isFeishuReactionsActionEnabled({ cfg, account })) {
return true;
}
}
return false;
}
export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
id: "feishu",
meta: {
@@ -120,69 +136,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
stripPatterns: () => ['<at user_id="[^"]*">[^<]*</at>'],
},
reload: { configPrefixes: ["channels.feishu"] },
configSchema: {
schema: {
type: "object",
additionalProperties: false,
properties: {
enabled: { type: "boolean" },
defaultAccount: { type: "string" },
appId: { type: "string" },
appSecret: secretInputJsonSchema,
encryptKey: secretInputJsonSchema,
verificationToken: secretInputJsonSchema,
domain: {
oneOf: [
{ type: "string", enum: ["feishu", "lark"] },
{ type: "string", format: "uri", pattern: "^https://" },
],
},
connectionMode: { type: "string", enum: ["websocket", "webhook"] },
webhookPath: { type: "string" },
webhookHost: { type: "string" },
webhookPort: { type: "integer", minimum: 1 },
dmPolicy: { type: "string", enum: ["open", "pairing", "allowlist"] },
allowFrom: { type: "array", items: { oneOf: [{ type: "string" }, { type: "number" }] } },
groupPolicy: { type: "string", enum: ["open", "allowlist", "disabled"] },
groupAllowFrom: {
type: "array",
items: { oneOf: [{ type: "string" }, { type: "number" }] },
},
requireMention: { type: "boolean" },
groupSessionScope: {
type: "string",
enum: ["group", "group_sender", "group_topic", "group_topic_sender"],
},
topicSessionMode: { type: "string", enum: ["disabled", "enabled"] },
replyInThread: { type: "string", enum: ["disabled", "enabled"] },
historyLimit: { type: "integer", minimum: 0 },
dmHistoryLimit: { type: "integer", minimum: 0 },
textChunkLimit: { type: "integer", minimum: 1 },
chunkMode: { type: "string", enum: ["length", "newline"] },
mediaMaxMb: { type: "number", minimum: 0 },
renderMode: { type: "string", enum: ["auto", "raw", "card"] },
accounts: {
type: "object",
additionalProperties: {
type: "object",
properties: {
enabled: { type: "boolean" },
name: { type: "string" },
appId: { type: "string" },
appSecret: secretInputJsonSchema,
encryptKey: secretInputJsonSchema,
verificationToken: secretInputJsonSchema,
domain: { type: "string", enum: ["feishu", "lark"] },
connectionMode: { type: "string", enum: ["websocket", "webhook"] },
webhookHost: { type: "string" },
webhookPath: { type: "string" },
webhookPort: { type: "integer", minimum: 1 },
},
},
},
},
},
},
configSchema: buildChannelConfigSchema(FeishuConfigSchema),
config: {
listAccountIds: (cfg) => listFeishuAccountIds(cfg),
resolveAccount: (cfg, accountId) => resolveFeishuAccount({ cfg, accountId }),
@@ -255,6 +209,172 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
},
formatAllowFrom: ({ allowFrom }) => formatAllowFromLowercase({ allowFrom }),
},
actions: {
listActions: ({ cfg }) => {
if (listEnabledFeishuAccounts(cfg).length === 0) {
return [];
}
const actions = new Set<ChannelMessageActionName>();
if (areAnyFeishuReactionActionsEnabled(cfg)) {
actions.add("react");
actions.add("reactions");
}
return Array.from(actions);
},
supportsCards: ({ cfg }) => {
return (
cfg.channels?.feishu?.enabled !== false &&
Boolean(resolveFeishuCredentials(cfg.channels?.feishu as FeishuConfig | undefined))
);
},
handleAction: async (ctx) => {
const account = resolveFeishuAccount({ cfg: ctx.cfg, accountId: ctx.accountId ?? undefined });
if (
(ctx.action === "react" || ctx.action === "reactions") &&
!isFeishuReactionsActionEnabled({ cfg: ctx.cfg, account })
) {
throw new Error("Feishu reactions are disabled via actions.reactions.");
}
if (ctx.action === "send" && ctx.params.card) {
const card = ctx.params.card as Record<string, unknown>;
const to =
typeof ctx.params.to === "string"
? ctx.params.to.trim()
: typeof ctx.params.target === "string"
? ctx.params.target.trim()
: "";
if (!to) {
return {
isError: true,
content: [{ type: "text" as const, text: "Feishu card send requires a target (to)." }],
details: { error: "Feishu card send requires a target (to)." },
};
}
const replyToMessageId =
typeof ctx.params.replyTo === "string"
? ctx.params.replyTo.trim() || undefined
: undefined;
const result = await sendCardFeishu({
cfg: ctx.cfg,
to,
card,
accountId: ctx.accountId ?? undefined,
replyToMessageId,
});
return {
content: [
{
type: "text" as const,
text: JSON.stringify({ ok: true, channel: "feishu", ...result }),
},
],
details: { ok: true, channel: "feishu", ...result },
};
}
if (ctx.action === "react") {
const messageId =
(typeof ctx.params.messageId === "string" && ctx.params.messageId.trim()) ||
(typeof ctx.params.message_id === "string" && ctx.params.message_id.trim()) ||
undefined;
if (!messageId) {
throw new Error("Feishu reaction requires messageId.");
}
const emoji = typeof ctx.params.emoji === "string" ? ctx.params.emoji.trim() : "";
const remove = ctx.params.remove === true;
const clearAll = ctx.params.clearAll === true;
if (remove) {
if (!emoji) {
throw new Error("Emoji is required to remove a Feishu reaction.");
}
const matches = await listReactionsFeishu({
cfg: ctx.cfg,
messageId,
emojiType: emoji,
accountId: ctx.accountId ?? undefined,
});
const ownReaction = matches.find((entry) => entry.operatorType === "app");
if (!ownReaction) {
return {
content: [
{ type: "text" as const, text: JSON.stringify({ ok: true, removed: null }) },
],
details: { ok: true, removed: null },
};
}
await removeReactionFeishu({
cfg: ctx.cfg,
messageId,
reactionId: ownReaction.reactionId,
accountId: ctx.accountId ?? undefined,
});
return {
content: [
{ type: "text" as const, text: JSON.stringify({ ok: true, removed: emoji }) },
],
details: { ok: true, removed: emoji },
};
}
if (!emoji) {
if (!clearAll) {
throw new Error(
"Emoji is required to add a Feishu reaction. Set clearAll=true to remove all bot reactions.",
);
}
const reactions = await listReactionsFeishu({
cfg: ctx.cfg,
messageId,
accountId: ctx.accountId ?? undefined,
});
let removed = 0;
for (const reaction of reactions.filter((entry) => entry.operatorType === "app")) {
await removeReactionFeishu({
cfg: ctx.cfg,
messageId,
reactionId: reaction.reactionId,
accountId: ctx.accountId ?? undefined,
});
removed += 1;
}
return {
content: [{ type: "text" as const, text: JSON.stringify({ ok: true, removed }) }],
details: { ok: true, removed },
};
}
await addReactionFeishu({
cfg: ctx.cfg,
messageId,
emojiType: emoji,
accountId: ctx.accountId ?? undefined,
});
return {
content: [{ type: "text" as const, text: JSON.stringify({ ok: true, added: emoji }) }],
details: { ok: true, added: emoji },
};
}
if (ctx.action === "reactions") {
const messageId =
(typeof ctx.params.messageId === "string" && ctx.params.messageId.trim()) ||
(typeof ctx.params.message_id === "string" && ctx.params.message_id.trim()) ||
undefined;
if (!messageId) {
throw new Error("Feishu reactions lookup requires messageId.");
}
const reactions = await listReactionsFeishu({
cfg: ctx.cfg,
messageId,
accountId: ctx.accountId ?? undefined,
});
return {
content: [{ type: "text" as const, text: JSON.stringify({ ok: true, reactions }) }],
details: { ok: true, reactions },
};
}
throw new Error(`Unsupported Feishu action: "${String(ctx.action)}"`);
},
},
security: {
collectWarnings: ({ cfg, accountId }) => {
const account = resolveFeishuAccount({ cfg, accountId });

View File

@@ -217,6 +217,26 @@ describe("FeishuConfigSchema optimization flags", () => {
});
});
describe("FeishuConfigSchema actions", () => {
it("accepts top-level reactions action gate", () => {
const result = FeishuConfigSchema.parse({
actions: { reactions: false },
});
expect(result.actions?.reactions).toBe(false);
});
it("accepts account-level reactions action gate", () => {
const result = FeishuConfigSchema.parse({
accounts: {
main: {
actions: { reactions: false },
},
},
});
expect(result.accounts?.main?.actions?.reactions).toBe(false);
});
});
describe("FeishuConfigSchema defaultAccount", () => {
it("accepts defaultAccount when it matches an account key", () => {
const result = FeishuConfigSchema.safeParse({

View File

@@ -3,6 +3,13 @@ import { z } from "zod";
export { z };
import { buildSecretInputSchema, hasConfiguredSecretInput } from "./secret-input.js";
const ChannelActionsSchema = z
.object({
reactions: z.boolean().optional(),
})
.strict()
.optional();
const DmPolicySchema = z.enum(["open", "pairing", "allowlist"]);
const GroupPolicySchema = z.union([
z.enum(["open", "allowlist", "disabled"]),
@@ -170,6 +177,7 @@ const FeishuSharedConfigShape = {
renderMode: RenderModeSchema,
streaming: StreamingModeSchema,
tools: FeishuToolsConfigSchema,
actions: ChannelActionsSchema,
replyInThread: ReplyInThreadSchema,
reactionNotifications: ReactionNotificationModeSchema,
typingIndicator: z.boolean().optional(),

View File

@@ -38,6 +38,10 @@ export type FeishuReactionCreatedEvent = {
action_time?: string;
};
export type FeishuReactionDeletedEvent = FeishuReactionCreatedEvent & {
reaction_id?: string;
};
type ResolveReactionSyntheticEventParams = {
cfg: ClawdbotConfig;
accountId: string;
@@ -47,6 +51,7 @@ type ResolveReactionSyntheticEventParams = {
verificationTimeoutMs?: number;
logger?: (message: string) => void;
uuid?: () => string;
action?: "created" | "deleted";
};
export async function resolveReactionSyntheticEvent(
@@ -61,6 +66,7 @@ export async function resolveReactionSyntheticEvent(
verificationTimeoutMs = FEISHU_REACTION_VERIFY_TIMEOUT_MS,
logger,
uuid = () => crypto.randomUUID(),
action = "created",
} = params;
const emoji = event.reaction_type?.emoji_type;
@@ -129,7 +135,10 @@ export async function resolveReactionSyntheticEvent(
chat_type: syntheticChatType,
message_type: "text",
content: JSON.stringify({
text: `[reacted with ${emoji} to message ${messageId}]`,
text:
action === "deleted"
? `[removed reaction ${emoji} from message ${messageId}]`
: `[reacted with ${emoji} to message ${messageId}]`,
}),
},
};
@@ -253,6 +262,19 @@ function registerEventHandlers(
const log = runtime?.log ?? console.log;
const error = runtime?.error ?? console.error;
const enqueue = createChatQueue();
const runFeishuHandler = async (params: { task: () => Promise<void>; errorMessage: string }) => {
if (fireAndForget) {
void params.task().catch((err) => {
error(`${params.errorMessage}: ${String(err)}`);
});
return;
}
try {
await params.task();
} catch (err) {
error(`${params.errorMessage}: ${String(err)}`);
}
};
const dispatchFeishuMessage = async (event: FeishuMessageEvent) => {
const chatId = event.message.chat_id?.trim() || "unknown";
const task = () =>
@@ -428,23 +450,102 @@ function registerEventHandlers(
}
},
"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) {
await runFeishuHandler({
errorMessage: `feishu[${accountId}]: error handling reaction event`,
task: 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,
botName: botNames.get(accountId),
runtime,
chatHistories,
accountId,
});
await promise;
},
});
},
"im.message.reaction.deleted_v1": async (data) => {
await runFeishuHandler({
errorMessage: `feishu[${accountId}]: error handling reaction removal event`,
task: async () => {
const event = data as FeishuReactionDeletedEvent;
const myBotId = botOpenIds.get(accountId);
const syntheticEvent = await resolveReactionSyntheticEvent({
cfg,
accountId,
event,
botOpenId: myBotId,
logger: log,
action: "deleted",
});
if (!syntheticEvent) {
return;
}
const promise = handleFeishuMessage({
cfg,
event: syntheticEvent,
botOpenId: myBotId,
botName: botNames.get(accountId),
runtime,
chatHistories,
accountId,
});
await promise;
},
});
},
"application.bot.menu_v6": async (data) => {
try {
const event = data as {
event_key?: string;
timestamp?: number;
operator?: {
operator_name?: string;
operator_id?: { open_id?: string; user_id?: string; union_id?: string };
};
};
const operatorOpenId = event.operator?.operator_id?.open_id?.trim();
const eventKey = event.event_key?.trim();
if (!operatorOpenId || !eventKey) {
return;
}
const syntheticEvent: FeishuMessageEvent = {
sender: {
sender_id: {
open_id: operatorOpenId,
user_id: event.operator?.operator_id?.user_id,
union_id: event.operator?.operator_id?.union_id,
},
sender_type: "user",
},
message: {
message_id: `bot-menu:${eventKey}:${event.timestamp ?? Date.now()}`,
chat_id: `p2p:${operatorOpenId}`,
chat_type: "p2p",
message_type: "text",
content: JSON.stringify({
text: `/menu ${eventKey}`,
}),
},
};
const promise = handleFeishuMessage({
cfg,
event: syntheticEvent,
botOpenId: myBotId,
botOpenId: botOpenIds.get(accountId),
botName: botNames.get(accountId),
runtime,
chatHistories,
@@ -452,29 +553,15 @@ function registerEventHandlers(
});
if (fireAndForget) {
promise.catch((err) => {
error(`feishu[${accountId}]: error handling reaction: ${String(err)}`);
error(`feishu[${accountId}]: error handling bot menu event: ${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)}`);
error(`feishu[${accountId}]: error handling bot menu event: ${String(err)}`);
}
},
"im.message.reaction.deleted_v1": async () => {
// Ignore reaction removals
},
"card.action.trigger": async (data: unknown) => {
try {
const event = data as unknown as FeishuCardActionEvent;

View File

@@ -0,0 +1,67 @@
import type { ClawdbotConfig } from "openclaw/plugin-sdk/feishu";
import { describe, expect, it } from "vitest";
import {
resolveReactionSyntheticEvent,
type FeishuReactionCreatedEvent,
} from "./monitor.account.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("Feishu reaction lifecycle", () => {
it("builds a created synthetic interaction payload", async () => {
const result = await resolveReactionSyntheticEvent({
cfg,
accountId: "default",
event: makeReactionEvent(),
botOpenId: "ou_bot",
fetchMessage: async () => ({
messageId: "om_msg1",
chatId: "oc_group_1",
chatType: "group",
senderOpenId: "ou_bot",
senderType: "app",
content: "hello",
contentType: "text",
}),
uuid: () => "fixed-uuid",
});
expect(result?.message.content).toBe('{"text":"[reacted with THUMBSUP to message om_msg1]"}');
});
it("builds a deleted synthetic interaction payload", async () => {
const result = await resolveReactionSyntheticEvent({
cfg,
accountId: "default",
event: makeReactionEvent(),
botOpenId: "ou_bot",
fetchMessage: async () => ({
messageId: "om_msg1",
chatId: "oc_group_1",
chatType: "group",
senderOpenId: "ou_bot",
senderType: "app",
content: "hello",
contentType: "text",
}),
uuid: () => "fixed-uuid",
action: "deleted",
});
expect(result?.message.content).toBe(
'{"text":"[removed reaction THUMBSUP from message om_msg1]"}',
);
});
});

View File

@@ -11,6 +11,8 @@ export {
export type { ReplyPayload } from "../auto-reply/types.js";
export { logTypingFailure } from "../channels/logging.js";
export type { AllowlistMatch } from "../channels/plugins/allowlist-match.js";
export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js";
export { createActionGate } from "../agents/tools/common.js";
export type {
ChannelOnboardingAdapter,
ChannelOnboardingDmPolicy,
@@ -29,6 +31,7 @@ export { PAIRING_APPROVED_MESSAGE } from "../channels/plugins/pairing-message.js
export type {
BaseProbeResult,
ChannelGroupContext,
ChannelMessageActionName,
ChannelMeta,
ChannelOutboundAdapter,
} from "../channels/plugins/types.js";