@vui-design/openclaw-plugin-feishu-progress 0.4.0 → 0.4.2

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 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.2",
4
4
  "description": "飞书任务进度卡片插件 — 在 AI 执行复杂多步骤任务时,实时更新飞书进度卡片",
5
5
  "keywords": [
6
6
  "openclaw-plugin",
@@ -11,6 +11,8 @@ interface TokenCache {
11
11
 
12
12
  export class FeishuClient {
13
13
  private tokenCache: TokenCache | null = null;
14
+ // 并发刷新去重:多个调用同时过期时只发一次请求
15
+ private refreshPromise: Promise<string> | null = null;
14
16
 
15
17
  constructor(private config: FeishuConfig) {}
16
18
 
@@ -24,6 +26,14 @@ export class FeishuClient {
24
26
  if (this.tokenCache && Date.now() < this.tokenCache.expiresAt) {
25
27
  return this.tokenCache.token;
26
28
  }
29
+ if (this.refreshPromise) return this.refreshPromise;
30
+ this.refreshPromise = this.doRefresh().finally(() => {
31
+ this.refreshPromise = null;
32
+ });
33
+ return this.refreshPromise;
34
+ }
35
+
36
+ private async doRefresh(): Promise<string> {
27
37
  const res = await fetch(
28
38
  `${this.baseUrl}/open-apis/auth/v3/tenant_access_token/internal`,
29
39
  {
@@ -38,7 +48,12 @@ export class FeishuClient {
38
48
  if (!res.ok) {
39
49
  throw new Error(`飞书获取 token 失败: HTTP ${res.status}`);
40
50
  }
41
- const data = (await res.json()) as { tenant_access_token: string; expire: number; code: number; msg: string };
51
+ const data = (await res.json()) as {
52
+ tenant_access_token: string;
53
+ expire: number;
54
+ code: number;
55
+ msg: string;
56
+ };
42
57
  if (data.code !== 0) {
43
58
  throw new Error(`飞书获取 token 失败: ${data.msg} (code=${data.code})`);
44
59
  }
@@ -78,7 +93,11 @@ export class FeishuClient {
78
93
  if (!res.ok) {
79
94
  throw new Error(`飞书发送消息失败: HTTP ${res.status}`);
80
95
  }
81
- const data = (await res.json()) as { code: number; msg: string; data: { message_id: string } };
96
+ const data = (await res.json()) as {
97
+ code: number;
98
+ msg: string;
99
+ data: { message_id: string };
100
+ };
82
101
  if (data.code !== 0) {
83
102
  throw new Error(`飞书发送消息失败: ${data.msg} (code=${data.code})`);
84
103
  }
package/src/index.ts CHANGED
@@ -15,110 +15,108 @@ 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
 
102
+ // 从 sessionKey 提取飞书用户 ID
103
+ // sessionKey 格式:agent:{agentId}:feishu:direct:{userId}
104
+ function chatIdFromSessionKey(sessionKey: string | undefined): string | undefined {
105
+ if (!sessionKey) return undefined;
106
+ const m = sessionKey.match(/^agent:[^:]+:feishu:(?:direct|group):(.+)$/);
107
+ return m?.[1];
108
+ }
109
+
117
110
  // ── 主插件 ────────────────────────────────────────────────────────────────
118
111
 
119
112
  interface RunState {
120
113
  chatId: string;
114
+ /** 用于 agent_end 精确匹配,避免按 chatId 误删并发 run */
115
+ agentId: string;
116
+ sessionKey: string;
121
117
  assistantBuffer: string;
118
+ /** 已发送过的 snippet,防止重复推送 */
119
+ sentSnippets: Set<string>;
122
120
  startedAt: number;
123
121
  lastSentAt: number;
124
122
  heartbeatTimer: ReturnType<typeof setInterval>;
@@ -133,7 +131,6 @@ export default {
133
131
  if (!cfg.appId || !cfg.appSecret) {
134
132
  api.logger?.warn(
135
133
  '[feishu-progress] appId / appSecret 未配置,进度通知已禁用。\n' +
136
- '请执行:\n' +
137
134
  ' openclaw config set plugins.entries.feishu-progress.config.appId "cli_xxx"\n' +
138
135
  ' openclaw config set plugins.entries.feishu-progress.config.appSecret "xxx"\n' +
139
136
  '然后重启 OpenClaw。'
@@ -147,13 +144,23 @@ export default {
147
144
  domain: cfg.domain ?? 'feishu',
148
145
  });
149
146
 
150
- // accountId ("main") → 飞书裸 ID(ou_xxx / oc_xxx)
147
+ // accountId ("main") → 飞书裸 ID,兜底用(sessionKey 解析失败时)
151
148
  const accountChatMap = new Map<string, string>();
152
149
 
153
- // runId → RunState(每次 agent run 的状态)
150
+ // runId → RunState
154
151
  const runStateMap = new Map<string, RunState>();
155
152
 
156
- // ── 1. 收到用户消息 → 记录 accountId 对应的飞书 chat ID ─────────────────
153
+ // 统一清理入口
154
+ function cleanupRun(runId: string): void {
155
+ const state = runStateMap.get(runId);
156
+ if (!state) return;
157
+ clearInterval(state.heartbeatTimer);
158
+ runStateMap.delete(runId);
159
+ }
160
+
161
+ // ── Hooks ───────────────────────────────────────────────────────────────
162
+
163
+ // 收到飞书消息 → 记录 accountId → chatId(兜底索引)
157
164
  api.on('message_received', async (_event: any, ctx: any) => {
158
165
  if (ctx.channelId !== 'feishu') return;
159
166
  if (!ctx.conversationId) return;
@@ -161,23 +168,30 @@ export default {
161
168
  const rawId = ctx.conversationId.includes(':')
162
169
  ? ctx.conversationId.split(':').pop()!
163
170
  : ctx.conversationId;
164
- accountChatMap.set(ctx.accountId ?? 'default', rawId);
171
+ if (ctx.accountId) accountChatMap.set(ctx.accountId, rawId);
165
172
  });
166
173
 
167
- // ── 2. LLM 调用前 runId+agentId 建立关联,并启动心跳 ─────────────
174
+ // llm_input建立 runId RunState,启动心跳
168
175
  api.on('llm_input', async (event: any, ctx: any) => {
169
- if (!event.runId || !ctx.agentId) return;
176
+ if (!event.runId) return;
170
177
  if (runStateMap.has(event.runId)) return; // 同一 run 只初始化一次
171
178
 
172
- const chatId = accountChatMap.get(ctx.agentId);
179
+ // 从 sessionKey 精确解析 chatId;无法解析时退回 accountChatMap
180
+ const chatId =
181
+ chatIdFromSessionKey(ctx.sessionKey) ??
182
+ (ctx.agentId ? accountChatMap.get(ctx.agentId) : undefined);
173
183
  if (!chatId) return;
174
184
 
175
185
  const startedAt = Date.now();
176
186
  const state: RunState = {
177
187
  chatId,
188
+ agentId: ctx.agentId ?? '',
189
+ sessionKey: ctx.sessionKey ?? '',
178
190
  assistantBuffer: '',
191
+ sentSnippets: new Set(),
179
192
  startedAt,
180
193
  lastSentAt: startedAt,
194
+ // 10s 间隔确保 55s 阈值能准时触发
181
195
  heartbeatTimer: setInterval(() => {
182
196
  if (!runStateMap.has(event.runId)) return;
183
197
  const idleSec = (Date.now() - state.lastSentAt) / 1000;
@@ -186,34 +200,40 @@ export default {
186
200
  state.lastSentAt = Date.now();
187
201
  client.sendText(chatId, `⏳ 处理中,已用时约 ${elapsed} 秒...`).catch(() => {});
188
202
  }
189
- }, 30_000),
203
+ }, 10_000),
190
204
  };
191
205
 
192
206
  runStateMap.set(event.runId, state);
193
207
  });
194
208
 
195
- // ── 3. 实时流事件 → 核心逻辑 ─────────────────────────────────────────────
209
+ // ── 实时流事件(与钉钉插件使用相同的事件总线)──────────────────────────
196
210
  api.runtime.events.onAgentEvent((evt: any) => {
197
211
  const { runId, stream, data } = evt;
198
212
  const state = runStateMap.get(runId);
199
213
  if (!state) return;
200
214
 
201
- // assistant 流:累积 AI 输出文本
215
+ // assistant → 累积 AI 文本 delta
202
216
  if (stream === 'assistant') {
203
217
  state.assistantBuffer += (data?.delta ?? '') as string;
204
218
  return;
205
219
  }
206
220
 
207
- // tool:start → 推送进度消息
221
+ // tool:start → 推送进度
208
222
  if (stream === 'tool' && data?.phase === 'start') {
209
- // AI 文本优先,没有则用参数解析标签
210
223
  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
- );
224
+ state.assistantBuffer = ''; // 每次工具调用前消费
225
+
226
+ let label: string;
227
+ if (snippet && !state.sentSnippets.has(snippet)) {
228
+ // AI 文本优先,且去重
229
+ state.sentSnippets.add(snippet);
230
+ label = snippet;
231
+ } else {
232
+ label = buildToolLabel(
233
+ data.name as string,
234
+ (data.args ?? {}) as Record<string, unknown>,
235
+ );
236
+ }
217
237
 
218
238
  state.lastSentAt = Date.now();
219
239
  client.sendText(state.chatId, label).catch((err: any) => {
@@ -224,8 +244,16 @@ export default {
224
244
 
225
245
  // lifecycle:end/error → 清理
226
246
  if (stream === 'lifecycle' && (data?.phase === 'end' || data?.phase === 'error')) {
227
- clearInterval(state.heartbeatTimer);
228
- runStateMap.delete(runId);
247
+ cleanupRun(runId);
248
+ }
249
+ });
250
+
251
+ // agent_end → 兜底清理(按 sessionKey/agentId 精确匹配,不按 chatId)
252
+ api.on('agent_end', async (_event: any, ctx: any) => {
253
+ for (const [runId, state] of runStateMap) {
254
+ const matchBySession = ctx.sessionKey && state.sessionKey === ctx.sessionKey;
255
+ const matchByAgent = !ctx.sessionKey && ctx.agentId && state.agentId === ctx.agentId;
256
+ if (matchBySession || matchByAgent) cleanupRun(runId);
229
257
  }
230
258
  });
231
259
  },