@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 +1 -1
- package/src/feishu-client.ts +21 -2
- package/src/index.ts +69 -41
package/package.json
CHANGED
package/src/feishu-client.ts
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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
|
|
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
|
|
147
|
+
// accountId ("main") → 飞书裸 ID,兜底用(sessionKey 解析失败时)
|
|
151
148
|
const accountChatMap = new Map<string, string>();
|
|
152
149
|
|
|
153
|
-
// runId → RunState
|
|
150
|
+
// runId → RunState
|
|
154
151
|
const runStateMap = new Map<string, RunState>();
|
|
155
152
|
|
|
156
|
-
//
|
|
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
|
|
171
|
+
if (ctx.accountId) accountChatMap.set(ctx.accountId, rawId);
|
|
165
172
|
});
|
|
166
173
|
|
|
167
|
-
//
|
|
174
|
+
// llm_input → 建立 runId → RunState,启动心跳
|
|
168
175
|
api.on('llm_input', async (event: any, ctx: any) => {
|
|
169
|
-
if (!event.runId
|
|
176
|
+
if (!event.runId) return;
|
|
170
177
|
if (runStateMap.has(event.runId)) return; // 同一 run 只初始化一次
|
|
171
178
|
|
|
172
|
-
|
|
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
|
-
},
|
|
203
|
+
}, 10_000),
|
|
190
204
|
};
|
|
191
205
|
|
|
192
206
|
runStateMap.set(event.runId, state);
|
|
193
207
|
});
|
|
194
208
|
|
|
195
|
-
// ──
|
|
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
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
228
|
-
|
|
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
|
},
|