Skip to content

Commit 4294ce7

Browse files
committed
Add Feishu reactions and card action support
1 parent 774b404 commit 4294ce7

File tree

9 files changed

+521
-103
lines changed

9 files changed

+521
-103
lines changed

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: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,56 @@ describe("feishuPlugin.status.probeAccount", () => {
4646
expect(result).toMatchObject({ ok: true, appId: "cli_main" });
4747
});
4848
});
49+
50+
describe("feishuPlugin actions", () => {
51+
it("does not advertise reactions when disabled via actions config", () => {
52+
const cfg = {
53+
channels: {
54+
feishu: {
55+
enabled: true,
56+
appId: "cli_main",
57+
appSecret: "secret_main",
58+
actions: {
59+
reactions: false,
60+
},
61+
},
62+
},
63+
} as OpenClawConfig;
64+
65+
expect(feishuPlugin.actions?.listActions?.({ cfg })).toEqual([]);
66+
});
67+
68+
it("advertises reactions when any enabled configured account allows them", () => {
69+
const cfg = {
70+
channels: {
71+
feishu: {
72+
enabled: true,
73+
defaultAccount: "main",
74+
actions: {
75+
reactions: false,
76+
},
77+
accounts: {
78+
main: {
79+
appId: "cli_main",
80+
appSecret: "secret_main",
81+
enabled: true,
82+
actions: {
83+
reactions: false,
84+
},
85+
},
86+
secondary: {
87+
appId: "cli_secondary",
88+
appSecret: "secret_secondary",
89+
enabled: true,
90+
actions: {
91+
reactions: true,
92+
},
93+
},
94+
},
95+
},
96+
},
97+
} as OpenClawConfig;
98+
99+
expect(feishuPlugin.actions?.listActions?.({ cfg })).toEqual(["react", "reactions"]);
100+
});
101+
});

0 commit comments

Comments
 (0)