@vui-design/openclaw-plugin-feishu-progress 0.4.1 → 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.1",
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
@@ -99,12 +99,21 @@ function extractSnippet(text: string): string {
99
99
  return (lines[lines.length - 1] ?? '').slice(0, 50);
100
100
  }
101
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
+
102
110
  // ── 主插件 ────────────────────────────────────────────────────────────────
103
111
 
104
112
  interface RunState {
105
- /** 飞书接收方 ID(ou_xxx / oc_xxx) */
106
113
  chatId: string;
107
- /** 当前轮次累积的 AI 输出文本 */
114
+ /** 用于 agent_end 精确匹配,避免按 chatId 误删并发 run */
115
+ agentId: string;
116
+ sessionKey: string;
108
117
  assistantBuffer: string;
109
118
  /** 已发送过的 snippet,防止重复推送 */
110
119
  sentSnippets: Set<string>;
@@ -135,50 +144,54 @@ export default {
135
144
  domain: cfg.domain ?? 'feishu',
136
145
  });
137
146
 
138
- // ── 关联数据 ────────────────────────────────────────────────────────────
139
-
140
- // accountId ("main") → 飞书裸 ID(ou_xxx / oc_xxx)
141
- // 用 sessionKey 细化:sessionKey → chatId(隔离多对话)
142
- const sessionKeyChatMap = new Map<string, string>();
143
- // accountId → chatId(兜底,在没有 sessionKey 时使用)
147
+ // accountId ("main") → 飞书裸 ID,兜底用(sessionKey 解析失败时)
144
148
  const accountChatMap = new Map<string, string>();
145
149
 
146
150
  // runId → RunState
147
151
  const runStateMap = new Map<string, RunState>();
148
152
 
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
+
149
161
  // ── Hooks ───────────────────────────────────────────────────────────────
150
162
 
151
- // 收到飞书消息 → 记录 chatId,同时按 sessionKey 和 accountId 双索引
163
+ // 收到飞书消息 → 记录 accountId chatId(兜底索引)
152
164
  api.on('message_received', async (_event: any, ctx: any) => {
153
165
  if (ctx.channelId !== 'feishu') return;
154
166
  if (!ctx.conversationId) return;
167
+ // OpenClaw 内部格式 "user:ou_xxx",剥离前缀
155
168
  const rawId = ctx.conversationId.includes(':')
156
169
  ? ctx.conversationId.split(':').pop()!
157
170
  : ctx.conversationId;
158
171
  if (ctx.accountId) accountChatMap.set(ctx.accountId, rawId);
159
- // sessionKey 在 message_received ctx 里也可能存在
160
- if (ctx.sessionKey) sessionKeyChatMap.set(ctx.sessionKey, rawId);
161
172
  });
162
173
 
163
174
  // llm_input → 建立 runId → RunState,启动心跳
164
- // 此时 ctx.agentId 和 ctx.sessionKey 均可用,可精确找到 chatId
165
175
  api.on('llm_input', async (event: any, ctx: any) => {
166
176
  if (!event.runId) return;
167
177
  if (runStateMap.has(event.runId)) return; // 同一 run 只初始化一次
168
178
 
169
- // sessionKey 精确匹配优先,其次 agentId 兜底
179
+ // sessionKey 精确解析 chatId;无法解析时退回 accountChatMap
170
180
  const chatId =
171
- (ctx.sessionKey ? sessionKeyChatMap.get(ctx.sessionKey) : undefined) ??
181
+ chatIdFromSessionKey(ctx.sessionKey) ??
172
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: '',
179
191
  sentSnippets: new Set(),
180
192
  startedAt,
181
193
  lastSentAt: startedAt,
194
+ // 10s 间隔确保 55s 阈值能准时触发
182
195
  heartbeatTimer: setInterval(() => {
183
196
  if (!runStateMap.has(event.runId)) return;
184
197
  const idleSec = (Date.now() - state.lastSentAt) / 1000;
@@ -187,7 +200,7 @@ export default {
187
200
  state.lastSentAt = Date.now();
188
201
  client.sendText(chatId, `⏳ 处理中,已用时约 ${elapsed} 秒...`).catch(() => {});
189
202
  }
190
- }, 30_000),
203
+ }, 10_000),
191
204
  };
192
205
 
193
206
  runStateMap.set(event.runId, state);
@@ -212,7 +225,7 @@ export default {
212
225
 
213
226
  let label: string;
214
227
  if (snippet && !state.sentSnippets.has(snippet)) {
215
- // AI 文本去重:同一段文字不重复发送
228
+ // AI 文本优先,且去重
216
229
  state.sentSnippets.add(snippet);
217
230
  label = snippet;
218
231
  } else {
@@ -231,22 +244,16 @@ export default {
231
244
 
232
245
  // lifecycle:end/error → 清理
233
246
  if (stream === 'lifecycle' && (data?.phase === 'end' || data?.phase === 'error')) {
234
- clearInterval(state.heartbeatTimer);
235
- runStateMap.delete(runId);
247
+ cleanupRun(runId);
236
248
  }
237
249
  });
238
250
 
239
- // agent_end hook 作为兜底清理(防止 lifecycle 事件丢失时内存泄漏)
251
+ // agent_end 兜底清理(按 sessionKey/agentId 精确匹配,不按 chatId)
240
252
  api.on('agent_end', async (_event: any, ctx: any) => {
241
- // 通过 sessionKey 找到对应的 runId 并清理
242
253
  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
- }
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);
250
257
  }
251
258
  });
252
259
  },