@sunnoy/wecom 1.9.0 → 2.0.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.
- package/README.md +391 -845
- package/image-processor.js +30 -27
- package/index.js +10 -43
- package/package.json +5 -5
- package/think-parser.js +51 -11
- package/wecom/accounts.js +323 -189
- package/wecom/channel-plugin.js +543 -750
- package/wecom/constants.js +57 -47
- package/wecom/dm-policy.js +91 -0
- package/wecom/group-policy.js +85 -0
- package/wecom/onboarding.js +117 -0
- package/wecom/runtime-telemetry.js +330 -0
- package/wecom/sandbox.js +60 -0
- package/wecom/state.js +33 -35
- package/wecom/workspace-template.js +62 -5
- package/wecom/ws-monitor.js +1487 -0
- package/wecom/ws-state.js +160 -0
- package/crypto.js +0 -135
- package/stream-manager.js +0 -358
- package/webhook.js +0 -469
- package/wecom/agent-inbound.js +0 -541
- package/wecom/http-handler-state.js +0 -23
- package/wecom/http-handler.js +0 -395
- package/wecom/inbound-processor.js +0 -562
- package/wecom/media.js +0 -192
- package/wecom/outbound-delivery.js +0 -435
- package/wecom/response-url.js +0 -33
- package/wecom/stream-utils.js +0 -163
- package/wecom/webhook-targets.js +0 -28
- package/wecom/xml-parser.js +0 -126
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
2
|
+
const ACTIVE_SEND_LIMIT = 10;
|
|
3
|
+
const ACTIVE_SEND_WARN_THRESHOLD = 8;
|
|
4
|
+
const REPLY_LIMIT = 30;
|
|
5
|
+
const REPLY_WARN_THRESHOLD = 24;
|
|
6
|
+
const MAX_TRACKED_CHATS_PER_ACCOUNT = 500;
|
|
7
|
+
|
|
8
|
+
const accountStates = new Map();
|
|
9
|
+
|
|
10
|
+
function currentDayKey(at) {
|
|
11
|
+
return new Date(at).toISOString().slice(0, 10);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function ensureAccountState(accountId) {
|
|
15
|
+
if (!accountStates.has(accountId)) {
|
|
16
|
+
accountStates.set(accountId, {
|
|
17
|
+
lastInboundAt: null,
|
|
18
|
+
lastOutboundAt: null,
|
|
19
|
+
displaced: false,
|
|
20
|
+
lastDisplacedAt: null,
|
|
21
|
+
lastDisplacedReason: null,
|
|
22
|
+
chats: new Map(),
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
return accountStates.get(accountId);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function ensureChatState(accountState, chatId) {
|
|
29
|
+
const normalizedChatId = String(chatId ?? "").trim();
|
|
30
|
+
if (!normalizedChatId) {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
if (!accountState.chats.has(normalizedChatId)) {
|
|
34
|
+
accountState.chats.set(normalizedChatId, {
|
|
35
|
+
chatId: normalizedChatId,
|
|
36
|
+
lastInboundAt: null,
|
|
37
|
+
lastOutboundAt: null,
|
|
38
|
+
lastTouchedAt: 0,
|
|
39
|
+
replyCount24h: 0,
|
|
40
|
+
activeSendDay: currentDayKey(Date.now()),
|
|
41
|
+
activeSendCountToday: 0,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
return accountState.chats.get(normalizedChatId);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function touchChatState(chatState, at) {
|
|
48
|
+
chatState.lastTouchedAt = Math.max(chatState.lastTouchedAt ?? 0, at);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function pruneChatState(chatState, at) {
|
|
52
|
+
const dayKey = currentDayKey(at);
|
|
53
|
+
if (chatState.activeSendDay !== dayKey) {
|
|
54
|
+
chatState.activeSendDay = dayKey;
|
|
55
|
+
chatState.activeSendCountToday = 0;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (!chatState.lastInboundAt || at - chatState.lastInboundAt >= DAY_MS) {
|
|
59
|
+
chatState.replyCount24h = 0;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function pruneAccountState(accountState, at) {
|
|
64
|
+
for (const [chatId, chatState] of accountState.chats.entries()) {
|
|
65
|
+
pruneChatState(chatState, at);
|
|
66
|
+
|
|
67
|
+
const lastRelevantAt = Math.max(
|
|
68
|
+
chatState.lastTouchedAt ?? 0,
|
|
69
|
+
chatState.lastInboundAt ?? 0,
|
|
70
|
+
chatState.lastOutboundAt ?? 0,
|
|
71
|
+
);
|
|
72
|
+
const hasReplyWindow = chatState.lastInboundAt && at - chatState.lastInboundAt < DAY_MS;
|
|
73
|
+
const hasDailyQuotaState = chatState.activeSendCountToday > 0;
|
|
74
|
+
if (!hasReplyWindow && !hasDailyQuotaState && lastRelevantAt > 0 && at - lastRelevantAt >= DAY_MS) {
|
|
75
|
+
accountState.chats.delete(chatId);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (accountState.chats.size <= MAX_TRACKED_CHATS_PER_ACCOUNT) {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const oldestFirst = [...accountState.chats.entries()].sort((left, right) => {
|
|
84
|
+
const leftTouched = left[1].lastTouchedAt ?? 0;
|
|
85
|
+
const rightTouched = right[1].lastTouchedAt ?? 0;
|
|
86
|
+
return leftTouched - rightTouched;
|
|
87
|
+
});
|
|
88
|
+
const excess = accountState.chats.size - MAX_TRACKED_CHATS_PER_ACCOUNT;
|
|
89
|
+
for (const [chatId] of oldestFirst.slice(0, excess)) {
|
|
90
|
+
accountState.chats.delete(chatId);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function describeReplyQuota(chatState, at) {
|
|
95
|
+
pruneChatState(chatState, at);
|
|
96
|
+
const windowActive = Boolean(chatState.lastInboundAt && at - chatState.lastInboundAt < DAY_MS);
|
|
97
|
+
const used = windowActive ? chatState.replyCount24h : 0;
|
|
98
|
+
const remaining = windowActive ? Math.max(0, REPLY_LIMIT - used) : REPLY_LIMIT;
|
|
99
|
+
return {
|
|
100
|
+
bucket: "reply24h",
|
|
101
|
+
limit: REPLY_LIMIT,
|
|
102
|
+
windowActive,
|
|
103
|
+
used,
|
|
104
|
+
remaining,
|
|
105
|
+
exhausted: windowActive && used >= REPLY_LIMIT,
|
|
106
|
+
nearLimit: windowActive && used >= REPLY_WARN_THRESHOLD,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function describeActiveQuota(chatState, at) {
|
|
111
|
+
pruneChatState(chatState, at);
|
|
112
|
+
const used = chatState.activeSendCountToday;
|
|
113
|
+
return {
|
|
114
|
+
bucket: "activeDaily",
|
|
115
|
+
limit: ACTIVE_SEND_LIMIT,
|
|
116
|
+
used,
|
|
117
|
+
remaining: Math.max(0, ACTIVE_SEND_LIMIT - used),
|
|
118
|
+
exhausted: used >= ACTIVE_SEND_LIMIT,
|
|
119
|
+
nearLimit: used >= ACTIVE_SEND_WARN_THRESHOLD,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function forecastReplyQuota({ accountId, chatId, at = Date.now() }) {
|
|
124
|
+
const accountState = ensureAccountState(accountId);
|
|
125
|
+
const chatState = ensureChatState(accountState, chatId);
|
|
126
|
+
if (!chatState) {
|
|
127
|
+
return {
|
|
128
|
+
bucket: "reply24h",
|
|
129
|
+
limit: REPLY_LIMIT,
|
|
130
|
+
windowActive: false,
|
|
131
|
+
used: 0,
|
|
132
|
+
remaining: REPLY_LIMIT,
|
|
133
|
+
exhausted: false,
|
|
134
|
+
nearLimit: false,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
return describeReplyQuota(chatState, at);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function forecastActiveSendQuota({ accountId, chatId, at = Date.now() }) {
|
|
141
|
+
const accountState = ensureAccountState(accountId);
|
|
142
|
+
const chatState = ensureChatState(accountState, chatId);
|
|
143
|
+
if (!chatState) {
|
|
144
|
+
return {
|
|
145
|
+
bucket: "activeDaily",
|
|
146
|
+
limit: ACTIVE_SEND_LIMIT,
|
|
147
|
+
windowActive: false,
|
|
148
|
+
used: 0,
|
|
149
|
+
remaining: ACTIVE_SEND_LIMIT,
|
|
150
|
+
exhausted: false,
|
|
151
|
+
nearLimit: false,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const replyQuota = describeReplyQuota(chatState, at);
|
|
156
|
+
if (replyQuota.windowActive && !replyQuota.exhausted) {
|
|
157
|
+
return replyQuota;
|
|
158
|
+
}
|
|
159
|
+
return describeActiveQuota(chatState, at);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function recordInboundMessage({ accountId, chatId, at = Date.now() }) {
|
|
163
|
+
const accountState = ensureAccountState(accountId);
|
|
164
|
+
accountState.lastInboundAt = at;
|
|
165
|
+
|
|
166
|
+
const chatState = ensureChatState(accountState, chatId);
|
|
167
|
+
if (!chatState) {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
chatState.lastInboundAt = at;
|
|
172
|
+
chatState.replyCount24h = 0;
|
|
173
|
+
touchChatState(chatState, at);
|
|
174
|
+
pruneAccountState(accountState, at);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function recordPassiveReply({ accountId, chatId, at = Date.now(), countQuota = true } = {}) {
|
|
178
|
+
const accountState = ensureAccountState(accountId);
|
|
179
|
+
accountState.lastOutboundAt = at;
|
|
180
|
+
|
|
181
|
+
const chatState = ensureChatState(accountState, chatId);
|
|
182
|
+
if (!chatState) {
|
|
183
|
+
return {
|
|
184
|
+
bucket: "reply24h",
|
|
185
|
+
used: 0,
|
|
186
|
+
limit: REPLY_LIMIT,
|
|
187
|
+
remaining: REPLY_LIMIT,
|
|
188
|
+
windowActive: false,
|
|
189
|
+
exhausted: false,
|
|
190
|
+
nearLimit: false,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
chatState.lastOutboundAt = at;
|
|
195
|
+
touchChatState(chatState, at);
|
|
196
|
+
const quota = describeReplyQuota(chatState, at);
|
|
197
|
+
if (countQuota && quota.windowActive) {
|
|
198
|
+
chatState.replyCount24h += 1;
|
|
199
|
+
}
|
|
200
|
+
pruneAccountState(accountState, at);
|
|
201
|
+
return describeReplyQuota(chatState, at);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function recordActiveSend({ accountId, chatId, at = Date.now() }) {
|
|
205
|
+
const accountState = ensureAccountState(accountId);
|
|
206
|
+
accountState.lastOutboundAt = at;
|
|
207
|
+
|
|
208
|
+
const chatState = ensureChatState(accountState, chatId);
|
|
209
|
+
if (!chatState) {
|
|
210
|
+
return {
|
|
211
|
+
bucket: "activeDaily",
|
|
212
|
+
used: 0,
|
|
213
|
+
limit: ACTIVE_SEND_LIMIT,
|
|
214
|
+
remaining: ACTIVE_SEND_LIMIT,
|
|
215
|
+
windowActive: false,
|
|
216
|
+
exhausted: false,
|
|
217
|
+
nearLimit: false,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
chatState.lastOutboundAt = at;
|
|
222
|
+
touchChatState(chatState, at);
|
|
223
|
+
const quota = forecastActiveSendQuota({ accountId, chatId, at });
|
|
224
|
+
if (quota.bucket === "reply24h" && quota.windowActive) {
|
|
225
|
+
chatState.replyCount24h += 1;
|
|
226
|
+
} else {
|
|
227
|
+
pruneChatState(chatState, at);
|
|
228
|
+
chatState.activeSendCountToday += 1;
|
|
229
|
+
}
|
|
230
|
+
pruneAccountState(accountState, at);
|
|
231
|
+
return forecastActiveSendQuota({ accountId, chatId, at });
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function recordOutboundActivity({ accountId, at = Date.now() }) {
|
|
235
|
+
const accountState = ensureAccountState(accountId);
|
|
236
|
+
accountState.lastOutboundAt = at;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function markAccountDisplaced({ accountId, reason = null, at = Date.now() }) {
|
|
240
|
+
const accountState = ensureAccountState(accountId);
|
|
241
|
+
accountState.displaced = true;
|
|
242
|
+
accountState.lastDisplacedAt = at;
|
|
243
|
+
accountState.lastDisplacedReason = reason ? String(reason) : null;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export function clearAccountDisplaced(accountId) {
|
|
247
|
+
const accountState = ensureAccountState(accountId);
|
|
248
|
+
accountState.displaced = false;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export function getAccountTelemetry(accountId, { now = Date.now() } = {}) {
|
|
252
|
+
const accountState = accountStates.get(accountId);
|
|
253
|
+
if (!accountState) {
|
|
254
|
+
return {
|
|
255
|
+
lastInboundAt: null,
|
|
256
|
+
lastOutboundAt: null,
|
|
257
|
+
connection: {
|
|
258
|
+
displaced: false,
|
|
259
|
+
lastDisplacedAt: null,
|
|
260
|
+
lastDisplacedReason: null,
|
|
261
|
+
},
|
|
262
|
+
quotas: {
|
|
263
|
+
trackedChats: 0,
|
|
264
|
+
replyWindowChats: 0,
|
|
265
|
+
totalReplyCount24h: 0,
|
|
266
|
+
nearLimitReplyChats: 0,
|
|
267
|
+
exhaustedReplyChats: 0,
|
|
268
|
+
activeDailyChats: 0,
|
|
269
|
+
totalActiveSendCountToday: 0,
|
|
270
|
+
nearLimitActiveChats: 0,
|
|
271
|
+
exhaustedActiveChats: 0,
|
|
272
|
+
},
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
pruneAccountState(accountState, now);
|
|
277
|
+
|
|
278
|
+
const summary = {
|
|
279
|
+
trackedChats: 0,
|
|
280
|
+
replyWindowChats: 0,
|
|
281
|
+
totalReplyCount24h: 0,
|
|
282
|
+
nearLimitReplyChats: 0,
|
|
283
|
+
exhaustedReplyChats: 0,
|
|
284
|
+
activeDailyChats: 0,
|
|
285
|
+
totalActiveSendCountToday: 0,
|
|
286
|
+
nearLimitActiveChats: 0,
|
|
287
|
+
exhaustedActiveChats: 0,
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
for (const chatState of accountState.chats.values()) {
|
|
291
|
+
summary.trackedChats += 1;
|
|
292
|
+
|
|
293
|
+
const replyQuota = describeReplyQuota(chatState, now);
|
|
294
|
+
if (replyQuota.windowActive) {
|
|
295
|
+
summary.replyWindowChats += 1;
|
|
296
|
+
summary.totalReplyCount24h += replyQuota.used;
|
|
297
|
+
if (replyQuota.exhausted) {
|
|
298
|
+
summary.exhaustedReplyChats += 1;
|
|
299
|
+
} else if (replyQuota.nearLimit) {
|
|
300
|
+
summary.nearLimitReplyChats += 1;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const activeQuota = describeActiveQuota(chatState, now);
|
|
305
|
+
if (activeQuota.used > 0) {
|
|
306
|
+
summary.activeDailyChats += 1;
|
|
307
|
+
summary.totalActiveSendCountToday += activeQuota.used;
|
|
308
|
+
if (activeQuota.exhausted) {
|
|
309
|
+
summary.exhaustedActiveChats += 1;
|
|
310
|
+
} else if (activeQuota.nearLimit) {
|
|
311
|
+
summary.nearLimitActiveChats += 1;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return {
|
|
317
|
+
lastInboundAt: accountState.lastInboundAt,
|
|
318
|
+
lastOutboundAt: accountState.lastOutboundAt,
|
|
319
|
+
connection: {
|
|
320
|
+
displaced: accountState.displaced,
|
|
321
|
+
lastDisplacedAt: accountState.lastDisplacedAt,
|
|
322
|
+
lastDisplacedReason: accountState.lastDisplacedReason,
|
|
323
|
+
},
|
|
324
|
+
quotas: summary,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export function resetRuntimeTelemetryForTesting() {
|
|
329
|
+
accountStates.clear();
|
|
330
|
+
}
|
package/wecom/sandbox.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { realpath } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Resolve the allowed sandbox roots for outbound file access.
|
|
6
|
+
*
|
|
7
|
+
* @param {object} params
|
|
8
|
+
* @param {Function} params.resolveAgentWorkspaceDir - (config, agentId) => string
|
|
9
|
+
* @param {Function} params.resolveDefaultAgentId - (config) => string
|
|
10
|
+
* @param {Function} params.resolveStateDir - () => string
|
|
11
|
+
* @param {object} params.config - OpenClaw configuration
|
|
12
|
+
* @param {string} [params.agentId] - Agent identifier
|
|
13
|
+
* @returns {string[]} Deduplicated list of resolved absolute root paths.
|
|
14
|
+
*/
|
|
15
|
+
export function resolveSandboxRoots({ resolveAgentWorkspaceDir, resolveDefaultAgentId, resolveStateDir, config, agentId }) {
|
|
16
|
+
const effectiveAgentId = agentId || resolveDefaultAgentId(config);
|
|
17
|
+
const workspaceDir = resolveAgentWorkspaceDir(config, effectiveAgentId);
|
|
18
|
+
const stateDir = resolveStateDir();
|
|
19
|
+
const mediaCacheDir = path.join(stateDir, "media");
|
|
20
|
+
const browserMediaDir = path.join(stateDir, "media", "browser");
|
|
21
|
+
|
|
22
|
+
return [...new Set([workspaceDir, mediaCacheDir, browserMediaDir].map((p) => path.resolve(p)))];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Validate that a file path falls within one of the allowed sandbox roots.
|
|
27
|
+
*
|
|
28
|
+
* Uses `fs.realpath` to resolve symlinks before checking, preventing
|
|
29
|
+
* symlink-based escapes. Falls back to `path.resolve` if `realpath` fails
|
|
30
|
+
* (e.g. file does not exist yet), which still catches `..` traversal.
|
|
31
|
+
*
|
|
32
|
+
* @param {string} filePath - The absolute file path to validate.
|
|
33
|
+
* @param {string[]} allowedRoots - List of allowed root directories.
|
|
34
|
+
* @throws {Error} If the path escapes all sandbox roots.
|
|
35
|
+
*/
|
|
36
|
+
export async function assertPathInsideSandbox(filePath, allowedRoots) {
|
|
37
|
+
if (!filePath || typeof filePath !== "string") {
|
|
38
|
+
throw new Error("Sandbox violation: empty file path");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const resolved = path.resolve(filePath);
|
|
42
|
+
|
|
43
|
+
// Resolve symlinks when possible; fall back to lexical resolve.
|
|
44
|
+
let real;
|
|
45
|
+
try {
|
|
46
|
+
real = await realpath(resolved);
|
|
47
|
+
} catch {
|
|
48
|
+
real = resolved;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
for (const root of allowedRoots) {
|
|
52
|
+
const resolvedRoot = path.resolve(root);
|
|
53
|
+
// Ensure trailing separator so "/workspace-x" doesn't match "/workspace-xyz".
|
|
54
|
+
if (real === resolvedRoot || real.startsWith(resolvedRoot + path.sep)) {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
throw new Error(`Sandbox violation: path "${filePath}" escapes allowed roots`);
|
|
60
|
+
}
|
package/wecom/state.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
2
|
import { getWebhookBotSendUrl } from "./constants.js";
|
|
3
|
-
import { resolveAgentConfigForAccount, resolveAccount } from "./accounts.js";
|
|
3
|
+
import { resolveAgentConfigForAccount, resolveDefaultAccountId, resolveAccount } from "./accounts.js";
|
|
4
4
|
|
|
5
5
|
const runtimeState = {
|
|
6
6
|
runtime: null,
|
|
@@ -10,13 +10,6 @@ const runtimeState = {
|
|
|
10
10
|
};
|
|
11
11
|
|
|
12
12
|
export const dispatchLocks = new Map();
|
|
13
|
-
export const messageBuffers = new Map();
|
|
14
|
-
export const webhookTargets = new Map();
|
|
15
|
-
export const activeStreams = new Map();
|
|
16
|
-
export const activeStreamHistory = new Map();
|
|
17
|
-
export const lastStreamByKey = new Map();
|
|
18
|
-
export const streamMeta = new Map();
|
|
19
|
-
export const responseUrls = new Map();
|
|
20
13
|
export const streamContext = new AsyncLocalStorage();
|
|
21
14
|
|
|
22
15
|
export function setRuntime(runtime) {
|
|
@@ -50,36 +43,41 @@ export function setEnsureDynamicAgentWriteQueue(queuePromise) {
|
|
|
50
43
|
runtimeState.ensureDynamicAgentWriteQueue = queuePromise;
|
|
51
44
|
}
|
|
52
45
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
46
|
+
function resolveEffectiveAccountId(accountId) {
|
|
47
|
+
if (accountId) {
|
|
48
|
+
return accountId;
|
|
49
|
+
}
|
|
50
|
+
const contextual = streamContext.getStore()?.accountId;
|
|
51
|
+
if (contextual) {
|
|
52
|
+
return contextual;
|
|
53
|
+
}
|
|
54
|
+
return resolveDefaultAccountId(getOpenclawConfig());
|
|
55
|
+
}
|
|
56
|
+
|
|
60
57
|
export function resolveAgentConfig(accountId) {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
58
|
+
return resolveAgentConfigForAccount(getOpenclawConfig(), resolveEffectiveAccountId(accountId));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function resolveAccountConfig(accountId) {
|
|
62
|
+
return resolveAccount(getOpenclawConfig(), resolveEffectiveAccountId(accountId));
|
|
65
63
|
}
|
|
66
64
|
|
|
67
|
-
/**
|
|
68
|
-
* Resolve a webhook name to a full webhook URL.
|
|
69
|
-
* Supports both full URLs and bare keys in config.
|
|
70
|
-
* Returns null when the webhook name is not configured.
|
|
71
|
-
*
|
|
72
|
-
* @param {string} name - Webhook name from the `to` field (e.g. "ops-group")
|
|
73
|
-
* @param {string} [accountId] - Optional account ID for multi-account lookup.
|
|
74
|
-
* @returns {string|null}
|
|
75
|
-
*/
|
|
76
65
|
export function resolveWebhookUrl(name, accountId) {
|
|
77
|
-
const
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
66
|
+
const account = resolveAccountConfig(accountId);
|
|
67
|
+
const value = account?.config?.webhooks?.[name];
|
|
68
|
+
if (!value) {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
if (String(value).startsWith("http")) {
|
|
72
|
+
return String(value);
|
|
73
|
+
}
|
|
84
74
|
return `${getWebhookBotSendUrl()}?key=${value}`;
|
|
85
75
|
}
|
|
76
|
+
|
|
77
|
+
export function resetStateForTesting() {
|
|
78
|
+
runtimeState.runtime = null;
|
|
79
|
+
runtimeState.openclawConfig = null;
|
|
80
|
+
runtimeState.ensuredDynamicAgentIds = new Set();
|
|
81
|
+
runtimeState.ensureDynamicAgentWriteQueue = Promise.resolve();
|
|
82
|
+
dispatchLocks.clear();
|
|
83
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { copyFileSync, existsSync, mkdirSync, readdirSync } from "node:fs";
|
|
1
|
+
import { copyFileSync, existsSync, mkdirSync, readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
2
3
|
import { join } from "node:path";
|
|
3
4
|
import { logger } from "../logger.js";
|
|
4
5
|
import { BOOTSTRAP_FILENAMES } from "./constants.js";
|
|
@@ -11,6 +12,40 @@ import {
|
|
|
11
12
|
setOpenclawConfig,
|
|
12
13
|
} from "./state.js";
|
|
13
14
|
|
|
15
|
+
function expandTilde(p) {
|
|
16
|
+
if (!p || !p.startsWith("~")) return p;
|
|
17
|
+
return join(homedir(), p.slice(1));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// --- mtime caches for force-reseed ---
|
|
21
|
+
const _templateMtimeCache = new Map(); // templateDir → { maxMtimeMs, checkedAt }
|
|
22
|
+
const _agentSeedMtimeCache = new Map(); // `${templateDir}::${agentId}` → maxMtimeMs
|
|
23
|
+
const TEMPLATE_MTIME_CACHE_TTL_MS = 60_000;
|
|
24
|
+
|
|
25
|
+
function getTemplateMaxMtimeMs(templateDir) {
|
|
26
|
+
const now = Date.now();
|
|
27
|
+
const cached = _templateMtimeCache.get(templateDir);
|
|
28
|
+
if (cached && now - cached.checkedAt < TEMPLATE_MTIME_CACHE_TTL_MS) {
|
|
29
|
+
return cached.maxMtimeMs;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let maxMtimeMs = 0;
|
|
33
|
+
const files = readdirSync(templateDir);
|
|
34
|
+
for (const file of files) {
|
|
35
|
+
if (!BOOTSTRAP_FILENAMES.has(file)) continue;
|
|
36
|
+
const st = statSync(join(templateDir, file));
|
|
37
|
+
if (st.mtimeMs > maxMtimeMs) maxMtimeMs = st.mtimeMs;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
_templateMtimeCache.set(templateDir, { maxMtimeMs, checkedAt: now });
|
|
41
|
+
return maxMtimeMs;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function clearTemplateMtimeCache({ agentSeedCache = true } = {}) {
|
|
45
|
+
_templateMtimeCache.clear();
|
|
46
|
+
if (agentSeedCache) _agentSeedMtimeCache.clear();
|
|
47
|
+
}
|
|
48
|
+
|
|
14
49
|
/**
|
|
15
50
|
* Resolve the agent workspace directory for a given agentId.
|
|
16
51
|
* Mirrors openclaw core's resolveAgentWorkspaceDir logic for non-default agents:
|
|
@@ -42,7 +77,8 @@ export function getWorkspaceTemplateDir(config) {
|
|
|
42
77
|
* @param {string} [overrideTemplateDir] - Optional per-account template directory
|
|
43
78
|
*/
|
|
44
79
|
export function seedAgentWorkspace(agentId, config, overrideTemplateDir) {
|
|
45
|
-
const
|
|
80
|
+
const rawTemplateDir = overrideTemplateDir || getWorkspaceTemplateDir(config);
|
|
81
|
+
const templateDir = expandTilde(rawTemplateDir);
|
|
46
82
|
if (!templateDir) {
|
|
47
83
|
return;
|
|
48
84
|
}
|
|
@@ -55,6 +91,15 @@ export function seedAgentWorkspace(agentId, config, overrideTemplateDir) {
|
|
|
55
91
|
const workspaceDir = resolveAgentWorkspaceDirLocal(agentId);
|
|
56
92
|
|
|
57
93
|
try {
|
|
94
|
+
const templateMaxMtimeMs = getTemplateMaxMtimeMs(templateDir);
|
|
95
|
+
const cacheKey = `${templateDir}::${agentId}`;
|
|
96
|
+
const lastSyncedMtimeMs = _agentSeedMtimeCache.get(cacheKey) ?? 0;
|
|
97
|
+
const isFirstSeed = lastSyncedMtimeMs === 0;
|
|
98
|
+
|
|
99
|
+
if (templateMaxMtimeMs <= lastSyncedMtimeMs && existsSync(workspaceDir)) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
58
103
|
mkdirSync(workspaceDir, { recursive: true });
|
|
59
104
|
|
|
60
105
|
const files = readdirSync(templateDir);
|
|
@@ -62,13 +107,25 @@ export function seedAgentWorkspace(agentId, config, overrideTemplateDir) {
|
|
|
62
107
|
if (!BOOTSTRAP_FILENAMES.has(file)) {
|
|
63
108
|
continue;
|
|
64
109
|
}
|
|
110
|
+
const src = join(templateDir, file);
|
|
65
111
|
const dest = join(workspaceDir, file);
|
|
66
112
|
if (existsSync(dest)) {
|
|
67
|
-
|
|
113
|
+
if (!isFirstSeed) {
|
|
114
|
+
const srcMtimeMs = statSync(src).mtimeMs;
|
|
115
|
+
const destMtimeMs = statSync(dest).mtimeMs;
|
|
116
|
+
if (srcMtimeMs <= destMtimeMs) {
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
copyFileSync(src, dest);
|
|
121
|
+
logger.info("WeCom: re-seeded workspace file", { agentId, file, isFirstSeed });
|
|
122
|
+
} else {
|
|
123
|
+
copyFileSync(src, dest);
|
|
124
|
+
logger.info("WeCom: seeded workspace file", { agentId, file });
|
|
68
125
|
}
|
|
69
|
-
copyFileSync(join(templateDir, file), dest);
|
|
70
|
-
logger.info("WeCom: seeded workspace file", { agentId, file });
|
|
71
126
|
}
|
|
127
|
+
|
|
128
|
+
_agentSeedMtimeCache.set(cacheKey, templateMaxMtimeMs);
|
|
72
129
|
} catch (err) {
|
|
73
130
|
logger.warn("WeCom: failed to seed agent workspace", {
|
|
74
131
|
agentId,
|