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