@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.
- package/package.json +1 -1
- package/src/index.ts +59 -38
package/package.json
CHANGED
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
|
|
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
|
|
146
|
+
// runId → RunState
|
|
154
147
|
const runStateMap = new Map<string, RunState>();
|
|
155
148
|
|
|
156
|
-
// ──
|
|
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
|
|
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
|
-
//
|
|
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
|
|
166
|
+
if (!event.runId) return;
|
|
170
167
|
if (runStateMap.has(event.runId)) return; // 同一 run 只初始化一次
|
|
171
168
|
|
|
172
|
-
|
|
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
|
-
// ──
|
|
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
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
};
|