@sunnoy/wecom 1.9.0 → 2.0.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/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 +83 -8
- package/wecom/ws-monitor.js +1544 -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,
|
|
@@ -100,8 +157,10 @@ export function upsertAgentIdOnlyEntry(cfg, agentId) {
|
|
|
100
157
|
const nextList = [...currentList];
|
|
101
158
|
|
|
102
159
|
// Keep "main" as the explicit default when creating agents.list for the first time.
|
|
160
|
+
// Include heartbeat: {} so main inherits agents.defaults.heartbeat even when
|
|
161
|
+
// dynamic agents also have explicit heartbeat entries (hasExplicitHeartbeatAgents).
|
|
103
162
|
if (nextList.length === 0) {
|
|
104
|
-
nextList.push({ id: "main" });
|
|
163
|
+
nextList.push({ id: "main", heartbeat: {} });
|
|
105
164
|
existingIds.add("main");
|
|
106
165
|
changed = true;
|
|
107
166
|
}
|
|
@@ -128,7 +187,7 @@ export async function ensureDynamicAgentListed(agentId, templateDir) {
|
|
|
128
187
|
|
|
129
188
|
const runtime = getRuntime();
|
|
130
189
|
const configRuntime = runtime?.config;
|
|
131
|
-
if (!configRuntime?.
|
|
190
|
+
if (!configRuntime?.writeConfigFile) {
|
|
132
191
|
return;
|
|
133
192
|
}
|
|
134
193
|
|
|
@@ -139,10 +198,26 @@ export async function ensureDynamicAgentListed(agentId, templateDir) {
|
|
|
139
198
|
return;
|
|
140
199
|
}
|
|
141
200
|
|
|
142
|
-
// Upsert into memory
|
|
201
|
+
// Upsert into in-memory config so the running gateway sees it immediately.
|
|
143
202
|
const changed = upsertAgentIdOnlyEntry(openclawConfig, normalizedId);
|
|
144
203
|
if (changed) {
|
|
145
204
|
logger.info("WeCom: dynamic agent added to in-memory agents.list", { agentId: normalizedId });
|
|
205
|
+
|
|
206
|
+
// Persist to disk so `openclaw agents list` (separate process) can see
|
|
207
|
+
// the dynamic agent and it survives gateway restarts.
|
|
208
|
+
// Write the mutated in-memory config directly (same pattern as logoutAccount).
|
|
209
|
+
// NOTE: loadConfig() returns runtimeConfigSnapshot in gateway mode — the same
|
|
210
|
+
// object we already mutated above — so a read-modify-write pattern silently
|
|
211
|
+
// skips the write (diskChanged=false). Writing directly avoids this.
|
|
212
|
+
try {
|
|
213
|
+
await configRuntime.writeConfigFile(openclawConfig);
|
|
214
|
+
logger.info("WeCom: dynamic agent persisted to config file", { agentId: normalizedId });
|
|
215
|
+
} catch (writeErr) {
|
|
216
|
+
logger.warn("WeCom: failed to persist dynamic agent to config file", {
|
|
217
|
+
agentId: normalizedId,
|
|
218
|
+
error: writeErr?.message || String(writeErr),
|
|
219
|
+
});
|
|
220
|
+
}
|
|
146
221
|
}
|
|
147
222
|
|
|
148
223
|
// Always attempt seeding so recreated/cleaned dynamic agents can recover
|