From f4dbd78afd64253c5d4f9a2b2f124c6ea0b1afb1 Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Sat, 14 Mar 2026 20:25:02 -0500 Subject: [PATCH] Add Feishu reactions and card action support (#46692) * Add Feishu reactions and card action support * Tighten Feishu action handling --- docs/zh-CN/channels/feishu.md | 57 +++- extensions/feishu/src/card-action.ts | 30 +- extensions/feishu/src/channel.test.ts | 118 ++++++++ extensions/feishu/src/channel.ts | 280 +++++++++++++----- extensions/feishu/src/config-schema.test.ts | 20 ++ extensions/feishu/src/config-schema.ts | 8 + extensions/feishu/src/monitor.account.ts | 145 +++++++-- .../src/monitor.reaction.lifecycle.test.ts | 67 +++++ src/plugin-sdk/feishu.ts | 3 + 9 files changed, 599 insertions(+), 129 deletions(-) create mode 100644 extensions/feishu/src/monitor.reaction.lifecycle.test.ts diff --git a/docs/zh-CN/channels/feishu.md b/docs/zh-CN/channels/feishu.md index 7a1c198733c..6a8d8633af9 100644 --- a/docs/zh-CN/channels/feishu.md +++ b/docs/zh-CN/channels/feishu.md @@ -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 `)继续走现有消息路由,但不通过渠道配置自动创建或同步菜单。 ## 网关管理命令 @@ -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..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..requireMention` | 是否需要 @提及 | `true` | | `channels.feishu.groups..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` | --- diff --git a/extensions/feishu/src/card-action.ts b/extensions/feishu/src/card-action.ts index b3030c39a1a..e4f76846316 100644 --- a/extensions/feishu/src/card-action.ts +++ b/extensions/feishu/src/card-action.ts @@ -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 = { diff --git a/extensions/feishu/src/channel.test.ts b/extensions/feishu/src/channel.test.ts index 936ba4c0054..e7db645be0b 100644 --- a/extensions/feishu/src/channel.test.ts +++ b/extensions/feishu/src/channel.test.ts @@ -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 }); + }); +}); diff --git a/extensions/feishu/src/channel.ts b/extensions/feishu/src/channel.ts index 856941c4b21..3baa7c916a2 100644 --- a/extensions/feishu/src/channel.ts +++ b/extensions/feishu/src/channel.ts @@ -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 = { id: "feishu", meta: { @@ -120,69 +136,7 @@ export const feishuPlugin: ChannelPlugin = { stripPatterns: () => ['[^<]*'], }, 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 = { }, formatAllowFrom: ({ allowFrom }) => formatAllowFromLowercase({ allowFrom }), }, + actions: { + listActions: ({ cfg }) => { + if (listEnabledFeishuAccounts(cfg).length === 0) { + return []; + } + const actions = new Set(); + 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; + 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 }); diff --git a/extensions/feishu/src/config-schema.test.ts b/extensions/feishu/src/config-schema.test.ts index aacbac85062..60855a324e9 100644 --- a/extensions/feishu/src/config-schema.test.ts +++ b/extensions/feishu/src/config-schema.test.ts @@ -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({ diff --git a/extensions/feishu/src/config-schema.ts b/extensions/feishu/src/config-schema.ts index b78404de6f8..db1714f173f 100644 --- a/extensions/feishu/src/config-schema.ts +++ b/extensions/feishu/src/config-schema.ts @@ -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(), diff --git a/extensions/feishu/src/monitor.account.ts b/extensions/feishu/src/monitor.account.ts index 3f3cad8ddc3..6bc990a8d1e 100644 --- a/extensions/feishu/src/monitor.account.ts +++ b/extensions/feishu/src/monitor.account.ts @@ -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; 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; diff --git a/extensions/feishu/src/monitor.reaction.lifecycle.test.ts b/extensions/feishu/src/monitor.reaction.lifecycle.test.ts new file mode 100644 index 00000000000..f48bb3e68e7 --- /dev/null +++ b/extensions/feishu/src/monitor.reaction.lifecycle.test.ts @@ -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 { + 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]"}', + ); + }); +}); diff --git a/src/plugin-sdk/feishu.ts b/src/plugin-sdk/feishu.ts index 4b8b0b9abe9..783f730edbe 100644 --- a/src/plugin-sdk/feishu.ts +++ b/src/plugin-sdk/feishu.ts @@ -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";