@vui-design/openclaw-plugin-feishu-progress 0.4.1 → 0.4.3
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 +34 -27
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
|
@@ -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
|
-
/**
|
|
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
|
-
// 收到飞书消息 → 记录
|
|
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
|
|
179
|
+
// 从 sessionKey 精确解析 chatId;无法解析时退回 accountChatMap
|
|
170
180
|
const chatId =
|
|
171
|
-
(ctx.sessionKey
|
|
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
|
-
},
|
|
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
|
-
|
|
235
|
-
runStateMap.delete(runId);
|
|
247
|
+
cleanupRun(runId);
|
|
236
248
|
}
|
|
237
249
|
});
|
|
238
250
|
|
|
239
|
-
// agent_end
|
|
251
|
+
// agent_end → 兜底清理(lifecycle:end 未触发时的保障)
|
|
252
|
+
// 只按 sessionKey 匹配,避免同 agentId 多并发 run 时误清其他 run
|
|
240
253
|
api.on('agent_end', async (_event: any, ctx: any) => {
|
|
241
|
-
//
|
|
254
|
+
if (!ctx.sessionKey) return; // 无 sessionKey 无法精确匹配,跳过
|
|
242
255
|
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
|
-
}
|
|
256
|
+
if (state.sessionKey === ctx.sessionKey) cleanupRun(runId);
|
|
250
257
|
}
|
|
251
258
|
});
|
|
252
259
|
},
|