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

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 +190 -43
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vui-design/openclaw-plugin-feishu-progress",
3
- "version": "0.2.6",
3
+ "version": "0.4.0",
4
4
  "description": "飞书任务进度卡片插件 — 在 AI 执行复杂多步骤任务时,实时更新飞书进度卡片",
5
5
  "keywords": [
6
6
  "openclaw-plugin",
package/src/index.ts CHANGED
@@ -1,35 +1,128 @@
1
1
  import { FeishuClient } from './feishu-client.js';
2
2
 
3
- // 常见工具名称中文映射(含 OpenClaw 内部工具名 + Claude 工具名)
4
- const TOOL_LABEL: Record<string, string> = {
5
- // OpenClaw 内部工具名
6
- exec: '执行命令',
7
- process: '执行进程',
8
- read: '读取文件',
9
- write: '写入文件',
10
- search: '网络搜索',
11
- fetch: '抓取网页',
12
- // Claude 工具名
13
- read_file: '读取文件',
14
- write_file: '写入文件',
15
- list_directory: '浏览目录',
16
- bash: '执行命令',
17
- web_search: '网络搜索',
18
- tavily_search: '网络搜索',
19
- web_fetch: '抓取网页',
20
- computer: '操作电脑',
21
- screenshot: '截图',
22
- str_replace_editor: '编辑文件',
23
- create_file: '创建文件',
24
- delete_file: '删除文件',
25
- };
3
+ // ── 工具参数 语义化中文标签 ────────────────────────────────────────────────
4
+
5
+ function basename(p: string): string {
6
+ return p.split(/[/\\]/).filter(Boolean).pop() ?? p;
7
+ }
26
8
 
27
- function toolLabel(name: string): string {
28
- return TOOL_LABEL[name] ?? name;
9
+ function s(v: unknown, max = 60): string {
10
+ return String(v ?? '').slice(0, max).trim();
29
11
  }
30
12
 
31
- // sessionKey conversationId(飞书 chat_id)
32
- const sessionChatMap = new Map<string, string>();
13
+ function buildToolLabel(toolName: string, args: Record<string, unknown>): string {
14
+ switch (toolName) {
15
+ case 'exec':
16
+ case 'bash': {
17
+ const cmd = s(args?.command ?? args?.cmd ?? args?.input, 150);
18
+
19
+ const brewInstall = cmd.match(/brew\s+install\s+([\w@./-]+)/);
20
+ if (brewInstall) return `📦 安装 ${brewInstall[1]}`;
21
+ const brewUninstall = cmd.match(/brew\s+uninstall\s+([\w@./-]+)/);
22
+ if (brewUninstall) return `🗑️ 卸载 ${brewUninstall[1]}`;
23
+ const which = cmd.match(/^(?:which|command\s+-v)\s+([\w.-]+)/);
24
+ if (which) return `🔍 检查 ${which[1]} 是否已安装`;
25
+
26
+ const npmInstall = cmd.match(/(?:npm|pnpm|yarn)\s+(?:install|add)\s+([\w@./-]+)?/);
27
+ if (npmInstall) return npmInstall[1] ? `📦 安装 ${npmInstall[1]}` : '📦 安装依赖';
28
+ const npmRun = cmd.match(/(?:npm|pnpm|yarn)\s+run\s+([\w:.-]+)/);
29
+ if (npmRun) return `▶️ 运行 ${npmRun[1]}`;
30
+
31
+ const pip = cmd.match(/pip(?:\d)?\s+install\s+([\w@.-]+)/);
32
+ if (pip) return `📦 安装 ${pip[1]}`;
33
+
34
+ const gitClone = cmd.match(/git\s+clone\s+(?:.*\/)?([^/\s]+?)(?:\.git)?(?:\s|$)/);
35
+ if (gitClone) return `📥 克隆仓库 ${gitClone[1]}`;
36
+ if (/git\s+commit/.test(cmd)) return '💾 提交代码';
37
+ if (/git\s+push/.test(cmd)) return '🚀 推送代码';
38
+ if (/git\s+pull/.test(cmd)) return '⬇️ 拉取代码';
39
+
40
+ const mkdir = cmd.match(/^mkdir\s+(?:-[\w]+\s+)?([\w./~-]+)/);
41
+ if (mkdir) return `📁 创建目录 ${basename(mkdir[1])}`;
42
+ const rm = cmd.match(/^rm\s+(?:-[\w]+\s+)?([\w./~-]+)/);
43
+ if (rm) return `🗑️ 删除 ${basename(rm[1])}`;
44
+
45
+ const curlUrl = cmd.match(/(?:curl|wget)\s+.*?(https?:\/\/[^\s]+)/);
46
+ if (curlUrl) { try { return `🌐 请求 ${new URL(curlUrl[1]).hostname}`; } catch { /**/ } }
47
+
48
+ if (cmd) return `⚡ ${cmd.slice(0, 40)}`;
49
+ return '⚡ 执行命令';
50
+ }
51
+
52
+ case 'process': {
53
+ const cmd = s(args?.command ?? args?.cmd, 80);
54
+ return cmd ? `⚡ ${cmd.slice(0, 40)}` : '⚡ 执行进程';
55
+ }
56
+
57
+ case 'read':
58
+ case 'read_file': {
59
+ const p = s(args?.path ?? args?.file_path ?? args?.filePath);
60
+ return p ? `📂 读取 ${basename(p)}` : '📂 读取文件';
61
+ }
62
+
63
+ case 'write':
64
+ case 'write_file':
65
+ case 'create_file': {
66
+ const p = s(args?.path ?? args?.file_path ?? args?.filePath);
67
+ return p ? `✏️ 写入 ${basename(p)}` : '✏️ 写入文件';
68
+ }
69
+
70
+ case 'str_replace_editor':
71
+ case 'edit_file': {
72
+ const p = s(args?.path ?? args?.file_path);
73
+ return p ? `✏️ 编辑 ${basename(p)}` : '✏️ 编辑文件';
74
+ }
75
+
76
+ case 'list_directory': {
77
+ const p = s(args?.path ?? args?.directory);
78
+ return p ? `📋 浏览 ${basename(p) || p}` : '📋 浏览目录';
79
+ }
80
+
81
+ case 'search':
82
+ case 'web_search':
83
+ case 'tavily_search': {
84
+ const q = s(args?.query ?? args?.q, 30);
85
+ return q ? `🌐 搜索「${q}」` : '🌐 网络搜索';
86
+ }
87
+
88
+ case 'fetch':
89
+ case 'web_fetch': {
90
+ const url = s(args?.url ?? args?.href);
91
+ try { return `🌐 访问 ${new URL(url).hostname}`; } catch { /**/ }
92
+ return '🌐 抓取网页';
93
+ }
94
+
95
+ case 'computer': {
96
+ const action = s(args?.action);
97
+ return action ? `🖥️ 操作电脑:${action}` : '🖥️ 操作电脑';
98
+ }
99
+
100
+ case 'screenshot': return '📸 截图';
101
+
102
+ case 'delete_file': {
103
+ const p = s(args?.path ?? args?.file_path);
104
+ return p ? `🗑️ 删除 ${basename(p)}` : '🗑️ 删除文件';
105
+ }
106
+
107
+ default: return `🔧 ${toolName}`;
108
+ }
109
+ }
110
+
111
+ // 从 AI 输出文本中提取最末一句(作为进度提示优先显示)
112
+ function extractSnippet(text: string): string {
113
+ const lines = text.split(/\n+/).map(l => l.trim()).filter(Boolean);
114
+ return (lines[lines.length - 1] ?? '').slice(0, 50);
115
+ }
116
+
117
+ // ── 主插件 ────────────────────────────────────────────────────────────────
118
+
119
+ interface RunState {
120
+ chatId: string;
121
+ assistantBuffer: string;
122
+ startedAt: number;
123
+ lastSentAt: number;
124
+ heartbeatTimer: ReturnType<typeof setInterval>;
125
+ }
33
126
 
34
127
  export default {
35
128
  id: 'feishu-progress',
@@ -39,7 +132,7 @@ export default {
39
132
 
40
133
  if (!cfg.appId || !cfg.appSecret) {
41
134
  api.logger?.warn(
42
- '[feishu-progress] appId / appSecret 未配置,进度卡片功能已禁用。\n' +
135
+ '[feishu-progress] appId / appSecret 未配置,进度通知已禁用。\n' +
43
136
  '请执行:\n' +
44
137
  ' openclaw config set plugins.entries.feishu-progress.config.appId "cli_xxx"\n' +
45
138
  ' openclaw config set plugins.entries.feishu-progress.config.appSecret "xxx"\n' +
@@ -54,32 +147,86 @@ export default {
54
147
  domain: cfg.domain ?? 'feishu',
55
148
  });
56
149
 
57
- // ── 1. 收到用户消息时,记录当前会话对应的飞书 ID ─────────────────────────
150
+ // accountId ("main") 飞书裸 ID(ou_xxx / oc_xxx)
151
+ const accountChatMap = new Map<string, string>();
152
+
153
+ // runId → RunState(每次 agent run 的状态)
154
+ const runStateMap = new Map<string, RunState>();
155
+
156
+ // ── 1. 收到用户消息 → 记录 accountId 对应的飞书 chat ID ─────────────────
58
157
  api.on('message_received', async (_event: any, ctx: any) => {
59
158
  if (ctx.channelId !== 'feishu') return;
60
159
  if (!ctx.conversationId) return;
61
- // OpenClaw 内部格式为 "user:ou_xxx" / "group:oc_xxx",剥离前缀取裸 Feishu ID
160
+ // OpenClaw 内部格式 "user:ou_xxx",剥离前缀
62
161
  const rawId = ctx.conversationId.includes(':')
63
162
  ? ctx.conversationId.split(':').pop()!
64
163
  : ctx.conversationId;
65
- sessionChatMap.set(ctx.accountId ?? 'default', rawId);
164
+ accountChatMap.set(ctx.accountId ?? 'default', rawId);
66
165
  });
67
166
 
68
- // ── 2. 每次工具调用前,自动推送进度到飞书 ──────────────────────────────
69
- api.on('before_tool_call', async (event: any, ctx: any) => {
70
- const key = ctx.agentId ?? ctx.sessionKey ?? 'default';
71
- const chatId = sessionChatMap.get(key);
167
+ // ── 2. LLM 调用前 → 用 runId+agentId 建立关联,并启动心跳 ─────────────
168
+ api.on('llm_input', async (event: any, ctx: any) => {
169
+ if (!event.runId || !ctx.agentId) return;
170
+ if (runStateMap.has(event.runId)) return; // 同一 run 只初始化一次
171
+
172
+ const chatId = accountChatMap.get(ctx.agentId);
72
173
  if (!chatId) return;
73
- try {
74
- await client.sendText(chatId, `🔧 正在执行:${toolLabel(event.toolName)}`);
75
- } catch (err: any) {
76
- api.logger?.warn(`[feishu-progress] 推送失败: ${err?.message}`);
77
- }
174
+
175
+ const startedAt = Date.now();
176
+ const state: RunState = {
177
+ chatId,
178
+ assistantBuffer: '',
179
+ startedAt,
180
+ lastSentAt: startedAt,
181
+ heartbeatTimer: setInterval(() => {
182
+ if (!runStateMap.has(event.runId)) return;
183
+ const idleSec = (Date.now() - state.lastSentAt) / 1000;
184
+ if (idleSec >= 55) {
185
+ const elapsed = Math.round((Date.now() - state.startedAt) / 1000);
186
+ state.lastSentAt = Date.now();
187
+ client.sendText(chatId, `⏳ 处理中,已用时约 ${elapsed} 秒...`).catch(() => {});
188
+ }
189
+ }, 30_000),
190
+ };
191
+
192
+ runStateMap.set(event.runId, state);
78
193
  });
79
194
 
80
- // ── 3. 任务结束后清理 session 映射 ───────────────────────────────────────
81
- api.on('agent_end', async (_event: any, ctx: any) => {
82
- sessionChatMap.delete(ctx.agentId ?? ctx.sessionKey ?? 'default');
195
+ // ── 3. 实时流事件 核心逻辑 ─────────────────────────────────────────────
196
+ api.runtime.events.onAgentEvent((evt: any) => {
197
+ const { runId, stream, data } = evt;
198
+ const state = runStateMap.get(runId);
199
+ if (!state) return;
200
+
201
+ // assistant 流:累积 AI 输出文本
202
+ if (stream === 'assistant') {
203
+ state.assistantBuffer += (data?.delta ?? '') as string;
204
+ return;
205
+ }
206
+
207
+ // tool:start → 推送进度消息
208
+ if (stream === 'tool' && data?.phase === 'start') {
209
+ // AI 文本优先,没有则用参数解析标签
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
+ );
217
+
218
+ state.lastSentAt = Date.now();
219
+ client.sendText(state.chatId, label).catch((err: any) => {
220
+ api.logger?.warn(`[feishu-progress] 推送失败: ${err?.message}`);
221
+ });
222
+ return;
223
+ }
224
+
225
+ // lifecycle:end/error → 清理
226
+ if (stream === 'lifecycle' && (data?.phase === 'end' || data?.phase === 'error')) {
227
+ clearInterval(state.heartbeatTimer);
228
+ runStateMap.delete(runId);
229
+ }
83
230
  });
84
231
  },
85
232
  };