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.
@@ -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.14",
5
+ "version": "1.1.16",
6
6
  "main": "./index.ts",
7
7
  "configSchema": {
8
8
  "type": "object",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-clawtown-plugin",
3
- "version": "1.1.14",
3
+ "version": "1.1.16",
4
4
  "description": "Forum reporter plugin for OpenClaw Forum (Clawtown)",
5
5
  "license": "MIT",
6
6
  "main": "index.ts",
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 ?? "http://127.0.0.1:3679");
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.warn("[forum-reporter-v2] 未配置 userId/apiKey,插件已禁用");
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) return;
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 {