@sunnoy/wecom 2.1.0 → 2.2.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 +6 -2
- package/openclaw.plugin.json +3 -0
- package/package.json +5 -3
- package/skills/wecom-doc/SKILL.md +363 -0
- package/skills/wecom-doc/references/doc-api.md +224 -0
- package/wecom/accounts.js +1 -0
- package/wecom/callback-inbound.js +133 -33
- package/wecom/channel-plugin.js +107 -125
- package/wecom/constants.js +79 -3
- package/wecom/mcp-config.js +146 -0
- package/wecom/media-uploader.js +208 -0
- package/wecom/openclaw-compat.js +302 -0
- package/wecom/reqid-store.js +146 -0
- package/wecom/workspace-template.js +107 -21
- package/wecom/ws-monitor.js +665 -324
- package/image-processor.js +0 -175
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
3
|
+
import { logger } from "../logger.js";
|
|
4
|
+
import { REQID_TTL_MS, REQID_MAX_SIZE, REQID_FLUSH_DEBOUNCE_MS } from "./constants.js";
|
|
5
|
+
import { resolveStateDir } from "./openclaw-compat.js";
|
|
6
|
+
|
|
7
|
+
function getStorePath(accountId) {
|
|
8
|
+
return path.join(resolveStateDir(), "wecomConfig", `reqids-${accountId}.json`);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async function readJsonFile(filePath) {
|
|
12
|
+
try {
|
|
13
|
+
return JSON.parse(await readFile(filePath, "utf8"));
|
|
14
|
+
} catch (error) {
|
|
15
|
+
if (error?.code === "ENOENT") return {};
|
|
16
|
+
throw error;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function writeJsonFileAtomically(filePath, value) {
|
|
21
|
+
const dir = path.dirname(filePath);
|
|
22
|
+
await mkdir(dir, { recursive: true });
|
|
23
|
+
const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
24
|
+
await writeFile(tempPath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
25
|
+
await rename(tempPath, filePath);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function createPersistentReqIdStore(accountId, options = {}) {
|
|
29
|
+
const maxSize = options.maxSize ?? REQID_MAX_SIZE;
|
|
30
|
+
const ttlMs = options.ttlMs ?? REQID_TTL_MS;
|
|
31
|
+
const debounceMs = options.debounceMs ?? REQID_FLUSH_DEBOUNCE_MS;
|
|
32
|
+
const storePath = options.storePath ?? getStorePath(accountId);
|
|
33
|
+
const writeJson = options.writeJsonFileAtomically ?? writeJsonFileAtomically;
|
|
34
|
+
const cache = new Map();
|
|
35
|
+
let dirty = false;
|
|
36
|
+
let dirtyVersion = 0;
|
|
37
|
+
let flushTimer = null;
|
|
38
|
+
let flushPromise = null;
|
|
39
|
+
|
|
40
|
+
function evictOldest() {
|
|
41
|
+
if (cache.size <= maxSize) return;
|
|
42
|
+
const entries = [...cache.entries()].sort((a, b) => a[1].updatedAt - b[1].updatedAt);
|
|
43
|
+
const toRemove = entries.length - maxSize;
|
|
44
|
+
for (let i = 0; i < toRemove; i++) {
|
|
45
|
+
cache.delete(entries[i][0]);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function scheduleFlush() {
|
|
50
|
+
if (flushTimer) return;
|
|
51
|
+
flushTimer = setTimeout(async () => {
|
|
52
|
+
flushTimer = null;
|
|
53
|
+
try {
|
|
54
|
+
await store.flush();
|
|
55
|
+
} catch (error) {
|
|
56
|
+
logger.warn(`[ReqIdStore:${accountId}] Debounced flush failed: ${error.message}`);
|
|
57
|
+
}
|
|
58
|
+
}, debounceMs);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const store = {
|
|
62
|
+
set(chatId, reqId) {
|
|
63
|
+
cache.set(chatId, { reqId, updatedAt: Date.now() });
|
|
64
|
+
dirty = true;
|
|
65
|
+
dirtyVersion += 1;
|
|
66
|
+
evictOldest();
|
|
67
|
+
scheduleFlush();
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
getSync(chatId) {
|
|
71
|
+
const entry = cache.get(chatId);
|
|
72
|
+
if (!entry) return undefined;
|
|
73
|
+
if (Date.now() - entry.updatedAt > ttlMs) {
|
|
74
|
+
cache.delete(chatId);
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
return entry.reqId;
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
async warmup() {
|
|
81
|
+
try {
|
|
82
|
+
const data = await readJsonFile(storePath);
|
|
83
|
+
const now = Date.now();
|
|
84
|
+
for (const [chatId, entry] of Object.entries(data)) {
|
|
85
|
+
if (entry?.reqId && entry?.updatedAt && now - entry.updatedAt <= ttlMs) {
|
|
86
|
+
cache.set(chatId, entry);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
evictOldest();
|
|
90
|
+
logger.info(`[ReqIdStore:${accountId}] Warmed up ${cache.size} entries from ${storePath}`);
|
|
91
|
+
} catch (error) {
|
|
92
|
+
logger.warn(`[ReqIdStore:${accountId}] Warmup failed (non-fatal): ${error.message}`);
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
async flush() {
|
|
97
|
+
if (flushPromise) {
|
|
98
|
+
await flushPromise;
|
|
99
|
+
if (!dirty) return;
|
|
100
|
+
}
|
|
101
|
+
if (!dirty) return;
|
|
102
|
+
const currentFlush = (async () => {
|
|
103
|
+
const snapshot = Object.fromEntries(cache);
|
|
104
|
+
const snapshotVersion = dirtyVersion;
|
|
105
|
+
try {
|
|
106
|
+
await writeJson(storePath, snapshot);
|
|
107
|
+
if (dirtyVersion === snapshotVersion) {
|
|
108
|
+
dirty = false;
|
|
109
|
+
} else {
|
|
110
|
+
scheduleFlush();
|
|
111
|
+
}
|
|
112
|
+
logger.debug(`[ReqIdStore:${accountId}] Flushed ${cache.size} entries to disk`);
|
|
113
|
+
} catch (error) {
|
|
114
|
+
// Keep dirty = true so a subsequent set() or scheduled flush can retry
|
|
115
|
+
logger.warn(`[ReqIdStore:${accountId}] Flush failed, will retry on next trigger: ${error.message}`);
|
|
116
|
+
scheduleFlush();
|
|
117
|
+
}
|
|
118
|
+
})();
|
|
119
|
+
flushPromise = currentFlush;
|
|
120
|
+
try {
|
|
121
|
+
await currentFlush;
|
|
122
|
+
} finally {
|
|
123
|
+
if (flushPromise === currentFlush) {
|
|
124
|
+
flushPromise = null;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
|
|
129
|
+
destroy() {
|
|
130
|
+
if (flushTimer) {
|
|
131
|
+
clearTimeout(flushTimer);
|
|
132
|
+
flushTimer = null;
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
|
|
136
|
+
get size() {
|
|
137
|
+
return cache.size;
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
return store;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export const reqIdStoreTesting = {
|
|
145
|
+
getStorePath,
|
|
146
|
+
};
|
|
@@ -1,4 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
copyFileSync,
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
readdirSync,
|
|
7
|
+
renameSync,
|
|
8
|
+
statSync,
|
|
9
|
+
writeFileSync,
|
|
10
|
+
} from "node:fs";
|
|
2
11
|
import { homedir } from "node:os";
|
|
3
12
|
import { join } from "node:path";
|
|
4
13
|
import { logger } from "../logger.js";
|
|
@@ -19,8 +28,10 @@ function expandTilde(p) {
|
|
|
19
28
|
|
|
20
29
|
// --- mtime caches for force-reseed ---
|
|
21
30
|
const _templateMtimeCache = new Map(); // templateDir → { maxMtimeMs, checkedAt }
|
|
22
|
-
const _agentSeedMtimeCache = new Map(); // `${templateDir}::${agentId}` → maxMtimeMs
|
|
23
31
|
const TEMPLATE_MTIME_CACHE_TTL_MS = 60_000;
|
|
32
|
+
const TEMPLATE_STATE_DIRNAME = ".openclaw";
|
|
33
|
+
const TEMPLATE_STATE_FILENAME = "wecom-template-state.json";
|
|
34
|
+
const TEMPLATE_STATE_VERSION = 1;
|
|
24
35
|
|
|
25
36
|
function getTemplateMaxMtimeMs(templateDir) {
|
|
26
37
|
const now = Date.now();
|
|
@@ -43,7 +54,7 @@ function getTemplateMaxMtimeMs(templateDir) {
|
|
|
43
54
|
|
|
44
55
|
export function clearTemplateMtimeCache({ agentSeedCache = true } = {}) {
|
|
45
56
|
_templateMtimeCache.clear();
|
|
46
|
-
|
|
57
|
+
void agentSeedCache;
|
|
47
58
|
}
|
|
48
59
|
|
|
49
60
|
/**
|
|
@@ -67,9 +78,76 @@ export function getWorkspaceTemplateDir(config) {
|
|
|
67
78
|
return config?.channels?.wecom?.workspaceTemplate?.trim() || null;
|
|
68
79
|
}
|
|
69
80
|
|
|
81
|
+
function hasWorkspaceMemoryMarkers(workspaceDir) {
|
|
82
|
+
return existsSync(join(workspaceDir, "memory")) || existsSync(join(workspaceDir, "MEMORY.md"));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function resolveTemplateStatePath(workspaceDir) {
|
|
86
|
+
return join(workspaceDir, TEMPLATE_STATE_DIRNAME, TEMPLATE_STATE_FILENAME);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function readTemplateState(workspaceDir) {
|
|
90
|
+
const statePath = resolveTemplateStatePath(workspaceDir);
|
|
91
|
+
if (!existsSync(statePath)) {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
const raw = readFileSync(statePath, "utf8");
|
|
97
|
+
const parsed = JSON.parse(raw);
|
|
98
|
+
if (!parsed || typeof parsed !== "object") {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
version: parsed.version,
|
|
104
|
+
seededAt: typeof parsed.seededAt === "string" ? parsed.seededAt : null,
|
|
105
|
+
templateDir: typeof parsed.templateDir === "string" ? parsed.templateDir : null,
|
|
106
|
+
seededFiles: Array.isArray(parsed.seededFiles)
|
|
107
|
+
? parsed.seededFiles.filter((file) => typeof file === "string")
|
|
108
|
+
: [],
|
|
109
|
+
templateMtimeMs:
|
|
110
|
+
typeof parsed.templateMtimeMs === "number" && Number.isFinite(parsed.templateMtimeMs)
|
|
111
|
+
? parsed.templateMtimeMs
|
|
112
|
+
: null,
|
|
113
|
+
migratedFromLegacy: parsed.migratedFromLegacy === true,
|
|
114
|
+
};
|
|
115
|
+
} catch {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function writeTemplateState(workspaceDir, state) {
|
|
121
|
+
const statePath = resolveTemplateStatePath(workspaceDir);
|
|
122
|
+
const stateDir = join(workspaceDir, TEMPLATE_STATE_DIRNAME);
|
|
123
|
+
mkdirSync(stateDir, { recursive: true });
|
|
124
|
+
|
|
125
|
+
const payload = `${JSON.stringify(
|
|
126
|
+
{
|
|
127
|
+
version: TEMPLATE_STATE_VERSION,
|
|
128
|
+
seededAt: state.seededAt,
|
|
129
|
+
templateDir: state.templateDir,
|
|
130
|
+
seededFiles: [...new Set(state.seededFiles)].sort(),
|
|
131
|
+
templateMtimeMs: state.templateMtimeMs,
|
|
132
|
+
migratedFromLegacy: state.migratedFromLegacy === true,
|
|
133
|
+
},
|
|
134
|
+
null,
|
|
135
|
+
2,
|
|
136
|
+
)}\n`;
|
|
137
|
+
const tmpPath = `${statePath}.tmp-${process.pid}-${Date.now().toString(36)}`;
|
|
138
|
+
writeFileSync(tmpPath, payload, "utf8");
|
|
139
|
+
renameSync(tmpPath, statePath);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function collectExistingSeededFiles(workspaceDir, templateFiles) {
|
|
143
|
+
return templateFiles.filter((file) => existsSync(join(workspaceDir, file)));
|
|
144
|
+
}
|
|
145
|
+
|
|
70
146
|
/**
|
|
71
|
-
* Copy template files into a
|
|
72
|
-
*
|
|
147
|
+
* Copy selected template files into a dynamic agent workspace.
|
|
148
|
+
* BOOTSTRAP.md may be synced only before user memory markers appear; once the
|
|
149
|
+
* workspace has memory/ or MEMORY.md, bootstrap is treated as completed and is
|
|
150
|
+
* never re-seeded by the plugin.
|
|
73
151
|
* Silently skips if workspaceTemplate is not configured or directory is missing.
|
|
74
152
|
*
|
|
75
153
|
* @param {string} agentId
|
|
@@ -89,33 +167,38 @@ export function seedAgentWorkspace(agentId, config, overrideTemplateDir) {
|
|
|
89
167
|
}
|
|
90
168
|
|
|
91
169
|
const workspaceDir = resolveAgentWorkspaceDirLocal(agentId);
|
|
170
|
+
const workspaceExistedBefore = existsSync(workspaceDir);
|
|
92
171
|
|
|
93
172
|
try {
|
|
94
173
|
const templateMaxMtimeMs = getTemplateMaxMtimeMs(templateDir);
|
|
95
|
-
|
|
96
|
-
const lastSyncedMtimeMs = _agentSeedMtimeCache.get(cacheKey) ?? 0;
|
|
97
|
-
const isFirstSeed = lastSyncedMtimeMs === 0;
|
|
174
|
+
mkdirSync(workspaceDir, { recursive: true });
|
|
98
175
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
176
|
+
const templateFiles = readdirSync(templateDir).filter((file) => BOOTSTRAP_FILENAMES.has(file));
|
|
177
|
+
let state = readTemplateState(workspaceDir);
|
|
178
|
+
const isLegacyWorkspace = !state && workspaceExistedBefore;
|
|
179
|
+
const isFirstSeed = !state && !workspaceExistedBefore;
|
|
102
180
|
|
|
103
|
-
|
|
181
|
+
if (!state) {
|
|
182
|
+
state = {
|
|
183
|
+
version: TEMPLATE_STATE_VERSION,
|
|
184
|
+
seededAt: new Date().toISOString(),
|
|
185
|
+
templateDir,
|
|
186
|
+
seededFiles: isLegacyWorkspace ? collectExistingSeededFiles(workspaceDir, templateFiles) : [],
|
|
187
|
+
templateMtimeMs: templateMaxMtimeMs,
|
|
188
|
+
migratedFromLegacy: isLegacyWorkspace,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
104
191
|
|
|
105
|
-
const
|
|
106
|
-
for (const file of
|
|
107
|
-
if (!
|
|
192
|
+
const bootstrapAllowed = !hasWorkspaceMemoryMarkers(workspaceDir);
|
|
193
|
+
for (const file of templateFiles) {
|
|
194
|
+
if (file === "BOOTSTRAP.md" && !bootstrapAllowed) {
|
|
108
195
|
continue;
|
|
109
196
|
}
|
|
110
197
|
const src = join(templateDir, file);
|
|
111
198
|
const dest = join(workspaceDir, file);
|
|
112
199
|
if (existsSync(dest)) {
|
|
113
200
|
if (!isFirstSeed) {
|
|
114
|
-
|
|
115
|
-
const destMtimeMs = statSync(dest).mtimeMs;
|
|
116
|
-
if (srcMtimeMs <= destMtimeMs) {
|
|
117
|
-
continue;
|
|
118
|
-
}
|
|
201
|
+
continue;
|
|
119
202
|
}
|
|
120
203
|
copyFileSync(src, dest);
|
|
121
204
|
logger.info("WeCom: re-seeded workspace file", { agentId, file, isFirstSeed });
|
|
@@ -123,9 +206,12 @@ export function seedAgentWorkspace(agentId, config, overrideTemplateDir) {
|
|
|
123
206
|
copyFileSync(src, dest);
|
|
124
207
|
logger.info("WeCom: seeded workspace file", { agentId, file });
|
|
125
208
|
}
|
|
209
|
+
state.seededFiles.push(file);
|
|
126
210
|
}
|
|
127
211
|
|
|
128
|
-
|
|
212
|
+
state.templateDir = templateDir;
|
|
213
|
+
state.templateMtimeMs = templateMaxMtimeMs;
|
|
214
|
+
writeTemplateState(workspaceDir, state);
|
|
129
215
|
} catch (err) {
|
|
130
216
|
logger.warn("WeCom: failed to seed agent workspace", {
|
|
131
217
|
agentId,
|