@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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/index.ts +128 -75
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vui-design/openclaw-plugin-feishu-progress",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "飞书任务进度卡片插件 — 在 AI 执行复杂多步骤任务时,实时更新飞书进度卡片",
5
5
  "keywords": [
6
6
  "openclaw-plugin",
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 str(v: unknown, max = 60): string {
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, params: Record<string, unknown>): 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 = str(params?.command ?? params?.cmd ?? params?.input, 150);
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 = str(params?.command ?? params?.cmd, 80);
56
- return cmd ? `⚡ 运行:${cmd.slice(0, 40)}` : '⚡ 执行进程';
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 = str(params?.path ?? params?.file_path ?? params?.filePath);
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 = str(params?.path ?? params?.file_path ?? params?.filePath);
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 = str(params?.path ?? params?.file_path);
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 = str(params?.path ?? params?.directory);
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 = str(params?.query ?? params?.q, 30);
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 = str(params?.url ?? params?.href);
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 = str(params?.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 = str(params?.path ?? params?.file_path);
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
- // ── 提取 AI 文本中最有意义的一句(用于工具调用前的说明)─────────────────────
114
-
93
+ // AI 输出文本提取最末一句(去掉 markdown 标记符)
115
94
  function extractSnippet(text: string): string {
116
- // 取最后一段非空文本,截取前 50
117
- const lines = text.split(/\n+/).map(l => l.trim()).filter(Boolean);
118
- const last = lines[lines.length - 1] ?? '';
119
- return last.slice(0, 50);
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
- // agentId → 飞书裸 ID(ou_xxx / oc_xxx)
125
- const sessionChatMap = new Map<string, string>();
126
-
127
- // runId → 最近一次 LLM turn 的 AI 文本(用于 before_tool_call 显示上下文)
128
- const runTextMap = new Map<string, string>();
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
- // ── 1. 收到用户消息时,记录 agentId → 飞书 ID ────────────────────────────
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
- sessionChatMap.set(ctx.accountId ?? 'default', rawId);
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
- // ── 2. LLM 完成一轮生成后,缓存 AI 文本供 before_tool_call 使用 ──────────
165
- api.on('llm_output', async (event: any, _ctx: any) => {
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
- const texts: string[] = event.assistantTexts ?? [];
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
- // ── 3. 工具调用前,推送进度到飞书 ───────────────────────────────────────
173
- api.on('before_tool_call', async (event: any, ctx: any) => {
174
- const key = ctx.agentId ?? ctx.sessionKey ?? 'default';
175
- const chatId = sessionChatMap.get(key);
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
- // AI 文本优先(取自本轮 LLM 输出的最后一句),没有则用参数解析标签
179
- let label: string;
180
- if (event.runId && runTextMap.has(event.runId)) {
181
- const snippet = extractSnippet(runTextMap.get(event.runId)!);
182
- runTextMap.delete(event.runId); // 每轮只用一次
183
- label = snippet || buildToolLabel(event.toolName, event.params ?? {});
184
- } else {
185
- label = buildToolLabel(event.toolName, event.params ?? {});
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
- try {
189
- await client.sendText(chatId, label);
190
- } catch (err: any) {
191
- api.logger?.warn(`[feishu-progress] 推送失败: ${err?.message}`);
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
- // ── 4. 任务结束后清理 session 映射 ─────────────────────────────────────
239
+ // agent_end hook 作为兜底清理(防止 lifecycle 事件丢失时内存泄漏)
196
240
  api.on('agent_end', async (_event: any, ctx: any) => {
197
- sessionChatMap.delete(ctx.agentId ?? ctx.sessionKey ?? 'default');
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
  };