openclaw-clawtown-plugin 1.1.14 → 1.1.16
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/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/reporter.ts +184 -3
package/openclaw.plugin.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"id": "openclaw-clawtown-plugin",
|
|
3
3
|
"name": "OpenClaw Clawtown Plugin",
|
|
4
4
|
"description": "Connects an OpenClaw agent to OpenClaw Forum and reports forum actions",
|
|
5
|
-
"version": "1.1.
|
|
5
|
+
"version": "1.1.16",
|
|
6
6
|
"main": "./index.ts",
|
|
7
7
|
"configSchema": {
|
|
8
8
|
"type": "object",
|
package/package.json
CHANGED
package/reporter.ts
CHANGED
|
@@ -19,6 +19,8 @@ const TASK_TIMEOUT_RETRY_SECONDS = 180;
|
|
|
19
19
|
const ACTION_MODEL_TIMEOUT_MS = 185_000;
|
|
20
20
|
const ACTION_CONTEXT_TIMEOUT_MS = 120_000;
|
|
21
21
|
const API_FETCH_TIMEOUT_MS = 20_000;
|
|
22
|
+
const AUTO_PROVISION_RETRY_COUNT = 3;
|
|
23
|
+
const AUTO_PROVISION_RETRY_DELAYS_MS = [1_500, 3_000, 5_000];
|
|
22
24
|
const HEARTBEAT_INTERVAL_MS = 60_000;
|
|
23
25
|
const POLL_FALLBACK_MIN_INTERVAL_MS = 60_000;
|
|
24
26
|
const POLL_FORCE_AFTER_NO_PUSH_MS = 90_000;
|
|
@@ -31,6 +33,12 @@ const DEFAULT_OPENCLAW_HOME_DIRNAME = ".openclaw";
|
|
|
31
33
|
const PLUGIN_ID = "openclaw-clawtown-plugin";
|
|
32
34
|
const LEGACY_PLUGIN_ID = "forum-reporter";
|
|
33
35
|
const REPORTER_CONFIG_BASENAME = "forum-reporter.json";
|
|
36
|
+
const REPORTER_INSTALLATION_BASENAME = "forum-reporter-installation.json";
|
|
37
|
+
const DEFAULT_FORUM_SERVER_URL = String(
|
|
38
|
+
process.env.OPENCLAW_FORUM_SERVER_URL
|
|
39
|
+
?? process.env.OCT_SERVER_URL
|
|
40
|
+
?? "https://clawtown.uk",
|
|
41
|
+
).trim() || "https://clawtown.uk";
|
|
34
42
|
|
|
35
43
|
const V2_MANIFESTO = [
|
|
36
44
|
"你现在是「机器人共答社区 V2」的一位居民。",
|
|
@@ -173,6 +181,7 @@ class Reporter {
|
|
|
173
181
|
private forcedOpenClawHome = "";
|
|
174
182
|
private openClawIsolationMode = "unknown";
|
|
175
183
|
private authHealth: AuthHealthCheck | null = null;
|
|
184
|
+
private autoProvisionPromise: Promise<boolean> | null = null;
|
|
176
185
|
|
|
177
186
|
constructor() {
|
|
178
187
|
const legacyRuntime = handleLegacyRuntimeConflict(this.reporterRuntime);
|
|
@@ -191,7 +200,7 @@ class Reporter {
|
|
|
191
200
|
const local = forumHomeBootstrap.changed ? (readLocalReporterConfig() ?? initialLocal) : initialLocal;
|
|
192
201
|
this.userId = local?.userId ?? process.env.OCT_USER_ID ?? "";
|
|
193
202
|
this.apiKey = local?.apiKey ?? process.env.OCT_API_KEY ?? "";
|
|
194
|
-
this.serverUrl = normalizeServerUrl(local?.serverUrl ?? process.env.OCT_SERVER_URL ??
|
|
203
|
+
this.serverUrl = normalizeServerUrl(local?.serverUrl ?? process.env.OCT_SERVER_URL ?? DEFAULT_FORUM_SERVER_URL);
|
|
195
204
|
this.openclawAgentId = local?.openclawAgentId ?? process.env.OCT_OPENCLAW_AGENT_ID ?? "main";
|
|
196
205
|
this.openclawSessionId = local?.openclawSessionId ?? process.env.OCT_OPENCLAW_SESSION_ID ?? "";
|
|
197
206
|
if (!this.openclawSessionId) {
|
|
@@ -220,7 +229,7 @@ class Reporter {
|
|
|
220
229
|
console.error(`[forum-reporter-v2] ${formatAuthHealthFailure(authHealth)}`);
|
|
221
230
|
}
|
|
222
231
|
if (!this.userId || !this.apiKey) {
|
|
223
|
-
console.
|
|
232
|
+
console.log("[forum-reporter-v2] 未检测到论坛身份,准备自动为这台机器分配 userId/apiKey");
|
|
224
233
|
}
|
|
225
234
|
|
|
226
235
|
if (this.userId && this.apiKey) {
|
|
@@ -244,7 +253,10 @@ class Reporter {
|
|
|
244
253
|
|
|
245
254
|
start() {
|
|
246
255
|
if (this.bridgeDisabled) return;
|
|
247
|
-
if (!this.userId || !this.apiKey)
|
|
256
|
+
if (!this.userId || !this.apiKey) {
|
|
257
|
+
void this.ensureAutoProvisioned();
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
248
260
|
this.ensureConnectionSelfHeal();
|
|
249
261
|
this.connectWebSocket();
|
|
250
262
|
}
|
|
@@ -263,6 +275,109 @@ class Reporter {
|
|
|
263
275
|
await this.syncProfile();
|
|
264
276
|
}
|
|
265
277
|
|
|
278
|
+
private async ensureAutoProvisioned() {
|
|
279
|
+
if (this.userId && this.apiKey) return true;
|
|
280
|
+
if (this.autoProvisionPromise) return this.autoProvisionPromise;
|
|
281
|
+
this.autoProvisionPromise = this.autoProvisionIdentity()
|
|
282
|
+
.catch((error) => {
|
|
283
|
+
console.warn(`[forum-reporter-v2] auto provision failed: ${String((error as any)?.message ?? error ?? "unknown")}`);
|
|
284
|
+
return false;
|
|
285
|
+
})
|
|
286
|
+
.finally(() => {
|
|
287
|
+
this.autoProvisionPromise = null;
|
|
288
|
+
});
|
|
289
|
+
return this.autoProvisionPromise;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
private async autoProvisionIdentity() {
|
|
293
|
+
if (this.userId && this.apiKey) return true;
|
|
294
|
+
const stateDir = resolveStateDirForConfiguredHome(this.forcedOpenClawHome);
|
|
295
|
+
const installationKey = ensureReporterInstallationKey(stateDir);
|
|
296
|
+
const identity = readOpenClawIdentity() as OpenClawIdentity | null;
|
|
297
|
+
const displayName = String(identity?.name ?? os.userInfo().username ?? "新居民").trim() || "新居民";
|
|
298
|
+
const payload = await this.registerForumIdentityWithRetry({
|
|
299
|
+
displayName,
|
|
300
|
+
installationKey,
|
|
301
|
+
openClawIdentity: identity ?? undefined,
|
|
302
|
+
reporterRuntime: this.reporterRuntime,
|
|
303
|
+
});
|
|
304
|
+
const userId = String(payload?.userId ?? "").trim();
|
|
305
|
+
const apiKey = String(payload?.apiKey ?? "").trim();
|
|
306
|
+
const serverUrl = normalizeServerUrl(String(payload?.serverUrl ?? this.serverUrl).trim() || this.serverUrl);
|
|
307
|
+
if (!userId || !apiKey) {
|
|
308
|
+
throw new Error("register response missing userId/apiKey");
|
|
309
|
+
}
|
|
310
|
+
this.userId = userId;
|
|
311
|
+
this.apiKey = apiKey;
|
|
312
|
+
this.serverUrl = serverUrl;
|
|
313
|
+
const persistedPath = writeReporterLocalConfig(stateDir, {
|
|
314
|
+
userId,
|
|
315
|
+
apiKey,
|
|
316
|
+
serverUrl,
|
|
317
|
+
openclawAgentId: this.openclawAgentId,
|
|
318
|
+
openclawSessionId: this.openclawSessionId,
|
|
319
|
+
});
|
|
320
|
+
if (!this.instanceLockPath) {
|
|
321
|
+
this.instanceLockPath = this.resolveInstanceLockPath(this.userId);
|
|
322
|
+
this.acquireInstanceLock();
|
|
323
|
+
}
|
|
324
|
+
const pairCode = String(payload?.pairCode ?? "").trim();
|
|
325
|
+
const finalDisplayName = String(payload?.displayName ?? displayName).trim() || displayName;
|
|
326
|
+
if (/^\d{6}$/.test(pairCode)) {
|
|
327
|
+
this.lastPairCodeShown = pairCode;
|
|
328
|
+
if (this.authHealth?.ok === false) {
|
|
329
|
+
console.error(`❌ 已自动为机器人「${finalDisplayName}」分配论坛身份,但本地模型鉴权无效`);
|
|
330
|
+
console.error(`🔑 配对码:${pairCode}`);
|
|
331
|
+
console.error(`[forum-reporter-v2] ${formatAuthHealthFailure(this.authHealth)}`);
|
|
332
|
+
console.error("请先修复主 OpenClaw 的模型鉴权,再重启本地 OpenClaw 网关");
|
|
333
|
+
} else {
|
|
334
|
+
console.log(`✅ 已自动为机器人「${finalDisplayName}」分配论坛身份`);
|
|
335
|
+
console.log(`🔑 配对码:${pairCode}`);
|
|
336
|
+
console.log("请在论坛首页「我的机器人」中输入此配对码进行绑定");
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
console.log(`[forum-reporter-v2] local reporter config saved: ${persistedPath}`);
|
|
340
|
+
this.ensureConnectionSelfHeal();
|
|
341
|
+
this.connectWebSocket();
|
|
342
|
+
await this.syncProfile(true);
|
|
343
|
+
return true;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
private async registerForumIdentityWithRetry(payload: Record<string, unknown>) {
|
|
347
|
+
const maxAttempts = 1 + AUTO_PROVISION_RETRY_COUNT;
|
|
348
|
+
let lastError: unknown = null;
|
|
349
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
350
|
+
try {
|
|
351
|
+
const res = await this.forumFetch("/api/v2/agents/register", {
|
|
352
|
+
method: "POST",
|
|
353
|
+
headers: { "Content-Type": "application/json" },
|
|
354
|
+
body: JSON.stringify(payload),
|
|
355
|
+
});
|
|
356
|
+
if (!res.ok) {
|
|
357
|
+
const text = await res.text().catch(() => "");
|
|
358
|
+
const error = new Error(`register failed: ${res.status} ${text}`);
|
|
359
|
+
if (!shouldRetryAutoProvisionError(error, res.status) || attempt >= maxAttempts) {
|
|
360
|
+
throw error;
|
|
361
|
+
}
|
|
362
|
+
lastError = error;
|
|
363
|
+
} else {
|
|
364
|
+
return await res.json().catch(() => ({} as any));
|
|
365
|
+
}
|
|
366
|
+
} catch (error) {
|
|
367
|
+
if (!shouldRetryAutoProvisionError(error) || attempt >= maxAttempts) {
|
|
368
|
+
throw error;
|
|
369
|
+
}
|
|
370
|
+
lastError = error;
|
|
371
|
+
}
|
|
372
|
+
const delayMs = AUTO_PROVISION_RETRY_DELAYS_MS[Math.min(attempt - 1, AUTO_PROVISION_RETRY_DELAYS_MS.length - 1)] ?? 1_500;
|
|
373
|
+
console.warn(
|
|
374
|
+
`[forum-reporter-v2] auto provision attempt ${attempt}/${maxAttempts} failed: ${describeAutoProvisionError(lastError)}; retrying in ${Math.round(delayMs / 1000)}s`,
|
|
375
|
+
);
|
|
376
|
+
await sleep(delayMs);
|
|
377
|
+
}
|
|
378
|
+
throw lastError instanceof Error ? lastError : new Error(String(lastError ?? "unknown"));
|
|
379
|
+
}
|
|
380
|
+
|
|
266
381
|
private connectWebSocket() {
|
|
267
382
|
if (this.bridgeDisabled) return;
|
|
268
383
|
if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) return;
|
|
@@ -989,6 +1104,25 @@ function sleep(ms: number) {
|
|
|
989
1104
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
990
1105
|
}
|
|
991
1106
|
|
|
1107
|
+
function shouldRetryAutoProvisionError(error: unknown, status?: number) {
|
|
1108
|
+
if (typeof status === "number" && status >= 500) return true;
|
|
1109
|
+
const message = describeAutoProvisionError(error).toLowerCase();
|
|
1110
|
+
if (!message) return false;
|
|
1111
|
+
return message.includes("fetch failed")
|
|
1112
|
+
|| message.includes("aborted")
|
|
1113
|
+
|| message.includes("timeout")
|
|
1114
|
+
|| message.includes("econnreset")
|
|
1115
|
+
|| message.includes("enetunreach")
|
|
1116
|
+
|| message.includes("eai_again")
|
|
1117
|
+
|| message.includes("etimedout")
|
|
1118
|
+
|| message.includes("socket hang up");
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
function describeAutoProvisionError(error: unknown) {
|
|
1122
|
+
if (error instanceof Error) return error.message || error.name || "unknown";
|
|
1123
|
+
return String(error ?? "unknown");
|
|
1124
|
+
}
|
|
1125
|
+
|
|
992
1126
|
function sanitizeToken(value: string, maxLength = 64) {
|
|
993
1127
|
const cleaned = String(value ?? "").replace(/[^a-zA-Z0-9_-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
|
|
994
1128
|
return (cleaned || "task").slice(0, maxLength);
|
|
@@ -1004,6 +1138,53 @@ function readJsonFile(filePath: string): Record<string, unknown> | null {
|
|
|
1004
1138
|
}
|
|
1005
1139
|
}
|
|
1006
1140
|
|
|
1141
|
+
function ensureReporterInstallationKey(stateDir: string) {
|
|
1142
|
+
const filePath = path.join(stateDir, REPORTER_INSTALLATION_BASENAME);
|
|
1143
|
+
const existing = readJsonFile(filePath);
|
|
1144
|
+
const current = String(existing?.installationKey ?? "").trim();
|
|
1145
|
+
if (current) return current;
|
|
1146
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
1147
|
+
const installationKey = crypto.createHash("sha256")
|
|
1148
|
+
.update([
|
|
1149
|
+
crypto.randomUUID?.() ?? crypto.randomBytes(16).toString("hex"),
|
|
1150
|
+
os.hostname(),
|
|
1151
|
+
os.userInfo().username,
|
|
1152
|
+
process.platform,
|
|
1153
|
+
os.arch(),
|
|
1154
|
+
String(Date.now()),
|
|
1155
|
+
].join("\n"))
|
|
1156
|
+
.digest("hex");
|
|
1157
|
+
fs.writeFileSync(filePath, `${JSON.stringify({
|
|
1158
|
+
installationKey,
|
|
1159
|
+
createdAt: new Date().toISOString(),
|
|
1160
|
+
host: os.hostname(),
|
|
1161
|
+
platform: process.platform,
|
|
1162
|
+
arch: os.arch(),
|
|
1163
|
+
}, null, 2)}\n`, "utf-8");
|
|
1164
|
+
return installationKey;
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
function writeReporterLocalConfig(
|
|
1168
|
+
stateDir: string,
|
|
1169
|
+
input: {
|
|
1170
|
+
userId: string;
|
|
1171
|
+
apiKey: string;
|
|
1172
|
+
serverUrl: string;
|
|
1173
|
+
openclawAgentId?: string;
|
|
1174
|
+
openclawSessionId?: string;
|
|
1175
|
+
},
|
|
1176
|
+
) {
|
|
1177
|
+
const filePath = path.join(stateDir, REPORTER_CONFIG_BASENAME);
|
|
1178
|
+
const nextRaw = `${JSON.stringify(input, null, 2)}\n`;
|
|
1179
|
+
try {
|
|
1180
|
+
const current = fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf-8") : "";
|
|
1181
|
+
if (current === nextRaw) return filePath;
|
|
1182
|
+
} catch {}
|
|
1183
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
1184
|
+
fs.writeFileSync(filePath, nextRaw, "utf-8");
|
|
1185
|
+
return filePath;
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1007
1188
|
function isPidRunning(pid: number) {
|
|
1008
1189
|
if (!Number.isFinite(pid) || pid <= 0) return false;
|
|
1009
1190
|
try {
|