Skip to content

Commit f4dbd78

Browse files
authored
Add Feishu reactions and card action support (#46692)
* Add Feishu reactions and card action support * Tighten Feishu action handling
1 parent 946c24d commit f4dbd78

9 files changed

Lines changed: 599 additions & 129 deletions

File tree

docs/zh-CN/channels/feishu.md

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,11 @@ Lark(国际版)请使用 https://open.larksuite.com/app,并在配置中设
149149
**事件订阅** 页面:
150150

151151
1. 选择 **使用长连接接收事件**(WebSocket 模式)
152-
2. 添加事件:`im.message.receive_v1`(接收消息)
152+
2. 添加事件:
153+
- `im.message.receive_v1`
154+
- `im.message.reaction.created_v1`
155+
- `im.message.reaction.deleted_v1`
156+
- `application.bot.menu_v6`
153157

154158
⚠️ **注意**:如果网关未启动或渠道未添加,长连接设置将保存失败。
155159

@@ -435,7 +439,7 @@ openclaw pairing list feishu
435439
| `/reset` | 重置对话会话 |
436440
| `/model` | 查看/切换模型 |
437441

438-
> 注意:飞书目前不支持原生命令菜单,命令需要以文本形式发送
442+
飞书机器人菜单建议直接在飞书开放平台的机器人能力页面配置。OpenClaw 当前支持接收 `application.bot.menu_v6` 事件,并把点击事件转换成普通文本命令(例如 `/menu <eventKey>`)继续走现有消息路由,但不通过渠道配置自动创建或同步菜单
439443

440444
## 网关管理命令
441445

@@ -526,14 +530,52 @@ openclaw pairing list feishu
526530
channels: {
527531
feishu: {
528532
streaming: true, // 启用流式卡片输出(默认 true)
529-
blockStreaming: true, // 启用块级流式(默认 true)
533+
blockStreamingCoalesce: {
534+
enabled: true,
535+
minDelayMs: 50,
536+
maxDelayMs: 250,
537+
},
530538
},
531539
},
532540
}
533541
```
534542

535543
如需禁用流式输出(等待完整回复后一次性发送),可设置 `streaming: false`
536544

545+
### 交互式卡片
546+
547+
OpenClaw 默认会在需要时发送 Markdown 卡片;如果你需要完整的 Feishu 原生交互式卡片,也可以显式发送原始 `card` payload。
548+
549+
- 默认路径:文本自动渲染或 Markdown 卡片
550+
- 显式卡片:通过消息动作的 `card` 参数发送原始交互卡片
551+
- 更新卡片:同一消息支持后续 patch/update
552+
553+
卡片按钮回调当前走文本回退路径:
554+
555+
-`action.value.text` 存在,则作为入站文本继续处理
556+
-`action.value.command` 存在,则作为命令文本继续处理
557+
- 其他对象值会序列化为 JSON 文本
558+
559+
这样可以保持与现有消息/命令路由兼容,而不要求下游先理解 Feishu 专有的交互 payload。
560+
561+
### 表情反应
562+
563+
飞书渠道现已完整支持表情反应生命周期:
564+
565+
- 接收 `reaction created`
566+
- 接收 `reaction deleted`
567+
- 主动添加反应
568+
- 主动删除自身反应
569+
- 查询消息上的反应列表
570+
571+
是否把入站反应转成内部消息,可通过 `reactionNotifications` 控制:
572+
573+
|| 行为 |
574+
| ----- | ---------------------------- |
575+
| `off` | 不生成反应通知 |
576+
| `own` | 仅当反应发生在机器人消息上时 |
577+
| `all` | 所有可验证的反应都生成通知 |
578+
537579
### 消息引用
538580

539581
在群聊中,机器人的回复可以引用用户发送的原始消息,让对话上下文更加清晰。
@@ -653,14 +695,19 @@ openclaw pairing list feishu
653695
| `channels.feishu.accounts.<id>.domain` | 单账号 API 域名覆盖 | `feishu` |
654696
| `channels.feishu.dmPolicy` | 私聊策略 | `pairing` |
655697
| `channels.feishu.allowFrom` | 私聊白名单(open_id 列表) | - |
656-
| `channels.feishu.groupPolicy` | 群组策略 | `open` |
698+
| `channels.feishu.groupPolicy` | 群组策略 | `allowlist` |
657699
| `channels.feishu.groupAllowFrom` | 群组白名单 | - |
658700
| `channels.feishu.groups.<chat_id>.requireMention` | 是否需要 @提及 | `true` |
659701
| `channels.feishu.groups.<chat_id>.enabled` | 是否启用该群组 | `true` |
702+
| `channels.feishu.replyInThread` | 群聊回复是否进入飞书话题线程 | `disabled` |
703+
| `channels.feishu.groupSessionScope` | 群聊会话隔离粒度 | `group` |
660704
| `channels.feishu.textChunkLimit` | 消息分块大小 | `2000` |
661705
| `channels.feishu.mediaMaxMb` | 媒体大小限制 | `30` |
662706
| `channels.feishu.streaming` | 启用流式卡片输出 | `true` |
663-
| `channels.feishu.blockStreaming` | 启用块级流式 | `true` |
707+
| `channels.feishu.blockStreamingCoalesce.enabled` | 启用块级流式合并 | `true` |
708+
| `channels.feishu.typingIndicator` | 发送“正在输入”状态 | `true` |
709+
| `channels.feishu.resolveSenderNames` | 拉取发送者名称 | `true` |
710+
| `channels.feishu.reactionNotifications` | 入站反应通知策略 | `own` |
664711

665712
---
666713

extensions/feishu/src/card-action.ts

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,20 @@ export type FeishuCardActionEvent = {
2020
};
2121
};
2222

23+
function buildCardActionTextFallback(event: FeishuCardActionEvent): string {
24+
const actionValue = event.action.value;
25+
if (typeof actionValue === "object" && actionValue !== null) {
26+
if ("text" in actionValue && typeof actionValue.text === "string") {
27+
return actionValue.text;
28+
}
29+
if ("command" in actionValue && typeof actionValue.command === "string") {
30+
return actionValue.command;
31+
}
32+
return JSON.stringify(actionValue);
33+
}
34+
return String(actionValue);
35+
}
36+
2337
export async function handleFeishuCardAction(params: {
2438
cfg: ClawdbotConfig;
2539
event: FeishuCardActionEvent;
@@ -30,21 +44,7 @@ export async function handleFeishuCardAction(params: {
3044
const { cfg, event, runtime, accountId } = params;
3145
const account = resolveFeishuAccount({ cfg, accountId });
3246
const log = runtime?.log ?? console.log;
33-
34-
// Extract action value
35-
const actionValue = event.action.value;
36-
let content = "";
37-
if (typeof actionValue === "object" && actionValue !== null) {
38-
if ("text" in actionValue && typeof actionValue.text === "string") {
39-
content = actionValue.text;
40-
} else if ("command" in actionValue && typeof actionValue.command === "string") {
41-
content = actionValue.command;
42-
} else {
43-
content = JSON.stringify(actionValue);
44-
}
45-
} else {
46-
content = String(actionValue);
47-
}
47+
const content = buildCardActionTextFallback(event);
4848

4949
// Construct a synthetic message event
5050
const messageEvent: FeishuMessageEvent = {

extensions/feishu/src/channel.test.ts

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,18 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/feishu";
22
import { describe, expect, it, vi } from "vitest";
33

44
const probeFeishuMock = vi.hoisted(() => vi.fn());
5+
const listReactionsFeishuMock = vi.hoisted(() => vi.fn());
56

67
vi.mock("./probe.js", () => ({
78
probeFeishu: probeFeishuMock,
89
}));
910

11+
vi.mock("./reactions.js", () => ({
12+
addReactionFeishu: vi.fn(),
13+
listReactionsFeishu: listReactionsFeishuMock,
14+
removeReactionFeishu: vi.fn(),
15+
}));
16+
1017
import { feishuPlugin } from "./channel.js";
1118

1219
describe("feishuPlugin.status.probeAccount", () => {
@@ -46,3 +53,114 @@ describe("feishuPlugin.status.probeAccount", () => {
4653
expect(result).toMatchObject({ ok: true, appId: "cli_main" });
4754
});
4855
});
56+
57+
describe("feishuPlugin actions", () => {
58+
const cfg = {
59+
channels: {
60+
feishu: {
61+
enabled: true,
62+
appId: "cli_main",
63+
appSecret: "secret_main",
64+
actions: {
65+
reactions: true,
66+
},
67+
},
68+
},
69+
} as OpenClawConfig;
70+
71+
it("does not advertise reactions when disabled via actions config", () => {
72+
const disabledCfg = {
73+
channels: {
74+
feishu: {
75+
enabled: true,
76+
appId: "cli_main",
77+
appSecret: "secret_main",
78+
actions: {
79+
reactions: false,
80+
},
81+
},
82+
},
83+
} as OpenClawConfig;
84+
85+
expect(feishuPlugin.actions?.listActions?.({ cfg: disabledCfg })).toEqual([]);
86+
});
87+
88+
it("advertises reactions when any enabled configured account allows them", () => {
89+
const cfg = {
90+
channels: {
91+
feishu: {
92+
enabled: true,
93+
defaultAccount: "main",
94+
actions: {
95+
reactions: false,
96+
},
97+
accounts: {
98+
main: {
99+
appId: "cli_main",
100+
appSecret: "secret_main",
101+
enabled: true,
102+
actions: {
103+
reactions: false,
104+
},
105+
},
106+
secondary: {
107+
appId: "cli_secondary",
108+
appSecret: "secret_secondary",
109+
enabled: true,
110+
actions: {
111+
reactions: true,
112+
},
113+
},
114+
},
115+
},
116+
},
117+
} as OpenClawConfig;
118+
119+
expect(feishuPlugin.actions?.listActions?.({ cfg })).toEqual(["react", "reactions"]);
120+
});
121+
122+
it("requires clearAll=true before removing all bot reactions", async () => {
123+
await expect(
124+
feishuPlugin.actions?.handleAction?.({
125+
action: "react",
126+
params: { messageId: "om_msg1" },
127+
cfg,
128+
accountId: undefined,
129+
} as never),
130+
).rejects.toThrow(
131+
"Emoji is required to add a Feishu reaction. Set clearAll=true to remove all bot reactions.",
132+
);
133+
});
134+
135+
it("throws for unsupported Feishu send actions without card payload", async () => {
136+
await expect(
137+
feishuPlugin.actions?.handleAction?.({
138+
action: "send",
139+
params: { to: "chat:oc_group_1", message: "hello" },
140+
cfg,
141+
accountId: undefined,
142+
} as never),
143+
).rejects.toThrow('Unsupported Feishu action: "send"');
144+
});
145+
146+
it("allows explicit clearAll=true when removing all bot reactions", async () => {
147+
listReactionsFeishuMock.mockResolvedValueOnce([
148+
{ reactionId: "r1", operatorType: "app" },
149+
{ reactionId: "r2", operatorType: "app" },
150+
]);
151+
152+
const result = await feishuPlugin.actions?.handleAction?.({
153+
action: "react",
154+
params: { messageId: "om_msg1", clearAll: true },
155+
cfg,
156+
accountId: undefined,
157+
} as never);
158+
159+
expect(listReactionsFeishuMock).toHaveBeenCalledWith({
160+
cfg,
161+
messageId: "om_msg1",
162+
accountId: undefined,
163+
});
164+
expect(result?.details).toMatchObject({ ok: true, removed: 2 });
165+
});
166+
});

0 commit comments

Comments
 (0)