@m1heng-clawd/feishu 0.1.16 → 0.1.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -0
- package/index.ts +4 -0
- package/package.json +2 -1
- package/skills/feishu-message/SKILL.md +111 -0
- package/skills/feishu-reaction/SKILL.md +120 -0
- package/src/bot.ts +2 -0
- package/src/channel.ts +2 -0
- package/src/config-schema.ts +2 -0
- package/src/drive-tools/register.ts +1 -1
- package/src/media-duration.ts +43 -8
- package/src/media.ts +21 -11
- package/src/message-tools/actions.ts +154 -0
- package/src/message-tools/index.ts +2 -0
- package/src/message-tools/register.ts +58 -0
- package/src/message-tools/schemas.ts +56 -0
- package/src/reaction-tools/actions.ts +127 -0
- package/src/reaction-tools/index.ts +2 -0
- package/src/reaction-tools/register.ts +51 -0
- package/src/reaction-tools/schemas.ts +34 -0
- package/src/reply-dispatcher.ts +23 -33
- package/src/streaming-card.ts +34 -11
- package/src/tools-common/tool-context.ts +1 -0
- package/src/tools-config.ts +2 -0
- package/src/types.ts +4 -0
package/README.md
CHANGED
|
@@ -16,6 +16,22 @@ Feishu/Lark (飞书) channel plugin for [OpenClaw](https://github.com/openclaw/o
|
|
|
16
16
|
|
|
17
17
|
---
|
|
18
18
|
|
|
19
|
+
## Sponsor
|
|
20
|
+
|
|
21
|
+

|
|
22
|
+
|
|
23
|
+
Thanks to our sponsors for supporting the project.
|
|
24
|
+
|
|
25
|
+
If you need an API key platform whose models can be used as an OpenClaw provider and work with Kimi, MiniMax, Claude Code, Codex, or direct API usage, YouYun Zhisuan is worth considering:
|
|
26
|
+
|
|
27
|
+
> UCloud's YouYun Zhisuan is an AI cloud platform that provides stable and comprehensive domestic and international model APIs through a single API key. Its high-value Coding Plan offers both monthly and pay-as-you-go options, typically at 20% to 50% of official pricing. It supports Claude Code, Codex, and direct API integrations, as well as enterprise-grade high concurrency, 24/7 technical support, and self-service invoicing. Register through [this link](https://passport.compshare.cn/register?referral_code=5oWQU772rtSCeExpcLFxwy&ytag=GPU_YY_YX_git_clawdbot-feishu) to receive a free RMB 5 platform trial credit.
|
|
28
|
+
|
|
29
|
+
感谢赞助方对本项目的支持。
|
|
30
|
+
|
|
31
|
+
如果你需要一个可作为 OpenClaw 模型 provider,并适用于 Kimi、MiniMax、Claude Code、Codex 或直接 API 调用的稳定 API Key 平台,可以了解一下优云智算:
|
|
32
|
+
|
|
33
|
+
> 优云智算是UCloud旗下AI云平台,提供稳定、全面的国内外模型API,仅一个key即可调用。主打包月、按量的高性价比 Coding Plan 套餐,基于官方2~5折优惠。支持接入 Claude Code、Codex 及 API 调用。支持企业高并发、7*24技术支持、自助开票。通过[此链接](https://passport.compshare.cn/register?referral_code=5oWQU772rtSCeExpcLFxwy&ytag=GPU_YY_YX_git_clawdbot-feishu)注册的用户,可得免费5元平台体验金!
|
|
34
|
+
|
|
19
35
|
## English
|
|
20
36
|
|
|
21
37
|
### Installation
|
|
@@ -113,6 +129,9 @@ openclaw plugins list | rg -i feishu
|
|
|
113
129
|
| `task:attachment:read` | `feishu_task_attachment_list`, `feishu_task_attachment_get` | List/get task attachments |
|
|
114
130
|
| `im:chat.announcement:read` | `feishu_chat` | Read group announcement |
|
|
115
131
|
| `im:chat:readonly` | `feishu_chat` | Get chat info, check bot membership |
|
|
132
|
+
| `im:message` or `im:message:readonly` | `feishu_message` | Read single/DM messages |
|
|
133
|
+
| `im:message.group_at_msg:readonly` | `feishu_message` | Read all messages in group chats |
|
|
134
|
+
| `im:message.reactions:read` | `feishu_reaction` | List reactions on messages |
|
|
116
135
|
|
|
117
136
|
**Read-write** (optional, for create/edit/delete operations):
|
|
118
137
|
|
|
@@ -131,6 +150,7 @@ openclaw plugins list | rg -i feishu
|
|
|
131
150
|
| `im:chat.announcement` | `feishu_chat` | Write/update group announcement |
|
|
132
151
|
| `im:chat` | `feishu_chat` | Create and delete group chats |
|
|
133
152
|
| `im:chat.members` | `feishu_chat` | Add members to group chats |
|
|
153
|
+
| `im:message.reactions:write_only` | `feishu_reaction` | Add and remove reactions |
|
|
134
154
|
|
|
135
155
|
> Task scope names may vary slightly in Feishu console UI. If needed, search for Task / Tasklist / Comment / Attachment-related permissions and grant read/write accordingly.
|
|
136
156
|
|
|
@@ -479,6 +499,8 @@ session:
|
|
|
479
499
|
- **Task tools**: Create, get details, update, and delete tasks via Feishu Task v2 API
|
|
480
500
|
- **Chat tools**: Read and write group announcements, create group chats, add members, check bot membership, delete chats (`feishu_chat`)
|
|
481
501
|
- **Urgent notification tools**: Send buzz/urgent notifications (app, SMS, voice call) via `feishu_urgent`
|
|
502
|
+
- **Message tools**: Read single messages or list recent chat history with time range and pagination (`feishu_message`)
|
|
503
|
+
- **Reaction tools**: Add, remove, and list emoji reactions on messages (`feishu_reaction`)
|
|
482
504
|
- **@mention forwarding**: When you @mention someone in your message, the bot's reply will automatically @mention them too
|
|
483
505
|
- **Permission error notification**: When the bot encounters a Feishu API permission error, it automatically notifies the user with the permission grant URL
|
|
484
506
|
- **Dynamic agent creation**: Each DM user can have their own isolated agent instance with dedicated workspace (optional)
|
|
@@ -629,6 +651,9 @@ openclaw plugins list | rg -i feishu
|
|
|
629
651
|
| `task:attachment:read` | `feishu_task_attachment_list`, `feishu_task_attachment_get` | 列出/获取任务附件 |
|
|
630
652
|
| `im:chat.announcement:read` | `feishu_chat` | 读取群公告 |
|
|
631
653
|
| `im:chat:readonly` | `feishu_chat` | 获取群信息、检查机器人是否在群内 |
|
|
654
|
+
| `im:message` 或 `im:message:readonly` | `feishu_message` | 读取单条/私聊消息 |
|
|
655
|
+
| `im:message.group_at_msg:readonly` | `feishu_message` | 读取群聊中所有消息 |
|
|
656
|
+
| `im:message.reactions:read` | `feishu_reaction` | 查看消息表情回复 |
|
|
632
657
|
|
|
633
658
|
**读写权限**(可选,用于创建/编辑/删除操作):
|
|
634
659
|
|
|
@@ -647,6 +672,7 @@ openclaw plugins list | rg -i feishu
|
|
|
647
672
|
| `im:chat.announcement` | `feishu_chat` | 写入/更新群公告 |
|
|
648
673
|
| `im:chat` | `feishu_chat` | 创建和删除群聊 |
|
|
649
674
|
| `im:chat.members` | `feishu_chat` | 向群聊添加成员 |
|
|
675
|
+
| `im:message.reactions:write_only` | `feishu_reaction` | 发送、删除消息表情回复 |
|
|
650
676
|
|
|
651
677
|
> 飞书控制台中任务权限的显示名称可能略有差异,必要时可按关键字 `task` / `tasklist` / `comment` / `attachment` 搜索并授予对应读写权限。
|
|
652
678
|
|
|
@@ -994,6 +1020,8 @@ session:
|
|
|
994
1020
|
- **任务工具**:基于 Task v2 API 支持任务创建、获取详情、更新和删除
|
|
995
1021
|
- **群聊工具**:读写群公告、创建群聊、添加成员、检查机器人是否在群内、删除群聊(`feishu_chat`)
|
|
996
1022
|
- **加急通知工具**:发送应用内加急(buzz)、短信、语音电话加急通知(`feishu_urgent`)
|
|
1023
|
+
- **消息工具**:读取单条消息或列出聊天历史记录,支持时间范围和分页(`feishu_message`)
|
|
1024
|
+
- **表情工具**:添加、删除、列出消息上的表情回复(`feishu_reaction`)
|
|
997
1025
|
- **@ 转发功能**:在消息中 @ 某人,机器人的回复会自动 @ 该用户
|
|
998
1026
|
- **权限错误提示**:当机器人遇到飞书 API 权限错误时,会自动通知用户并提供权限授权链接
|
|
999
1027
|
- **动态 Agent 创建**:每个私聊用户可拥有独立的 agent 实例和专属 workspace(可选)
|
package/index.ts
CHANGED
|
@@ -9,6 +9,8 @@ import { registerFeishuPermTools } from "./src/perm-tools/index.js";
|
|
|
9
9
|
import { registerFeishuTaskTools } from "./src/task-tools/index.js";
|
|
10
10
|
import { registerFeishuChatTools } from "./src/chat-tools/index.js";
|
|
11
11
|
import { registerFeishuUrgentTools } from "./src/urgent-tools/index.js";
|
|
12
|
+
import { registerFeishuMessageTools } from "./src/message-tools/index.js";
|
|
13
|
+
import { registerFeishuReactionTools } from "./src/reaction-tools/index.js";
|
|
12
14
|
import { registerFeishuWikiTools } from "./src/wiki-tools/index.js";
|
|
13
15
|
|
|
14
16
|
export { monitorFeishuProvider } from "./src/monitor.js";
|
|
@@ -64,6 +66,8 @@ const plugin = {
|
|
|
64
66
|
registerFeishuTaskTools(api);
|
|
65
67
|
registerFeishuChatTools(api);
|
|
66
68
|
registerFeishuUrgentTools(api);
|
|
69
|
+
registerFeishuMessageTools(api);
|
|
70
|
+
registerFeishuReactionTools(api);
|
|
67
71
|
},
|
|
68
72
|
};
|
|
69
73
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@m1heng-clawd/feishu",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.17",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "OpenClaw Feishu/Lark channel plugin",
|
|
6
6
|
"scripts": {
|
|
@@ -57,6 +57,7 @@
|
|
|
57
57
|
"@larksuiteoapi/node-sdk": "^1.59.0",
|
|
58
58
|
"@sinclair/typebox": "0.34.48",
|
|
59
59
|
"https-proxy-agent": "^7.0.6",
|
|
60
|
+
"music-metadata": "^11.12.1",
|
|
60
61
|
"zod": "^4.3.6"
|
|
61
62
|
},
|
|
62
63
|
"devDependencies": {
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: feishu-message
|
|
3
|
+
description: |
|
|
4
|
+
Feishu message reading. Activate when user mentions reading messages, chat history, message lookup, or finding previous messages.
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Feishu Message Tool
|
|
8
|
+
|
|
9
|
+
Single tool `feishu_message` for reading Feishu messages — get a single message by ID or list recent messages in a chat.
|
|
10
|
+
|
|
11
|
+
## Actions
|
|
12
|
+
|
|
13
|
+
### Get Single Message
|
|
14
|
+
|
|
15
|
+
```json
|
|
16
|
+
{
|
|
17
|
+
"action": "get",
|
|
18
|
+
"message_id": "om_xxx"
|
|
19
|
+
}
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
|
|
24
|
+
```json
|
|
25
|
+
{
|
|
26
|
+
"ok": true,
|
|
27
|
+
"action": "get",
|
|
28
|
+
"found": true,
|
|
29
|
+
"message_id": "om_xxx",
|
|
30
|
+
"msg_type": "text",
|
|
31
|
+
"content": "Hello world",
|
|
32
|
+
"sender_id": "ou_xxx",
|
|
33
|
+
"sender_type": "user",
|
|
34
|
+
"chat_id": "oc_xxx",
|
|
35
|
+
"create_time": "1710000000000",
|
|
36
|
+
"update_time": "1710000000000",
|
|
37
|
+
"mentions": []
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### List Recent Messages
|
|
42
|
+
|
|
43
|
+
List recent messages in a chat (DM or group), newest first by default.
|
|
44
|
+
Omit `chat_id` to use the current conversation's chat:
|
|
45
|
+
|
|
46
|
+
```json
|
|
47
|
+
{
|
|
48
|
+
"action": "list"
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
With custom page size, sort order, and time range:
|
|
53
|
+
|
|
54
|
+
```json
|
|
55
|
+
{
|
|
56
|
+
"action": "list",
|
|
57
|
+
"chat_id": "oc_xxx",
|
|
58
|
+
"page_size": 20,
|
|
59
|
+
"sort_type": "ByCreateTimeAsc",
|
|
60
|
+
"start_time": "1710000000",
|
|
61
|
+
"end_time": "1710086400"
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
|
|
67
|
+
```json
|
|
68
|
+
{
|
|
69
|
+
"ok": true,
|
|
70
|
+
"action": "list",
|
|
71
|
+
"chat_id": "oc_xxx",
|
|
72
|
+
"total": 10,
|
|
73
|
+
"messages": [
|
|
74
|
+
{
|
|
75
|
+
"message_id": "om_xxx",
|
|
76
|
+
"msg_type": "text",
|
|
77
|
+
"content_preview": "Hello world",
|
|
78
|
+
"sender_id": "ou_xxx",
|
|
79
|
+
"sender_type": "user",
|
|
80
|
+
"create_time": "1710000000000",
|
|
81
|
+
"chat_id": "oc_xxx"
|
|
82
|
+
}
|
|
83
|
+
]
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Parameters
|
|
88
|
+
|
|
89
|
+
| Parameter | Required | Description |
|
|
90
|
+
|-----------|----------|-------------|
|
|
91
|
+
| `action` | Yes | `get` or `list` |
|
|
92
|
+
| `message_id` | get: Yes | Feishu message ID (e.g., `om_xxx`) |
|
|
93
|
+
| `chat_id` | list: Optional | Chat ID (e.g., `oc_xxx`). Omit to use current chat. |
|
|
94
|
+
| `page_size` | list: Optional | Number of messages (default: 10, max: 50) |
|
|
95
|
+
| `sort_type` | list: Optional | `ByCreateTimeDesc` (default) or `ByCreateTimeAsc` |
|
|
96
|
+
| `start_time` | list: Optional | Unix timestamp in seconds (e.g., `"1710000000"`). No lower bound if omitted. |
|
|
97
|
+
| `end_time` | list: Optional | Unix timestamp in seconds (e.g., `"1710086400"`). No upper bound if omitted. |
|
|
98
|
+
|
|
99
|
+
## Configuration
|
|
100
|
+
|
|
101
|
+
```yaml
|
|
102
|
+
channels:
|
|
103
|
+
feishu:
|
|
104
|
+
tools:
|
|
105
|
+
message: true # default: true
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Permissions
|
|
109
|
+
|
|
110
|
+
- `im:message` or `im:message:readonly` — read single/DM messages
|
|
111
|
+
- `im:message.group_at_msg:readonly` — read all messages in group chats (not just @bot messages)
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: feishu-reaction
|
|
3
|
+
description: |
|
|
4
|
+
Feishu message emoji reactions. Activate when user mentions emoji, reaction, thumbsup, like, or responding to messages with emoji.
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Feishu Reaction Tool
|
|
8
|
+
|
|
9
|
+
Single tool `feishu_reaction` for adding, removing, and listing emoji reactions on Feishu messages.
|
|
10
|
+
|
|
11
|
+
## Workflow: Reacting to Previous Messages
|
|
12
|
+
|
|
13
|
+
To react to a message other than the current one, first use `feishu_message` with `action: "list"` to fetch recent messages and find the target `message_id`, then use `feishu_reaction` with that `message_id`.
|
|
14
|
+
|
|
15
|
+
## Actions
|
|
16
|
+
|
|
17
|
+
### Add Reaction
|
|
18
|
+
|
|
19
|
+
```json
|
|
20
|
+
{
|
|
21
|
+
"action": "add",
|
|
22
|
+
"message_id": "om_xxx",
|
|
23
|
+
"emoji_type": "THUMBSUP"
|
|
24
|
+
}
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
|
|
29
|
+
```json
|
|
30
|
+
{
|
|
31
|
+
"ok": true,
|
|
32
|
+
"action": "add",
|
|
33
|
+
"message_id": "om_xxx",
|
|
34
|
+
"emoji_type": "THUMBSUP",
|
|
35
|
+
"reaction_id": "ZCaCIjUBVVWSrm5L-3ZTw"
|
|
36
|
+
}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### Remove Reaction
|
|
40
|
+
|
|
41
|
+
```json
|
|
42
|
+
{
|
|
43
|
+
"action": "remove",
|
|
44
|
+
"message_id": "om_xxx",
|
|
45
|
+
"reaction_id": "ZCaCIjUBVVWSrm5L-3ZTw"
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### List Reactions
|
|
50
|
+
|
|
51
|
+
List all reactions on a message:
|
|
52
|
+
|
|
53
|
+
```json
|
|
54
|
+
{
|
|
55
|
+
"action": "list",
|
|
56
|
+
"message_id": "om_xxx"
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Filter by emoji type:
|
|
61
|
+
|
|
62
|
+
```json
|
|
63
|
+
{
|
|
64
|
+
"action": "list",
|
|
65
|
+
"message_id": "om_xxx",
|
|
66
|
+
"emoji_type": "THUMBSUP"
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
|
|
72
|
+
```json
|
|
73
|
+
{
|
|
74
|
+
"ok": true,
|
|
75
|
+
"action": "list",
|
|
76
|
+
"message_id": "om_xxx",
|
|
77
|
+
"emoji_type_filter": "THUMBSUP",
|
|
78
|
+
"total": 2,
|
|
79
|
+
"reactions": [
|
|
80
|
+
{
|
|
81
|
+
"reaction_id": "ZCaCIjUBVVWSrm5L-3ZTw",
|
|
82
|
+
"emoji_type": "THUMBSUP",
|
|
83
|
+
"operator_type": "user",
|
|
84
|
+
"operator_id": "ou_xxx"
|
|
85
|
+
}
|
|
86
|
+
]
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Parameters
|
|
91
|
+
|
|
92
|
+
| Parameter | Required | Description |
|
|
93
|
+
|-----------|----------|-------------|
|
|
94
|
+
| `action` | Yes | `add`, `remove`, or `list` |
|
|
95
|
+
| `message_id` | Yes | Feishu message ID (e.g., `om_xxx`) |
|
|
96
|
+
| `emoji_type` | add: Yes, list: Optional | Emoji type (e.g., `THUMBSUP`, `HEART`, `SMILE`) |
|
|
97
|
+
| `reaction_id` | remove: Yes | Reaction ID from add or list results |
|
|
98
|
+
|
|
99
|
+
## Common Emoji Types
|
|
100
|
+
|
|
101
|
+
| Emoji | Type |
|
|
102
|
+
|-------|------|
|
|
103
|
+
| THUMBSUP | THUMBSDOWN | HEART | SMILE | GRINNING |
|
|
104
|
+
| FIRE | CLAP | OK | CHECK | CROSS |
|
|
105
|
+
| PARTY | PRAY | CRY | ANGRY | THINKING |
|
|
106
|
+
| SURPRISED | LAUGHING | FIST | QUESTION | EXCLAMATION |
|
|
107
|
+
|
|
108
|
+
## Configuration
|
|
109
|
+
|
|
110
|
+
```yaml
|
|
111
|
+
channels:
|
|
112
|
+
feishu:
|
|
113
|
+
tools:
|
|
114
|
+
reaction: true # default: true
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Permissions
|
|
118
|
+
|
|
119
|
+
- `im:message.reactions:write_only` — add and remove reactions
|
|
120
|
+
- `im:message.reactions:read` — list reactions on messages
|
package/src/bot.ts
CHANGED
|
@@ -1489,6 +1489,7 @@ export async function handleFeishuMessage(params: {
|
|
|
1489
1489
|
accountId: account.accountId,
|
|
1490
1490
|
sessionKey: route.sessionKey,
|
|
1491
1491
|
senderOpenId: ctx.senderOpenId,
|
|
1492
|
+
chatId: ctx.chatId,
|
|
1492
1493
|
},
|
|
1493
1494
|
// Keep account context available while the agent executes plugin tools.
|
|
1494
1495
|
() =>
|
|
@@ -1576,6 +1577,7 @@ export async function handleFeishuMessage(params: {
|
|
|
1576
1577
|
accountId: account.accountId,
|
|
1577
1578
|
sessionKey: route.sessionKey,
|
|
1578
1579
|
senderOpenId: ctx.senderOpenId,
|
|
1580
|
+
chatId: ctx.chatId,
|
|
1579
1581
|
},
|
|
1580
1582
|
// Tool calls produced by this turn should resolve to the same inbound account.
|
|
1581
1583
|
() =>
|
package/src/channel.ts
CHANGED
|
@@ -60,6 +60,8 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
|
|
|
60
60
|
messageToolHints: () => [
|
|
61
61
|
"- Feishu targeting: omit `target` to reply to the current conversation (auto-inferred). Explicit targets: `user:open_id` or `chat:chat_id`.",
|
|
62
62
|
"- Feishu supports interactive cards for rich messages.",
|
|
63
|
+
"- To send a file from the OpenClaw agent's local filesystem workspace (or another allowed local path) as a Feishu attachment, reply with a standalone `MEDIA:<absolute-path-or-url>` line.",
|
|
64
|
+
"- `feishu_drive` is for Feishu cloud Drive content, not for the OpenClaw agent's local filesystem workspace.",
|
|
63
65
|
],
|
|
64
66
|
},
|
|
65
67
|
groups: {
|
package/src/config-schema.ts
CHANGED
|
@@ -91,6 +91,8 @@ const FeishuToolsConfigSchema = z
|
|
|
91
91
|
task: z.boolean().optional(), // Task operations (default: true)
|
|
92
92
|
chat: z.boolean().optional(), // Chat management operations (default: true)
|
|
93
93
|
urgent: z.boolean().optional(), // Buzz/urgent notifications (default: true)
|
|
94
|
+
message: z.boolean().optional(), // Message reading (default: true)
|
|
95
|
+
reaction: z.boolean().optional(), // Emoji reactions (default: true)
|
|
94
96
|
})
|
|
95
97
|
.strict()
|
|
96
98
|
.optional();
|
|
@@ -59,7 +59,7 @@ export function registerFeishuDriveTools(api: OpenClawPluginApi) {
|
|
|
59
59
|
name: "feishu_drive",
|
|
60
60
|
label: "Feishu Drive",
|
|
61
61
|
description:
|
|
62
|
-
"Feishu cloud
|
|
62
|
+
"Feishu cloud Drive operations. This tool works with Feishu Drive folders/files/documents, not the OpenClaw agent's local filesystem workspace. Actions: list, info, create_folder, move, delete, import_document. Use 'import_document' to create documents from Markdown with better structure preservation than block-by-block writing.",
|
|
63
63
|
parameters: FeishuDriveSchema,
|
|
64
64
|
run: async ({ client, account }, params) => {
|
|
65
65
|
const mediaMaxBytes = (account.config?.mediaMaxMb ?? 30) * 1024 * 1024;
|
package/src/media-duration.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { parseBuffer } from "music-metadata";
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Media duration parsers for Feishu file upload.
|
|
3
5
|
*
|
|
@@ -9,11 +11,38 @@
|
|
|
9
11
|
* - MP4 (ISO base media) — MP4, MOV, QuickTime, M4A, M4V, 3GP share this format
|
|
10
12
|
* - WAV (RIFF PCM) — uncompressed audio
|
|
11
13
|
*
|
|
12
|
-
*
|
|
13
|
-
* - MP3 (VBR makes byte-count estimation unreliable without scanning all frames)
|
|
14
|
-
* - Raw AAC / ADTS
|
|
14
|
+
* Raw AAC / ADTS is delegated to music-metadata on the audio path.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
+
async function parseAudioDurationWithMusicMetadata(params: {
|
|
18
|
+
buffer: Buffer;
|
|
19
|
+
fileName?: string;
|
|
20
|
+
contentType?: string;
|
|
21
|
+
}): Promise<number | undefined> {
|
|
22
|
+
const { buffer, fileName, contentType } = params;
|
|
23
|
+
try {
|
|
24
|
+
const metadata = await parseBuffer(
|
|
25
|
+
buffer,
|
|
26
|
+
{
|
|
27
|
+
mimeType: contentType,
|
|
28
|
+
path: fileName,
|
|
29
|
+
size: buffer.length,
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
duration: true,
|
|
33
|
+
skipCovers: true,
|
|
34
|
+
},
|
|
35
|
+
);
|
|
36
|
+
const seconds = metadata.format.duration;
|
|
37
|
+
if (typeof seconds !== "number" || !Number.isFinite(seconds) || seconds <= 0) {
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
return Math.round(seconds * 1000);
|
|
41
|
+
} catch {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
17
46
|
/**
|
|
18
47
|
* Parse duration from an OGG container (Opus or Vorbis).
|
|
19
48
|
* Reads the granule position from the last OGG page and divides by sample rate.
|
|
@@ -174,17 +203,23 @@ export function parseWavDurationMs(buffer: Buffer): number | undefined {
|
|
|
174
203
|
*
|
|
175
204
|
* Routes to the appropriate parser based on Feishu's file type:
|
|
176
205
|
* - "mp4" → MP4/MOV/QuickTime container parser
|
|
177
|
-
* - "opus" → OGG parser, then MP4 container
|
|
206
|
+
* - "opus" → music-metadata first, then OGG parser, then MP4 container, then WAV
|
|
178
207
|
*
|
|
179
208
|
* Returns duration in milliseconds, or undefined if not determinable.
|
|
180
209
|
*/
|
|
181
|
-
export function parseFeishuMediaDurationMs(
|
|
210
|
+
export async function parseFeishuMediaDurationMs(
|
|
182
211
|
buffer: Buffer,
|
|
183
212
|
fileType: "opus" | "mp4",
|
|
184
|
-
|
|
213
|
+
options?: { fileName?: string; contentType?: string },
|
|
214
|
+
): Promise<number | undefined> {
|
|
185
215
|
if (fileType === "mp4") {
|
|
186
216
|
return parseMp4DurationMs(buffer);
|
|
187
217
|
}
|
|
188
|
-
|
|
189
|
-
|
|
218
|
+
return (
|
|
219
|
+
await parseAudioDurationWithMusicMetadata({
|
|
220
|
+
buffer,
|
|
221
|
+
fileName: options?.fileName,
|
|
222
|
+
contentType: options?.contentType,
|
|
223
|
+
})
|
|
224
|
+
) ?? parseOggDurationMs(buffer) ?? parseMp4DurationMs(buffer) ?? parseWavDurationMs(buffer);
|
|
190
225
|
}
|
package/src/media.ts
CHANGED
|
@@ -192,6 +192,12 @@ export type SendMediaResult = {
|
|
|
192
192
|
chatId: string;
|
|
193
193
|
};
|
|
194
194
|
|
|
195
|
+
function assertNonEmptyMediaBuffer(buffer: Buffer, name: string): void {
|
|
196
|
+
if (buffer.length === 0) {
|
|
197
|
+
throw new Error(`Feishu media upload failed: "${name}" is empty (0 bytes)`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
195
201
|
/**
|
|
196
202
|
* Upload an image to Feishu and get an image_key for sending.
|
|
197
203
|
* Supports: JPEG, PNG, WEBP, GIF, TIFF, BMP, ICO
|
|
@@ -210,15 +216,16 @@ export async function uploadImageFeishu(params: {
|
|
|
210
216
|
|
|
211
217
|
const client = createFeishuClient(account);
|
|
212
218
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
219
|
+
if (Buffer.isBuffer(image)) {
|
|
220
|
+
assertNonEmptyMediaBuffer(image, "image");
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const imagePayload = typeof image === "string" ? fs.createReadStream(image) : image;
|
|
217
224
|
|
|
218
225
|
const response = await client.im.image.create({
|
|
219
226
|
data: {
|
|
220
227
|
image_type: imageType,
|
|
221
|
-
image:
|
|
228
|
+
image: imagePayload as any,
|
|
222
229
|
},
|
|
223
230
|
});
|
|
224
231
|
|
|
@@ -258,16 +265,17 @@ export async function uploadFileFeishu(params: {
|
|
|
258
265
|
|
|
259
266
|
const client = createFeishuClient(account);
|
|
260
267
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
268
|
+
if (Buffer.isBuffer(file)) {
|
|
269
|
+
assertNonEmptyMediaBuffer(file, fileName);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const filePayload = typeof file === "string" ? fs.createReadStream(file) : file;
|
|
265
273
|
|
|
266
274
|
const response = await client.im.file.create({
|
|
267
275
|
data: {
|
|
268
276
|
file_type: fileType,
|
|
269
277
|
file_name: fileName,
|
|
270
|
-
file:
|
|
278
|
+
file: filePayload as any,
|
|
271
279
|
...(duration !== undefined && { duration }),
|
|
272
280
|
},
|
|
273
281
|
});
|
|
@@ -512,6 +520,8 @@ export async function sendMediaFeishu(params: {
|
|
|
512
520
|
throw new Error("Either mediaUrl or mediaBuffer must be provided");
|
|
513
521
|
}
|
|
514
522
|
|
|
523
|
+
assertNonEmptyMediaBuffer(buffer, name);
|
|
524
|
+
|
|
515
525
|
// Determine if it's an image based on extension
|
|
516
526
|
const ext = path.extname(name).toLowerCase();
|
|
517
527
|
const isImage = [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".ico", ".tiff"].includes(ext);
|
|
@@ -530,7 +540,7 @@ export async function sendMediaFeishu(params: {
|
|
|
530
540
|
const msgType = fileType === "opus" ? "audio" : fileType === "mp4" ? "media" : "file";
|
|
531
541
|
const duration =
|
|
532
542
|
fileType === "opus" || fileType === "mp4"
|
|
533
|
-
? parseFeishuMediaDurationMs(buffer, fileType)
|
|
543
|
+
? await parseFeishuMediaDurationMs(buffer, fileType, { fileName: name, contentType })
|
|
534
544
|
: undefined;
|
|
535
545
|
const { fileKey } = await uploadFileFeishu({
|
|
536
546
|
cfg,
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import type * as Lark from "@larksuiteoapi/node-sdk";
|
|
2
|
+
import { runFeishuApiCall, type FeishuApiResponse } from "../tools-common/feishu-api.js";
|
|
3
|
+
import type { FeishuMessageParams } from "./schemas.js";
|
|
4
|
+
|
|
5
|
+
interface MessageGetResponse extends FeishuApiResponse {
|
|
6
|
+
data?: {
|
|
7
|
+
items?: Array<{
|
|
8
|
+
message_id?: string;
|
|
9
|
+
root_id?: string;
|
|
10
|
+
parent_id?: string;
|
|
11
|
+
msg_type?: string;
|
|
12
|
+
create_time?: string;
|
|
13
|
+
update_time?: string;
|
|
14
|
+
deleted?: boolean;
|
|
15
|
+
chat_id?: string;
|
|
16
|
+
sender?: { id: string; id_type: string; sender_type: string };
|
|
17
|
+
body?: { content: string };
|
|
18
|
+
mentions?: Array<{ key: string; id: string; id_type: string; name: string }>;
|
|
19
|
+
}>;
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface MessageListResponse extends FeishuApiResponse {
|
|
24
|
+
data?: {
|
|
25
|
+
has_more?: boolean;
|
|
26
|
+
page_token?: string;
|
|
27
|
+
items?: Array<{
|
|
28
|
+
message_id?: string;
|
|
29
|
+
root_id?: string;
|
|
30
|
+
parent_id?: string;
|
|
31
|
+
msg_type?: string;
|
|
32
|
+
create_time?: string;
|
|
33
|
+
deleted?: boolean;
|
|
34
|
+
chat_id?: string;
|
|
35
|
+
sender?: { id: string; id_type: string; sender_type: string };
|
|
36
|
+
body?: { content: string };
|
|
37
|
+
}>;
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function runMessageAction(
|
|
42
|
+
client: Lark.Client,
|
|
43
|
+
params: FeishuMessageParams,
|
|
44
|
+
): Promise<unknown> {
|
|
45
|
+
switch (params.action) {
|
|
46
|
+
case "get":
|
|
47
|
+
return getMessage(client, params);
|
|
48
|
+
case "list":
|
|
49
|
+
return listMessages(client, params);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function parseMessageContent(msgType: string | undefined, rawContent: string | undefined): string {
|
|
54
|
+
if (!rawContent) return "";
|
|
55
|
+
try {
|
|
56
|
+
const parsed = JSON.parse(rawContent);
|
|
57
|
+
if (msgType === "text") return (parsed.text as string) ?? rawContent;
|
|
58
|
+
if (msgType === "post") {
|
|
59
|
+
const title = (parsed.title as string) ?? "";
|
|
60
|
+
const lines: string[] = [];
|
|
61
|
+
for (const paragraph of (parsed.content as Array<Array<{ tag: string; text?: string }>>) ?? []) {
|
|
62
|
+
lines.push(paragraph.map((el) => el.text ?? "").join(""));
|
|
63
|
+
}
|
|
64
|
+
return title ? `${title}\n${lines.join("\n")}` : lines.join("\n");
|
|
65
|
+
}
|
|
66
|
+
if (msgType === "image") return "[image]";
|
|
67
|
+
if (msgType === "file") return `[file: ${(parsed.file_name as string) ?? ""}]`;
|
|
68
|
+
if (msgType === "audio") return "[audio]";
|
|
69
|
+
if (msgType === "sticker") return "[sticker]";
|
|
70
|
+
if (msgType === "share_chat") return "[share_chat]";
|
|
71
|
+
if (msgType === "share_user") return "[share_user]";
|
|
72
|
+
return `[${msgType ?? "unknown"}]`;
|
|
73
|
+
} catch {
|
|
74
|
+
return rawContent;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function getMessage(client: Lark.Client, params: FeishuMessageParams) {
|
|
79
|
+
if (!params.message_id) {
|
|
80
|
+
throw new Error("message_id is required for action=get");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const response = await runFeishuApiCall<MessageGetResponse>(
|
|
84
|
+
"Feishu get message",
|
|
85
|
+
() =>
|
|
86
|
+
client.im.message.get({
|
|
87
|
+
path: { message_id: params.message_id! },
|
|
88
|
+
}) as Promise<MessageGetResponse>,
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
const item = response.data?.items?.[0];
|
|
92
|
+
if (!item) {
|
|
93
|
+
return { ok: true, action: "get", message_id: params.message_id, found: false };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
ok: true,
|
|
98
|
+
action: "get",
|
|
99
|
+
found: true,
|
|
100
|
+
message_id: item.message_id ?? params.message_id,
|
|
101
|
+
msg_type: item.msg_type ?? "",
|
|
102
|
+
content: parseMessageContent(item.msg_type, item.body?.content),
|
|
103
|
+
sender_id: item.sender?.id ?? "",
|
|
104
|
+
sender_type: item.sender?.sender_type ?? "",
|
|
105
|
+
chat_id: item.chat_id ?? "",
|
|
106
|
+
create_time: item.create_time ?? "",
|
|
107
|
+
update_time: item.update_time ?? "",
|
|
108
|
+
mentions: item.mentions?.map((m) => ({ id: m.id, name: m.name })) ?? [],
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function listMessages(client: Lark.Client, params: FeishuMessageParams) {
|
|
113
|
+
if (!params.chat_id) {
|
|
114
|
+
throw new Error("chat_id is required for action=list");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const pageSize = Math.min(Math.max(params.page_size ?? 10, 1), 50);
|
|
118
|
+
const sortType = params.sort_type ?? "ByCreateTimeDesc";
|
|
119
|
+
|
|
120
|
+
const response = await runFeishuApiCall<MessageListResponse>(
|
|
121
|
+
"Feishu list messages",
|
|
122
|
+
() =>
|
|
123
|
+
client.im.message.list({
|
|
124
|
+
params: {
|
|
125
|
+
container_id_type: "chat",
|
|
126
|
+
container_id: params.chat_id!,
|
|
127
|
+
sort_type: sortType,
|
|
128
|
+
page_size: pageSize,
|
|
129
|
+
...(params.start_time ? { start_time: params.start_time } : {}),
|
|
130
|
+
...(params.end_time ? { end_time: params.end_time } : {}),
|
|
131
|
+
},
|
|
132
|
+
}) as Promise<MessageListResponse>,
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
const messages = (response.data?.items ?? [])
|
|
136
|
+
.filter((item) => !item.deleted)
|
|
137
|
+
.map((item) => ({
|
|
138
|
+
message_id: item.message_id ?? "",
|
|
139
|
+
msg_type: item.msg_type ?? "",
|
|
140
|
+
content_preview: parseMessageContent(item.msg_type, item.body?.content),
|
|
141
|
+
sender_id: item.sender?.id ?? "",
|
|
142
|
+
sender_type: item.sender?.sender_type ?? "",
|
|
143
|
+
create_time: item.create_time ?? "",
|
|
144
|
+
chat_id: item.chat_id ?? "",
|
|
145
|
+
}));
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
ok: true,
|
|
149
|
+
action: "list",
|
|
150
|
+
chat_id: params.chat_id,
|
|
151
|
+
total: messages.length,
|
|
152
|
+
messages,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
2
|
+
import { hasFeishuToolEnabledForAnyAccount, withFeishuToolClient } from "../tools-common/tool-exec.js";
|
|
3
|
+
import { getCurrentFeishuToolContext } from "../tools-common/tool-context.js";
|
|
4
|
+
import { errorResult, json } from "../tools-common/feishu-api.js";
|
|
5
|
+
import { runMessageAction } from "./actions.js";
|
|
6
|
+
import { FeishuMessageSchema, type FeishuMessageParams } from "./schemas.js";
|
|
7
|
+
|
|
8
|
+
export function registerFeishuMessageTools(api: OpenClawPluginApi) {
|
|
9
|
+
if (!api.config) {
|
|
10
|
+
api.logger.debug?.("feishu_message: No config available, skipping");
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if (!hasFeishuToolEnabledForAnyAccount(api.config)) {
|
|
15
|
+
api.logger.debug?.("feishu_message: No Feishu accounts configured, skipping");
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (!hasFeishuToolEnabledForAnyAccount(api.config, "message")) {
|
|
20
|
+
api.logger.debug?.("feishu_message: message tool disabled in config");
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
api.registerTool(
|
|
25
|
+
{
|
|
26
|
+
name: "feishu_message",
|
|
27
|
+
label: "Feishu Message",
|
|
28
|
+
description:
|
|
29
|
+
"Read Feishu messages. Actions: get (get a single message by message_id), list (list recent messages in a chat). " +
|
|
30
|
+
"Use list to discover message_ids for use with other tools (e.g., feishu_reaction).",
|
|
31
|
+
parameters: FeishuMessageSchema,
|
|
32
|
+
async execute(_toolCallId, params) {
|
|
33
|
+
const p = params as FeishuMessageParams;
|
|
34
|
+
if (p.action === "list") {
|
|
35
|
+
const isValidChatId = p.chat_id?.startsWith("oc_");
|
|
36
|
+
if (!isValidChatId) {
|
|
37
|
+
const ctx = getCurrentFeishuToolContext();
|
|
38
|
+
if (ctx?.chatId) p.chat_id = ctx.chatId;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
return await withFeishuToolClient({
|
|
44
|
+
api,
|
|
45
|
+
toolName: "feishu_message",
|
|
46
|
+
requiredTool: "message",
|
|
47
|
+
run: async ({ client }) => json(await runMessageAction(client, p)),
|
|
48
|
+
});
|
|
49
|
+
} catch (err) {
|
|
50
|
+
return errorResult(err);
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
{ name: "feishu_message" },
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
api.logger.debug?.("feishu_message: Registered feishu_message tool");
|
|
58
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { Type, type Static } from "@sinclair/typebox";
|
|
2
|
+
|
|
3
|
+
function stringEnum<T extends readonly string[]>(
|
|
4
|
+
values: T,
|
|
5
|
+
options: { description?: string; default?: T[number] } = {},
|
|
6
|
+
) {
|
|
7
|
+
return Type.Unsafe<T[number]>({ type: "string", enum: [...values], ...options });
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const ACTION_VALUES = ["get", "list"] as const;
|
|
11
|
+
const SORT_VALUES = ["ByCreateTimeAsc", "ByCreateTimeDesc"] as const;
|
|
12
|
+
|
|
13
|
+
export const FeishuMessageSchema = Type.Object({
|
|
14
|
+
action: stringEnum(ACTION_VALUES, {
|
|
15
|
+
description:
|
|
16
|
+
"Action to perform: get (get a single message by message_id), list (list recent messages in a chat by chat_id).",
|
|
17
|
+
}),
|
|
18
|
+
message_id: Type.Optional(
|
|
19
|
+
Type.String({
|
|
20
|
+
description: "Feishu message ID (e.g. om_xxx). Required for action=get.",
|
|
21
|
+
}),
|
|
22
|
+
),
|
|
23
|
+
chat_id: Type.Optional(
|
|
24
|
+
Type.String({
|
|
25
|
+
description: "Chat ID (e.g. oc_xxx). Required for action=list. Omit to use the current conversation's chat.",
|
|
26
|
+
}),
|
|
27
|
+
),
|
|
28
|
+
page_size: Type.Optional(
|
|
29
|
+
Type.Integer({
|
|
30
|
+
description: "Number of messages to fetch for action=list (default: 10, max: 50).",
|
|
31
|
+
minimum: 1,
|
|
32
|
+
maximum: 50,
|
|
33
|
+
default: 10,
|
|
34
|
+
}),
|
|
35
|
+
),
|
|
36
|
+
sort_type: Type.Optional(
|
|
37
|
+
stringEnum(SORT_VALUES, {
|
|
38
|
+
description: "Sort order for action=list. Default: ByCreateTimeDesc (newest first).",
|
|
39
|
+
default: "ByCreateTimeDesc",
|
|
40
|
+
}),
|
|
41
|
+
),
|
|
42
|
+
start_time: Type.Optional(
|
|
43
|
+
Type.String({
|
|
44
|
+
description:
|
|
45
|
+
"Start of time range for action=list, as Unix timestamp in seconds (e.g. \"1710000000\"). Omit for no lower bound.",
|
|
46
|
+
}),
|
|
47
|
+
),
|
|
48
|
+
end_time: Type.Optional(
|
|
49
|
+
Type.String({
|
|
50
|
+
description:
|
|
51
|
+
"End of time range for action=list, as Unix timestamp in seconds (e.g. \"1710086400\"). Omit for no upper bound.",
|
|
52
|
+
}),
|
|
53
|
+
),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
export type FeishuMessageParams = Static<typeof FeishuMessageSchema>;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import type * as Lark from "@larksuiteoapi/node-sdk";
|
|
2
|
+
import { runFeishuApiCall, type FeishuApiResponse } from "../tools-common/feishu-api.js";
|
|
3
|
+
import type { FeishuReactionParams } from "./schemas.js";
|
|
4
|
+
|
|
5
|
+
interface ReactionCreateResponse extends FeishuApiResponse {
|
|
6
|
+
data?: { reaction_id?: string };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface ReactionListResponse extends FeishuApiResponse {
|
|
10
|
+
data?: {
|
|
11
|
+
items?: Array<{
|
|
12
|
+
reaction_id?: string;
|
|
13
|
+
reaction_type?: { emoji_type?: string };
|
|
14
|
+
operator_type?: string;
|
|
15
|
+
operator_id?: { open_id?: string; user_id?: string; union_id?: string };
|
|
16
|
+
}>;
|
|
17
|
+
has_more?: boolean;
|
|
18
|
+
page_token?: string;
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function runReactionAction(
|
|
23
|
+
client: Lark.Client,
|
|
24
|
+
params: FeishuReactionParams,
|
|
25
|
+
): Promise<unknown> {
|
|
26
|
+
switch (params.action) {
|
|
27
|
+
case "add":
|
|
28
|
+
return addReaction(client, params);
|
|
29
|
+
case "remove":
|
|
30
|
+
return removeReaction(client, params);
|
|
31
|
+
case "list":
|
|
32
|
+
return listReactions(client, params);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function addReaction(client: Lark.Client, params: FeishuReactionParams) {
|
|
37
|
+
if (!params.emoji_type) {
|
|
38
|
+
throw new Error("emoji_type is required for action=add");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const response = await runFeishuApiCall<ReactionCreateResponse>(
|
|
42
|
+
"Feishu add reaction",
|
|
43
|
+
() =>
|
|
44
|
+
client.im.messageReaction.create({
|
|
45
|
+
path: { message_id: params.message_id },
|
|
46
|
+
data: { reaction_type: { emoji_type: params.emoji_type! } },
|
|
47
|
+
}) as Promise<ReactionCreateResponse>,
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
ok: true,
|
|
52
|
+
action: "add",
|
|
53
|
+
message_id: params.message_id,
|
|
54
|
+
emoji_type: params.emoji_type,
|
|
55
|
+
reaction_id: response.data?.reaction_id ?? null,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function removeReaction(client: Lark.Client, params: FeishuReactionParams) {
|
|
60
|
+
if (!params.reaction_id) {
|
|
61
|
+
throw new Error("reaction_id is required for action=remove");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
await runFeishuApiCall<FeishuApiResponse>(
|
|
65
|
+
"Feishu remove reaction",
|
|
66
|
+
() =>
|
|
67
|
+
client.im.messageReaction.delete({
|
|
68
|
+
path: {
|
|
69
|
+
message_id: params.message_id,
|
|
70
|
+
reaction_id: params.reaction_id!,
|
|
71
|
+
},
|
|
72
|
+
}) as Promise<FeishuApiResponse>,
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
ok: true,
|
|
77
|
+
action: "remove",
|
|
78
|
+
message_id: params.message_id,
|
|
79
|
+
reaction_id: params.reaction_id,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function listReactions(client: Lark.Client, params: FeishuReactionParams) {
|
|
84
|
+
const allItems: Array<{
|
|
85
|
+
reaction_id: string;
|
|
86
|
+
emoji_type: string;
|
|
87
|
+
operator_type: string;
|
|
88
|
+
operator_id: string;
|
|
89
|
+
}> = [];
|
|
90
|
+
|
|
91
|
+
let pageToken: string | undefined;
|
|
92
|
+
do {
|
|
93
|
+
const response = await runFeishuApiCall<ReactionListResponse>(
|
|
94
|
+
"Feishu list reactions",
|
|
95
|
+
() =>
|
|
96
|
+
client.im.messageReaction.list({
|
|
97
|
+
path: { message_id: params.message_id },
|
|
98
|
+
params: {
|
|
99
|
+
...(params.emoji_type ? { reaction_type: params.emoji_type } : {}),
|
|
100
|
+
...(pageToken ? { page_token: pageToken } : {}),
|
|
101
|
+
page_size: 50,
|
|
102
|
+
},
|
|
103
|
+
}) as Promise<ReactionListResponse>,
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
for (const item of response.data?.items ?? []) {
|
|
107
|
+
allItems.push({
|
|
108
|
+
reaction_id: item.reaction_id ?? "",
|
|
109
|
+
emoji_type: item.reaction_type?.emoji_type ?? "",
|
|
110
|
+
operator_type: item.operator_type ?? "user",
|
|
111
|
+
operator_id:
|
|
112
|
+
item.operator_id?.open_id ?? item.operator_id?.user_id ?? item.operator_id?.union_id ?? "",
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
pageToken = response.data?.has_more ? (response.data.page_token ?? undefined) : undefined;
|
|
117
|
+
} while (pageToken);
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
ok: true,
|
|
121
|
+
action: "list",
|
|
122
|
+
message_id: params.message_id,
|
|
123
|
+
emoji_type_filter: params.emoji_type ?? null,
|
|
124
|
+
total: allItems.length,
|
|
125
|
+
reactions: allItems,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
2
|
+
import { hasFeishuToolEnabledForAnyAccount, withFeishuToolClient } from "../tools-common/tool-exec.js";
|
|
3
|
+
import { errorResult, json } from "../tools-common/feishu-api.js";
|
|
4
|
+
import { runReactionAction } from "./actions.js";
|
|
5
|
+
import { FeishuReactionSchema, type FeishuReactionParams } from "./schemas.js";
|
|
6
|
+
|
|
7
|
+
export function registerFeishuReactionTools(api: OpenClawPluginApi) {
|
|
8
|
+
if (!api.config) {
|
|
9
|
+
api.logger.debug?.("feishu_reaction: No config available, skipping");
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
if (!hasFeishuToolEnabledForAnyAccount(api.config)) {
|
|
14
|
+
api.logger.debug?.("feishu_reaction: No Feishu accounts configured, skipping");
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (!hasFeishuToolEnabledForAnyAccount(api.config, "reaction")) {
|
|
19
|
+
api.logger.debug?.("feishu_reaction: reaction tool disabled in config");
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
api.registerTool(
|
|
24
|
+
{
|
|
25
|
+
name: "feishu_reaction",
|
|
26
|
+
label: "Feishu Reaction",
|
|
27
|
+
description:
|
|
28
|
+
"Add, remove, or list emoji reactions on Feishu messages. " +
|
|
29
|
+
"Supported actions: add (add an emoji reaction), remove (remove a reaction by ID), list (list all reactions). " +
|
|
30
|
+
"Common emoji types: THUMBSUP, THUMBSDOWN, HEART, SMILE, GRINNING, FIRE, CLAP, OK, CHECK, CROSS, PARTY, PRAY. " +
|
|
31
|
+
"To react to a previous message, first use feishu_message list to find the target message_id.",
|
|
32
|
+
parameters: FeishuReactionSchema,
|
|
33
|
+
async execute(_toolCallId, params) {
|
|
34
|
+
const p = params as FeishuReactionParams;
|
|
35
|
+
try {
|
|
36
|
+
return await withFeishuToolClient({
|
|
37
|
+
api,
|
|
38
|
+
toolName: "feishu_reaction",
|
|
39
|
+
requiredTool: "reaction",
|
|
40
|
+
run: async ({ client }) => json(await runReactionAction(client, p)),
|
|
41
|
+
});
|
|
42
|
+
} catch (err) {
|
|
43
|
+
return errorResult(err);
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
{ name: "feishu_reaction" },
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
api.logger.debug?.("feishu_reaction: Registered feishu_reaction tool");
|
|
51
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { Type, type Static } from "@sinclair/typebox";
|
|
2
|
+
|
|
3
|
+
function stringEnum<T extends readonly string[]>(
|
|
4
|
+
values: T,
|
|
5
|
+
options: { description?: string; default?: T[number] } = {},
|
|
6
|
+
) {
|
|
7
|
+
return Type.Unsafe<T[number]>({ type: "string", enum: [...values], ...options });
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const ACTION_VALUES = ["add", "remove", "list"] as const;
|
|
11
|
+
|
|
12
|
+
export const FeishuReactionSchema = Type.Object({
|
|
13
|
+
action: stringEnum(ACTION_VALUES, {
|
|
14
|
+
description:
|
|
15
|
+
"Action to perform: add (add emoji reaction to a message), remove (remove a reaction by reaction_id), list (list all reactions on a message).",
|
|
16
|
+
}),
|
|
17
|
+
message_id: Type.String({
|
|
18
|
+
description: "Feishu message ID (e.g. om_xxx). Use feishu_message list to find message_ids from chat history.",
|
|
19
|
+
}),
|
|
20
|
+
emoji_type: Type.Optional(
|
|
21
|
+
Type.String({
|
|
22
|
+
description:
|
|
23
|
+
"Emoji type to add or filter by (e.g. THUMBSUP, HEART, SMILE, GRINNING, FIRE, CLAP, OK, CHECK, CROSS). " +
|
|
24
|
+
"Required for action=add. Optional filter for action=list.",
|
|
25
|
+
}),
|
|
26
|
+
),
|
|
27
|
+
reaction_id: Type.Optional(
|
|
28
|
+
Type.String({
|
|
29
|
+
description: "Reaction ID to remove. Required for action=remove. Obtained from add or list results.",
|
|
30
|
+
}),
|
|
31
|
+
),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
export type FeishuReactionParams = Static<typeof FeishuReactionSchema>;
|
package/src/reply-dispatcher.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { buildMentionedCardContent, type MentionTarget } from "./mention.js";
|
|
|
12
12
|
import { normalizeFeishuMarkdownLinks } from "./text/markdown-links.js";
|
|
13
13
|
import { getFeishuRuntime } from "./runtime.js";
|
|
14
14
|
import { sendMarkdownCardFeishu, sendMessageFeishu } from "./send.js";
|
|
15
|
-
import { FeishuStreamingSession } from "./streaming-card.js";
|
|
15
|
+
import { FeishuStreamingSession, mergeStreamingText } from "./streaming-card.js";
|
|
16
16
|
import { resolveReceiveIdType } from "./targets.js";
|
|
17
17
|
import { addTypingIndicator, removeTypingIndicator, type TypingIndicatorState } from "./typing.js";
|
|
18
18
|
|
|
@@ -119,39 +119,15 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
|
|
|
119
119
|
let streaming: FeishuStreamingSession | null = null;
|
|
120
120
|
let streamText = "";
|
|
121
121
|
let lastPartial = "";
|
|
122
|
+
let streamingCompleted = false;
|
|
122
123
|
let partialUpdateQueue: Promise<void> = Promise.resolve();
|
|
123
124
|
let streamingStartPromise: Promise<void> | null = null;
|
|
124
125
|
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
streamText = nextText;
|
|
128
|
-
return;
|
|
129
|
-
}
|
|
130
|
-
if (nextText.startsWith(streamText)) {
|
|
131
|
-
// Handle cumulative partial payloads where nextText already includes prior text.
|
|
132
|
-
streamText = nextText;
|
|
133
|
-
return;
|
|
134
|
-
}
|
|
135
|
-
if (streamText.endsWith(nextText)) {
|
|
136
|
-
return;
|
|
137
|
-
}
|
|
138
|
-
streamText += nextText;
|
|
126
|
+
const mergeIntoStreamText = (nextText: string) => {
|
|
127
|
+
streamText = mergeStreamingText(streamText, nextText);
|
|
139
128
|
};
|
|
140
129
|
|
|
141
|
-
const
|
|
142
|
-
nextText: string,
|
|
143
|
-
options?: { dedupeWithLastPartial?: boolean },
|
|
144
|
-
) => {
|
|
145
|
-
if (!nextText) {
|
|
146
|
-
return;
|
|
147
|
-
}
|
|
148
|
-
if (options?.dedupeWithLastPartial && nextText === lastPartial) {
|
|
149
|
-
return;
|
|
150
|
-
}
|
|
151
|
-
if (options?.dedupeWithLastPartial) {
|
|
152
|
-
lastPartial = nextText;
|
|
153
|
-
}
|
|
154
|
-
mergeStreamingText(nextText);
|
|
130
|
+
const enqueueStreamingFlush = () => {
|
|
155
131
|
partialUpdateQueue = partialUpdateQueue.then(async () => {
|
|
156
132
|
if (streamingStartPromise) {
|
|
157
133
|
await streamingStartPromise;
|
|
@@ -163,7 +139,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
|
|
|
163
139
|
};
|
|
164
140
|
|
|
165
141
|
const startStreaming = () => {
|
|
166
|
-
if (!streamingEnabled || streamingStartPromise || streaming) {
|
|
142
|
+
if (!streamingEnabled || streamingStartPromise || streaming || streamingCompleted) {
|
|
167
143
|
return;
|
|
168
144
|
}
|
|
169
145
|
streamingStartPromise = (async () => {
|
|
@@ -198,6 +174,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
|
|
|
198
174
|
text = buildMentionedCardContent(mentionTargets, text);
|
|
199
175
|
}
|
|
200
176
|
await streaming.close(normalizeFeishuMarkdownLinks(text));
|
|
177
|
+
streamingCompleted = true;
|
|
201
178
|
}
|
|
202
179
|
streaming = null;
|
|
203
180
|
streamingStartPromise = null;
|
|
@@ -247,7 +224,8 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
|
|
|
247
224
|
if (info?.kind === "block") {
|
|
248
225
|
// Some runtimes emit block payloads without onPartial/final callbacks.
|
|
249
226
|
// Mirror block text into streamText so onIdle close still sends content.
|
|
250
|
-
|
|
227
|
+
mergeIntoStreamText(text);
|
|
228
|
+
enqueueStreamingFlush();
|
|
251
229
|
}
|
|
252
230
|
if (info?.kind === "final") {
|
|
253
231
|
streamText = text;
|
|
@@ -256,6 +234,13 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
|
|
|
256
234
|
return;
|
|
257
235
|
}
|
|
258
236
|
|
|
237
|
+
// Streaming card already delivered the content — skip regular send to
|
|
238
|
+
// avoid a duplicate card when the runtime delivers a second payload
|
|
239
|
+
// after the streaming session has closed (#399).
|
|
240
|
+
if (streamingCompleted) {
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
|
|
259
244
|
let first = true;
|
|
260
245
|
if (useCard) {
|
|
261
246
|
for (const chunk of core.channel.text.chunkTextWithMode(text, textChunkLimit, chunkMode)) {
|
|
@@ -312,10 +297,15 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
|
|
|
312
297
|
onPartialReply: streamingEnabled
|
|
313
298
|
? (payload: ReplyPayload) => {
|
|
314
299
|
const partialText = normalizeFeishuMarkdownLinks(payload.text ?? "");
|
|
315
|
-
if (!partialText) {
|
|
300
|
+
if (!partialText || partialText === lastPartial) {
|
|
316
301
|
return;
|
|
317
302
|
}
|
|
318
|
-
|
|
303
|
+
lastPartial = partialText;
|
|
304
|
+
// Partials are cumulative — replace streamText directly instead of
|
|
305
|
+
// merging, which avoids duplication when segment boundaries shift
|
|
306
|
+
// (e.g. after a tool call mid-stream).
|
|
307
|
+
streamText = partialText;
|
|
308
|
+
enqueueStreamingFlush();
|
|
319
309
|
}
|
|
320
310
|
: undefined,
|
|
321
311
|
},
|
package/src/streaming-card.ts
CHANGED
|
@@ -44,6 +44,21 @@ async function getToken(creds: Credentials): Promise<string> {
|
|
|
44
44
|
return data.tenant_access_token;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Find the length of the longest suffix of `a` that equals a prefix of `b`.
|
|
49
|
+
* Used to detect partial overlaps between consecutive streaming segments.
|
|
50
|
+
* Capped at 500 chars to keep cost bounded.
|
|
51
|
+
*/
|
|
52
|
+
function findOverlapLength(a: string, b: string): number {
|
|
53
|
+
const max = Math.min(a.length, b.length, 500);
|
|
54
|
+
for (let len = max; len > 0; len--) {
|
|
55
|
+
if (a.endsWith(b.slice(0, len))) {
|
|
56
|
+
return len;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return 0;
|
|
60
|
+
}
|
|
61
|
+
|
|
47
62
|
export function mergeStreamingText(
|
|
48
63
|
previousText: string | undefined,
|
|
49
64
|
nextText: string | undefined,
|
|
@@ -59,7 +74,14 @@ export function mergeStreamingText(
|
|
|
59
74
|
if (previous.includes(next)) {
|
|
60
75
|
return previous;
|
|
61
76
|
}
|
|
62
|
-
//
|
|
77
|
+
// Detect partial overlap: the end of previous matches the start of next.
|
|
78
|
+
// This happens when partials are cumulative within a segment but don't
|
|
79
|
+
// include prior segments (e.g. after a tool call mid-stream).
|
|
80
|
+
const overlap = findOverlapLength(previous, next);
|
|
81
|
+
if (overlap > 0) {
|
|
82
|
+
return previous + next.slice(overlap);
|
|
83
|
+
}
|
|
84
|
+
// No overlap — truly incremental chunk, just append.
|
|
63
85
|
return `${previous}${next}`;
|
|
64
86
|
}
|
|
65
87
|
|
|
@@ -147,30 +169,30 @@ export class FeishuStreamingSession {
|
|
|
147
169
|
}
|
|
148
170
|
|
|
149
171
|
async update(text: string): Promise<void> {
|
|
150
|
-
if (!this.state || this.closed) {
|
|
172
|
+
if (!this.state || this.closed || !text) {
|
|
151
173
|
return;
|
|
152
174
|
}
|
|
153
|
-
|
|
154
|
-
if (
|
|
175
|
+
// Caller sends cumulative full text — no merge needed, just throttle.
|
|
176
|
+
if (text === this.state.currentText) {
|
|
155
177
|
return;
|
|
156
178
|
}
|
|
157
179
|
const now = Date.now();
|
|
158
180
|
if (now - this.lastUpdateTime < this.updateThrottleMs) {
|
|
159
|
-
this.pendingText =
|
|
181
|
+
this.pendingText = text;
|
|
160
182
|
return;
|
|
161
183
|
}
|
|
162
184
|
this.pendingText = null;
|
|
163
185
|
this.lastUpdateTime = now;
|
|
164
186
|
|
|
187
|
+
const toSend = text;
|
|
165
188
|
this.queue = this.queue.then(async () => {
|
|
166
189
|
if (!this.state || this.closed) {
|
|
167
190
|
return;
|
|
168
191
|
}
|
|
169
|
-
|
|
170
|
-
if (!mergedText || mergedText === this.state.currentText) {
|
|
192
|
+
if (toSend === this.state.currentText) {
|
|
171
193
|
return;
|
|
172
194
|
}
|
|
173
|
-
this.state.currentText =
|
|
195
|
+
this.state.currentText = toSend;
|
|
174
196
|
this.state.sequence += 1;
|
|
175
197
|
const apiBase = resolveApiBase(this.creds.domain);
|
|
176
198
|
await fetch(`${apiBase}/cardkit/v1/cards/${this.state.cardId}/elements/content/content`, {
|
|
@@ -180,7 +202,7 @@ export class FeishuStreamingSession {
|
|
|
180
202
|
"Content-Type": "application/json",
|
|
181
203
|
},
|
|
182
204
|
body: JSON.stringify({
|
|
183
|
-
content:
|
|
205
|
+
content: toSend,
|
|
184
206
|
sequence: this.state.sequence,
|
|
185
207
|
uuid: `s_${this.state.cardId}_${this.state.sequence}`,
|
|
186
208
|
}),
|
|
@@ -196,8 +218,9 @@ export class FeishuStreamingSession {
|
|
|
196
218
|
this.closed = true;
|
|
197
219
|
await this.queue;
|
|
198
220
|
|
|
199
|
-
|
|
200
|
-
|
|
221
|
+
// finalText is authoritative when provided (from deliver kind=final).
|
|
222
|
+
// Fall back to pending or current text when closing via onIdle.
|
|
223
|
+
const text = finalText || this.pendingText || this.state.currentText;
|
|
201
224
|
const apiBase = resolveApiBase(this.creds.domain);
|
|
202
225
|
|
|
203
226
|
if (text && text !== this.state.currentText) {
|
package/src/tools-config.ts
CHANGED
package/src/types.ts
CHANGED
|
@@ -76,6 +76,10 @@ export type FeishuToolsConfig = {
|
|
|
76
76
|
chat?: boolean;
|
|
77
77
|
/** Enable the feishu_urgent tool (buzz/urgent notifications). Enabled by default. */
|
|
78
78
|
urgent?: boolean;
|
|
79
|
+
/** Enable the feishu_message tool (get/list messages). Enabled by default. */
|
|
80
|
+
message?: boolean;
|
|
81
|
+
/** Enable the feishu_reaction tool (add/remove/list emoji reactions). Enabled by default. */
|
|
82
|
+
reaction?: boolean;
|
|
79
83
|
};
|
|
80
84
|
|
|
81
85
|
export type DynamicAgentCreationConfig = {
|