@vui-design/openclaw-plugin-feishu-progress 0.3.0 → 0.4.1
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/package.json +1 -1
- package/src/index.ts +128 -75
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -1,131 +1,117 @@
|
|
|
1
1
|
import { FeishuClient } from './feishu-client.js';
|
|
2
2
|
|
|
3
|
-
// ──
|
|
3
|
+
// ── 工具参数 → 语义化中文标签 ────────────────────────────────────────────────
|
|
4
4
|
|
|
5
5
|
function basename(p: string): string {
|
|
6
6
|
return p.split(/[/\\]/).filter(Boolean).pop() ?? p;
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
-
function
|
|
9
|
+
function s(v: unknown, max = 60): string {
|
|
10
10
|
return String(v ?? '').slice(0, max).trim();
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
function buildToolLabel(toolName: string,
|
|
13
|
+
function buildToolLabel(toolName: string, args: Record<string, unknown>): string {
|
|
14
14
|
switch (toolName) {
|
|
15
15
|
case 'exec':
|
|
16
16
|
case 'bash': {
|
|
17
|
-
const cmd =
|
|
18
|
-
|
|
17
|
+
const cmd = s(args?.command ?? args?.cmd ?? args?.input, 150);
|
|
19
18
|
const brewInstall = cmd.match(/brew\s+install\s+([\w@./-]+)/);
|
|
20
19
|
if (brewInstall) return `📦 安装 ${brewInstall[1]}`;
|
|
21
20
|
const brewUninstall = cmd.match(/brew\s+uninstall\s+([\w@./-]+)/);
|
|
22
21
|
if (brewUninstall) return `🗑️ 卸载 ${brewUninstall[1]}`;
|
|
23
|
-
|
|
24
22
|
const which = cmd.match(/^(?:which|command\s+-v)\s+([\w.-]+)/);
|
|
25
23
|
if (which) return `🔍 检查 ${which[1]} 是否已安装`;
|
|
26
|
-
|
|
27
24
|
const npmInstall = cmd.match(/(?:npm|pnpm|yarn)\s+(?:install|add)\s+([\w@./-]+)?/);
|
|
28
25
|
if (npmInstall) return npmInstall[1] ? `📦 安装 ${npmInstall[1]}` : '📦 安装依赖';
|
|
29
26
|
const npmRun = cmd.match(/(?:npm|pnpm|yarn)\s+run\s+([\w:.-]+)/);
|
|
30
27
|
if (npmRun) return `▶️ 运行 ${npmRun[1]}`;
|
|
31
|
-
|
|
32
28
|
const pip = cmd.match(/pip(?:\d)?\s+install\s+([\w@.-]+)/);
|
|
33
29
|
if (pip) return `📦 安装 ${pip[1]}`;
|
|
34
|
-
|
|
35
30
|
const gitClone = cmd.match(/git\s+clone\s+(?:.*\/)?([^/\s]+?)(?:\.git)?(?:\s|$)/);
|
|
36
31
|
if (gitClone) return `📥 克隆仓库 ${gitClone[1]}`;
|
|
37
32
|
if (/git\s+commit/.test(cmd)) return '💾 提交代码';
|
|
38
33
|
if (/git\s+push/.test(cmd)) return '🚀 推送代码';
|
|
39
34
|
if (/git\s+pull/.test(cmd)) return '⬇️ 拉取代码';
|
|
40
|
-
|
|
41
35
|
const mkdir = cmd.match(/^mkdir\s+(?:-[\w]+\s+)?([\w./~-]+)/);
|
|
42
36
|
if (mkdir) return `📁 创建目录 ${basename(mkdir[1])}`;
|
|
43
|
-
|
|
44
37
|
const rm = cmd.match(/^rm\s+(?:-[\w]+\s+)?([\w./~-]+)/);
|
|
45
38
|
if (rm) return `🗑️ 删除 ${basename(rm[1])}`;
|
|
46
|
-
|
|
47
39
|
const curlUrl = cmd.match(/(?:curl|wget)\s+.*?(https?:\/\/[^\s]+)/);
|
|
48
40
|
if (curlUrl) { try { return `🌐 请求 ${new URL(curlUrl[1]).hostname}`; } catch { /**/ } }
|
|
49
|
-
|
|
50
41
|
if (cmd) return `⚡ ${cmd.slice(0, 40)}`;
|
|
51
42
|
return '⚡ 执行命令';
|
|
52
43
|
}
|
|
53
|
-
|
|
54
44
|
case 'process': {
|
|
55
|
-
const cmd =
|
|
56
|
-
return cmd ? `⚡
|
|
45
|
+
const cmd = s(args?.command ?? args?.cmd, 80);
|
|
46
|
+
return cmd ? `⚡ ${cmd.slice(0, 40)}` : '⚡ 执行进程';
|
|
57
47
|
}
|
|
58
|
-
|
|
59
48
|
case 'read':
|
|
60
49
|
case 'read_file': {
|
|
61
|
-
const p =
|
|
50
|
+
const p = s(args?.path ?? args?.file_path ?? args?.filePath);
|
|
62
51
|
return p ? `📂 读取 ${basename(p)}` : '📂 读取文件';
|
|
63
52
|
}
|
|
64
|
-
|
|
65
53
|
case 'write':
|
|
66
54
|
case 'write_file':
|
|
67
55
|
case 'create_file': {
|
|
68
|
-
const p =
|
|
56
|
+
const p = s(args?.path ?? args?.file_path ?? args?.filePath);
|
|
69
57
|
return p ? `✏️ 写入 ${basename(p)}` : '✏️ 写入文件';
|
|
70
58
|
}
|
|
71
|
-
|
|
72
59
|
case 'str_replace_editor':
|
|
73
60
|
case 'edit_file': {
|
|
74
|
-
const p =
|
|
61
|
+
const p = s(args?.path ?? args?.file_path);
|
|
75
62
|
return p ? `✏️ 编辑 ${basename(p)}` : '✏️ 编辑文件';
|
|
76
63
|
}
|
|
77
|
-
|
|
78
64
|
case 'list_directory': {
|
|
79
|
-
const p =
|
|
65
|
+
const p = s(args?.path ?? args?.directory);
|
|
80
66
|
return p ? `📋 浏览 ${basename(p) || p}` : '📋 浏览目录';
|
|
81
67
|
}
|
|
82
|
-
|
|
83
68
|
case 'search':
|
|
84
69
|
case 'web_search':
|
|
85
70
|
case 'tavily_search': {
|
|
86
|
-
const q =
|
|
71
|
+
const q = s(args?.query ?? args?.q, 30);
|
|
87
72
|
return q ? `🌐 搜索「${q}」` : '🌐 网络搜索';
|
|
88
73
|
}
|
|
89
|
-
|
|
90
74
|
case 'fetch':
|
|
91
75
|
case 'web_fetch': {
|
|
92
|
-
const url =
|
|
76
|
+
const url = s(args?.url ?? args?.href);
|
|
93
77
|
try { return `🌐 访问 ${new URL(url).hostname}`; } catch { /**/ }
|
|
94
78
|
return '🌐 抓取网页';
|
|
95
79
|
}
|
|
96
|
-
|
|
97
80
|
case 'computer': {
|
|
98
|
-
const action =
|
|
81
|
+
const action = s(args?.action);
|
|
99
82
|
return action ? `🖥️ 操作电脑:${action}` : '🖥️ 操作电脑';
|
|
100
83
|
}
|
|
101
|
-
|
|
102
84
|
case 'screenshot': return '📸 截图';
|
|
103
|
-
|
|
104
85
|
case 'delete_file': {
|
|
105
|
-
const p =
|
|
86
|
+
const p = s(args?.path ?? args?.file_path);
|
|
106
87
|
return p ? `🗑️ 删除 ${basename(p)}` : '🗑️ 删除文件';
|
|
107
88
|
}
|
|
108
|
-
|
|
109
89
|
default: return `🔧 ${toolName}`;
|
|
110
90
|
}
|
|
111
91
|
}
|
|
112
92
|
|
|
113
|
-
//
|
|
114
|
-
|
|
93
|
+
// 从 AI 输出文本提取最末一句(去掉 markdown 标记符)
|
|
115
94
|
function extractSnippet(text: string): string {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
95
|
+
const lines = text
|
|
96
|
+
.split(/\n+/)
|
|
97
|
+
.map(l => l.replace(/^[#*>\-\s]+/, '').trim())
|
|
98
|
+
.filter(Boolean);
|
|
99
|
+
return (lines[lines.length - 1] ?? '').slice(0, 50);
|
|
120
100
|
}
|
|
121
101
|
|
|
122
|
-
// ──
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
102
|
+
// ── 主插件 ────────────────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
interface RunState {
|
|
105
|
+
/** 飞书接收方 ID(ou_xxx / oc_xxx) */
|
|
106
|
+
chatId: string;
|
|
107
|
+
/** 当前轮次累积的 AI 输出文本 */
|
|
108
|
+
assistantBuffer: string;
|
|
109
|
+
/** 已发送过的 snippet,防止重复推送 */
|
|
110
|
+
sentSnippets: Set<string>;
|
|
111
|
+
startedAt: number;
|
|
112
|
+
lastSentAt: number;
|
|
113
|
+
heartbeatTimer: ReturnType<typeof setInterval>;
|
|
114
|
+
}
|
|
129
115
|
|
|
130
116
|
export default {
|
|
131
117
|
id: 'feishu-progress',
|
|
@@ -136,7 +122,6 @@ export default {
|
|
|
136
122
|
if (!cfg.appId || !cfg.appSecret) {
|
|
137
123
|
api.logger?.warn(
|
|
138
124
|
'[feishu-progress] appId / appSecret 未配置,进度通知已禁用。\n' +
|
|
139
|
-
'请执行:\n' +
|
|
140
125
|
' openclaw config set plugins.entries.feishu-progress.config.appId "cli_xxx"\n' +
|
|
141
126
|
' openclaw config set plugins.entries.feishu-progress.config.appSecret "xxx"\n' +
|
|
142
127
|
'然后重启 OpenClaw。'
|
|
@@ -150,51 +135,119 @@ export default {
|
|
|
150
135
|
domain: cfg.domain ?? 'feishu',
|
|
151
136
|
});
|
|
152
137
|
|
|
153
|
-
// ──
|
|
138
|
+
// ── 关联数据 ────────────────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
// accountId ("main") → 飞书裸 ID(ou_xxx / oc_xxx)
|
|
141
|
+
// 用 sessionKey 细化:sessionKey → chatId(隔离多对话)
|
|
142
|
+
const sessionKeyChatMap = new Map<string, string>();
|
|
143
|
+
// accountId → chatId(兜底,在没有 sessionKey 时使用)
|
|
144
|
+
const accountChatMap = new Map<string, string>();
|
|
145
|
+
|
|
146
|
+
// runId → RunState
|
|
147
|
+
const runStateMap = new Map<string, RunState>();
|
|
148
|
+
|
|
149
|
+
// ── Hooks ───────────────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
// 收到飞书消息 → 记录 chatId,同时按 sessionKey 和 accountId 双索引
|
|
154
152
|
api.on('message_received', async (_event: any, ctx: any) => {
|
|
155
153
|
if (ctx.channelId !== 'feishu') return;
|
|
156
154
|
if (!ctx.conversationId) return;
|
|
157
|
-
// OpenClaw 内部格式 "user:ou_xxx" / "group:oc_xxx",剥离前缀取裸 Feishu ID
|
|
158
155
|
const rawId = ctx.conversationId.includes(':')
|
|
159
156
|
? ctx.conversationId.split(':').pop()!
|
|
160
157
|
: ctx.conversationId;
|
|
161
|
-
|
|
158
|
+
if (ctx.accountId) accountChatMap.set(ctx.accountId, rawId);
|
|
159
|
+
// sessionKey 在 message_received ctx 里也可能存在
|
|
160
|
+
if (ctx.sessionKey) sessionKeyChatMap.set(ctx.sessionKey, rawId);
|
|
162
161
|
});
|
|
163
162
|
|
|
164
|
-
//
|
|
165
|
-
|
|
163
|
+
// llm_input → 建立 runId → RunState,启动心跳
|
|
164
|
+
// 此时 ctx.agentId 和 ctx.sessionKey 均可用,可精确找到 chatId
|
|
165
|
+
api.on('llm_input', async (event: any, ctx: any) => {
|
|
166
166
|
if (!event.runId) return;
|
|
167
|
-
|
|
168
|
-
const text = texts.join('').trim();
|
|
169
|
-
if (text) runTextMap.set(event.runId, text);
|
|
170
|
-
});
|
|
167
|
+
if (runStateMap.has(event.runId)) return; // 同一 run 只初始化一次
|
|
171
168
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
169
|
+
// sessionKey 精确匹配优先,其次 agentId 兜底
|
|
170
|
+
const chatId =
|
|
171
|
+
(ctx.sessionKey ? sessionKeyChatMap.get(ctx.sessionKey) : undefined) ??
|
|
172
|
+
(ctx.agentId ? accountChatMap.get(ctx.agentId) : undefined);
|
|
176
173
|
if (!chatId) return;
|
|
177
174
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
175
|
+
const startedAt = Date.now();
|
|
176
|
+
const state: RunState = {
|
|
177
|
+
chatId,
|
|
178
|
+
assistantBuffer: '',
|
|
179
|
+
sentSnippets: new Set(),
|
|
180
|
+
startedAt,
|
|
181
|
+
lastSentAt: startedAt,
|
|
182
|
+
heartbeatTimer: setInterval(() => {
|
|
183
|
+
if (!runStateMap.has(event.runId)) return;
|
|
184
|
+
const idleSec = (Date.now() - state.lastSentAt) / 1000;
|
|
185
|
+
if (idleSec >= 55) {
|
|
186
|
+
const elapsed = Math.round((Date.now() - state.startedAt) / 1000);
|
|
187
|
+
state.lastSentAt = Date.now();
|
|
188
|
+
client.sendText(chatId, `⏳ 处理中,已用时约 ${elapsed} 秒...`).catch(() => {});
|
|
189
|
+
}
|
|
190
|
+
}, 30_000),
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
runStateMap.set(event.runId, state);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// ── 实时流事件(与钉钉插件使用相同的事件总线)──────────────────────────
|
|
197
|
+
api.runtime.events.onAgentEvent((evt: any) => {
|
|
198
|
+
const { runId, stream, data } = evt;
|
|
199
|
+
const state = runStateMap.get(runId);
|
|
200
|
+
if (!state) return;
|
|
201
|
+
|
|
202
|
+
// assistant 流 → 累积 AI 文本 delta
|
|
203
|
+
if (stream === 'assistant') {
|
|
204
|
+
state.assistantBuffer += (data?.delta ?? '') as string;
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// tool:start → 推送进度
|
|
209
|
+
if (stream === 'tool' && data?.phase === 'start') {
|
|
210
|
+
const snippet = extractSnippet(state.assistantBuffer);
|
|
211
|
+
state.assistantBuffer = ''; // 每次工具调用前消费
|
|
212
|
+
|
|
213
|
+
let label: string;
|
|
214
|
+
if (snippet && !state.sentSnippets.has(snippet)) {
|
|
215
|
+
// AI 文本去重:同一段文字不重复发送
|
|
216
|
+
state.sentSnippets.add(snippet);
|
|
217
|
+
label = snippet;
|
|
218
|
+
} else {
|
|
219
|
+
label = buildToolLabel(
|
|
220
|
+
data.name as string,
|
|
221
|
+
(data.args ?? {}) as Record<string, unknown>,
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
state.lastSentAt = Date.now();
|
|
226
|
+
client.sendText(state.chatId, label).catch((err: any) => {
|
|
227
|
+
api.logger?.warn(`[feishu-progress] 推送失败: ${err?.message}`);
|
|
228
|
+
});
|
|
229
|
+
return;
|
|
186
230
|
}
|
|
187
231
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
232
|
+
// lifecycle:end/error → 清理
|
|
233
|
+
if (stream === 'lifecycle' && (data?.phase === 'end' || data?.phase === 'error')) {
|
|
234
|
+
clearInterval(state.heartbeatTimer);
|
|
235
|
+
runStateMap.delete(runId);
|
|
192
236
|
}
|
|
193
237
|
});
|
|
194
238
|
|
|
195
|
-
//
|
|
239
|
+
// agent_end hook 作为兜底清理(防止 lifecycle 事件丢失时内存泄漏)
|
|
196
240
|
api.on('agent_end', async (_event: any, ctx: any) => {
|
|
197
|
-
|
|
241
|
+
// 通过 sessionKey 找到对应的 runId 并清理
|
|
242
|
+
for (const [runId, state] of runStateMap) {
|
|
243
|
+
if (
|
|
244
|
+
(ctx.sessionKey && sessionKeyChatMap.get(ctx.sessionKey) === state.chatId) ||
|
|
245
|
+
(ctx.agentId && accountChatMap.get(ctx.agentId) === state.chatId)
|
|
246
|
+
) {
|
|
247
|
+
clearInterval(state.heartbeatTimer);
|
|
248
|
+
runStateMap.delete(runId);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
198
251
|
});
|
|
199
252
|
},
|
|
200
253
|
};
|