openclaw-clawtown-plugin 1.1.12 → 1.1.14

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.12",
5
+ "version": "1.1.14",
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.12",
3
+ "version": "1.1.14",
4
4
  "description": "Forum reporter plugin for OpenClaw Forum (Clawtown)",
5
5
  "license": "MIT",
6
6
  "main": "index.ts",
package/reporter.ts CHANGED
@@ -124,6 +124,20 @@ interface ReporterRuntimeInfo {
124
124
  pluginHash: string;
125
125
  syncedAt: number;
126
126
  pluginDir: string;
127
+ openClawHome?: string | null;
128
+ isolationMode?: string;
129
+ isolationActive?: boolean;
130
+ }
131
+
132
+ interface AuthHealthCheck {
133
+ ok: boolean;
134
+ storePath: string;
135
+ modelsPath: string;
136
+ requiredProviders: string[];
137
+ availableProviders: string[];
138
+ invalidProfileIds: string[];
139
+ missingProviders: string[];
140
+ reason: string;
127
141
  }
128
142
 
129
143
  class Reporter {
@@ -158,8 +172,17 @@ class Reporter {
158
172
  private reporterRuntime: ReporterRuntimeInfo = readReporterRuntimeInfo();
159
173
  private forcedOpenClawHome = "";
160
174
  private openClawIsolationMode = "unknown";
175
+ private authHealth: AuthHealthCheck | null = null;
161
176
 
162
177
  constructor() {
178
+ const legacyRuntime = handleLegacyRuntimeConflict(this.reporterRuntime);
179
+ if (legacyRuntime.summary) {
180
+ console.warn(`[forum-reporter-v2] ${legacyRuntime.summary}`);
181
+ }
182
+ if (legacyRuntime.disableCurrentRuntime) {
183
+ this.bridgeDisabled = true;
184
+ return;
185
+ }
163
186
  const initialLocal = readLocalReporterConfig();
164
187
  const forumHomeBootstrap = ensureForumIsolatedHome(this.reporterRuntime, initialLocal);
165
188
  if (forumHomeBootstrap.summary) {
@@ -191,6 +214,11 @@ class Reporter {
191
214
  isolationMode: this.openClawIsolationMode,
192
215
  isolationActive: Boolean(this.forcedOpenClawHome),
193
216
  };
217
+ const authHealth = inspectAuthHealthForStateDir(resolveStateDirForConfiguredHome(this.forcedOpenClawHome));
218
+ this.authHealth = authHealth;
219
+ if (!authHealth.ok) {
220
+ console.error(`[forum-reporter-v2] ${formatAuthHealthFailure(authHealth)}`);
221
+ }
194
222
  if (!this.userId || !this.apiKey) {
195
223
  console.warn("[forum-reporter-v2] 未配置 userId/apiKey,插件已禁用");
196
224
  }
@@ -667,9 +695,17 @@ class Reporter {
667
695
  const pairCode = String(payload?.pairCode ?? "").trim();
668
696
  if (/^\d{6}$/.test(pairCode) && pairCode !== this.lastPairCodeShown) {
669
697
  this.lastPairCodeShown = pairCode;
670
- console.log(`✅ 你的机器人「${payload?.displayName || this.userId}」已接入共答社区 V2`);
671
- console.log(`🔑 配对码:${pairCode}`);
672
- console.log("请在论坛首页「我的机器人」中输入此配对码进行绑定");
698
+ const displayName = String(payload?.displayName ?? "").trim() || this.userId;
699
+ if (this.authHealth?.ok === false) {
700
+ console.error(`❌ 机器人「${displayName}」已连上共答社区 V2,但本地模型鉴权无效,任务暂时不会执行`);
701
+ console.error(`🔑 配对码:${pairCode}`);
702
+ console.error(`[forum-reporter-v2] ${formatAuthHealthFailure(this.authHealth)}`);
703
+ console.error("请先修复主 OpenClaw 的模型鉴权,再重启本地 OpenClaw 网关");
704
+ } else {
705
+ console.log(`✅ 你的机器人「${displayName}」已接入共答社区 V2`);
706
+ console.log(`🔑 配对码:${pairCode}`);
707
+ console.log("请在论坛首页「我的机器人」中输入此配对码进行绑定");
708
+ }
673
709
  }
674
710
  } catch (error) {
675
711
  console.warn("[forum-reporter-v2] profile sync failed", error);
@@ -1575,6 +1611,86 @@ function looksLikeOpenClawHome(homePath: string) {
1575
1611
  }
1576
1612
  }
1577
1613
 
1614
+ function handleLegacyRuntimeConflict(runtimeInfo: ReporterRuntimeInfo) {
1615
+ const runtimeDir = path.resolve(String(runtimeInfo?.pluginDir ?? "").trim() || ".");
1616
+ if (path.basename(runtimeDir) !== LEGACY_PLUGIN_ID) {
1617
+ return { disableCurrentRuntime: false, summary: "" };
1618
+ }
1619
+ const canonicalCandidates = [
1620
+ path.join(path.dirname(runtimeDir), PLUGIN_ID),
1621
+ path.join(os.homedir(), DEFAULT_OPENCLAW_HOME_DIRNAME, "extensions", PLUGIN_ID),
1622
+ path.join(os.homedir(), FORUM_ISOLATED_HOME_DIRNAME, DEFAULT_OPENCLAW_HOME_DIRNAME, "extensions", PLUGIN_ID),
1623
+ ];
1624
+ const hasCanonical = canonicalCandidates.some((candidate) => {
1625
+ const resolved = path.resolve(candidate);
1626
+ return resolved !== runtimeDir && hasUsablePluginDir(resolved);
1627
+ });
1628
+ if (!hasCanonical) {
1629
+ return { disableCurrentRuntime: false, summary: "" };
1630
+ }
1631
+
1632
+ const changedParts = new Set<string>();
1633
+ const defaultStateDir = path.join(os.homedir(), DEFAULT_OPENCLAW_HOME_DIRNAME);
1634
+ const forumStateDir = path.join(os.homedir(), FORUM_ISOLATED_HOME_DIRNAME, DEFAULT_OPENCLAW_HOME_DIRNAME);
1635
+ if (stripLegacyPluginState(defaultStateDir, runtimeDir)) changedParts.add("default legacy state cleaned");
1636
+ if (stripLegacyPluginState(forumStateDir, runtimeDir)) changedParts.add("forum legacy state cleaned");
1637
+ if (quarantineLegacyRuntimeDir(runtimeDir)) changedParts.add("legacy dir quarantined");
1638
+
1639
+ const detail = changedParts.size ? ` (${Array.from(changedParts).join(", ")})` : "";
1640
+ return {
1641
+ disableCurrentRuntime: true,
1642
+ summary: `legacy forum-reporter runtime detected at ${runtimeDir}; disabling this instance to avoid mixed execution${detail}`,
1643
+ };
1644
+ }
1645
+
1646
+ function stripLegacyPluginState(stateDir: string, activeLegacyDir = "") {
1647
+ if (!stateDir || !fs.existsSync(stateDir)) return false;
1648
+ let changed = false;
1649
+ const configInfo = readOpenClawConfigInfo(stateDir);
1650
+ if (configInfo.path && configInfo.config) {
1651
+ const nextConfig = cloneJson(configInfo.config);
1652
+ const plugins = (nextConfig.plugins ??= {});
1653
+ const allow = Array.isArray(plugins.allow) ? plugins.allow : [];
1654
+ const nextAllow = allow.filter((item: any) => String(item) !== LEGACY_PLUGIN_ID);
1655
+ if (nextAllow.length !== allow.length) {
1656
+ plugins.allow = nextAllow;
1657
+ changed = true;
1658
+ }
1659
+ if (plugins.entries && Object.prototype.hasOwnProperty.call(plugins.entries, LEGACY_PLUGIN_ID)) {
1660
+ delete plugins.entries[LEGACY_PLUGIN_ID];
1661
+ changed = true;
1662
+ }
1663
+ if (plugins.installs && Object.prototype.hasOwnProperty.call(plugins.installs, LEGACY_PLUGIN_ID)) {
1664
+ delete plugins.installs[LEGACY_PLUGIN_ID];
1665
+ changed = true;
1666
+ }
1667
+ if (changed) {
1668
+ writeOpenClawConfig(configInfo.path, nextConfig);
1669
+ }
1670
+ }
1671
+ const legacyDir = path.join(stateDir, "extensions", LEGACY_PLUGIN_ID);
1672
+ if (path.resolve(legacyDir) !== path.resolve(activeLegacyDir || "")) {
1673
+ changed = removeDirectoryIfExists(legacyDir) || changed;
1674
+ }
1675
+ return changed;
1676
+ }
1677
+
1678
+ function quarantineLegacyRuntimeDir(runtimeDir: string) {
1679
+ if (!runtimeDir || !fs.existsSync(runtimeDir)) return false;
1680
+ const parent = path.dirname(runtimeDir);
1681
+ const targetDir = path.join(parent, `${LEGACY_PLUGIN_ID}-disabled`);
1682
+ if (path.resolve(targetDir) === path.resolve(runtimeDir)) return false;
1683
+ try {
1684
+ if (fs.existsSync(targetDir)) {
1685
+ fs.rmSync(targetDir, { recursive: true, force: true });
1686
+ }
1687
+ fs.renameSync(runtimeDir, targetDir);
1688
+ return true;
1689
+ } catch {
1690
+ return false;
1691
+ }
1692
+ }
1693
+
1578
1694
  function ensureForumIsolatedHome(
1579
1695
  runtimeInfo: ReporterRuntimeInfo,
1580
1696
  sourceLocalConfig: ReturnType<typeof readLocalReporterConfig>,
@@ -1664,6 +1780,237 @@ function hashDirectory(dirPath: string) {
1664
1780
  }
1665
1781
  }
1666
1782
 
1783
+ function resolveStateDirForConfiguredHome(openclawHome: string) {
1784
+ if (openclawHome) {
1785
+ const resolved = path.resolve(openclawHome);
1786
+ if (path.basename(resolved) === DEFAULT_OPENCLAW_HOME_DIRNAME) return resolved;
1787
+ return path.join(resolved, DEFAULT_OPENCLAW_HOME_DIRNAME);
1788
+ }
1789
+ return path.join(os.homedir(), DEFAULT_OPENCLAW_HOME_DIRNAME);
1790
+ }
1791
+
1792
+ function inspectAuthHealthForStateDir(stateDir: string): AuthHealthCheck {
1793
+ const configInfo = readOpenClawConfigInfo(stateDir);
1794
+ const requiredProviders = resolveConfiguredProviders(configInfo.config);
1795
+ const storePath = path.join(stateDir, "agents", "main", "agent", "auth-profiles.json");
1796
+ const modelsPath = path.join(stateDir, "agents", "main", "agent", "models.json");
1797
+ const availableProviders = new Set<string>(readInlineCredentialProviders(stateDir, configInfo.config));
1798
+ const invalidProfileIds: string[] = [];
1799
+ if (!fs.existsSync(storePath)) {
1800
+ const missingProviders = requiredProviders.filter((provider) => !availableProviders.has(provider));
1801
+ return {
1802
+ ok: missingProviders.length === 0,
1803
+ storePath,
1804
+ modelsPath,
1805
+ requiredProviders,
1806
+ availableProviders: Array.from(availableProviders),
1807
+ invalidProfileIds,
1808
+ missingProviders,
1809
+ reason: missingProviders.length ? "missing_store" : "ok",
1810
+ };
1811
+ }
1812
+ let parsed: Record<string, any> | null = null;
1813
+ try {
1814
+ parsed = JSON.parse(fs.readFileSync(storePath, "utf-8"));
1815
+ } catch {
1816
+ return {
1817
+ ok: false,
1818
+ storePath,
1819
+ modelsPath,
1820
+ requiredProviders,
1821
+ availableProviders: Array.from(availableProviders),
1822
+ invalidProfileIds,
1823
+ missingProviders: requiredProviders.filter((provider) => !availableProviders.has(provider)),
1824
+ reason: "invalid_json",
1825
+ };
1826
+ }
1827
+ const profiles = parsed?.profiles && typeof parsed.profiles === "object"
1828
+ ? parsed.profiles as Record<string, any>
1829
+ : null;
1830
+ if (!profiles) {
1831
+ return {
1832
+ ok: false,
1833
+ storePath,
1834
+ modelsPath,
1835
+ requiredProviders,
1836
+ availableProviders: Array.from(availableProviders),
1837
+ invalidProfileIds,
1838
+ missingProviders: requiredProviders.filter((provider) => !availableProviders.has(provider)),
1839
+ reason: "invalid_store",
1840
+ };
1841
+ }
1842
+ for (const [profileId, profile] of Object.entries(profiles)) {
1843
+ const normalized = normalizeAuthProfile(profile);
1844
+ if (!normalized.valid || !normalized.provider) {
1845
+ invalidProfileIds.push(profileId);
1846
+ continue;
1847
+ }
1848
+ availableProviders.add(normalized.provider);
1849
+ }
1850
+ const missingProviders = requiredProviders.filter((provider) => !availableProviders.has(provider));
1851
+ return {
1852
+ ok: missingProviders.length === 0,
1853
+ storePath,
1854
+ modelsPath,
1855
+ requiredProviders,
1856
+ availableProviders: Array.from(availableProviders),
1857
+ invalidProfileIds,
1858
+ missingProviders,
1859
+ reason: missingProviders.length ? "missing_provider" : "ok",
1860
+ };
1861
+ }
1862
+
1863
+ function normalizeAuthProfile(profile: any) {
1864
+ if (!profile || typeof profile !== "object") {
1865
+ return { valid: false, provider: "" };
1866
+ }
1867
+ const type = String(profile.type ?? "").trim().toLowerCase();
1868
+ const provider = String(profile.provider ?? "").trim().toLowerCase();
1869
+ if (!type || !provider) {
1870
+ return { valid: false, provider };
1871
+ }
1872
+ if (type === "api_key") {
1873
+ return {
1874
+ valid: hasNonEmptyString(profile.key) || hasNonEmptyString(profile.keyRef),
1875
+ provider,
1876
+ };
1877
+ }
1878
+ if (type === "token") {
1879
+ return {
1880
+ valid: hasNonEmptyString(profile.token) || hasNonEmptyString(profile.tokenRef),
1881
+ provider,
1882
+ };
1883
+ }
1884
+ if (type === "oauth") {
1885
+ return {
1886
+ valid: hasNonEmptyString(profile.access) || hasNonEmptyString(profile.refresh),
1887
+ provider,
1888
+ };
1889
+ }
1890
+ return { valid: false, provider };
1891
+ }
1892
+
1893
+ function hasNonEmptyString(value: unknown) {
1894
+ return typeof value === "string" && value.trim().length > 0;
1895
+ }
1896
+
1897
+ function resolveConfiguredProviders(config: Record<string, any> | null) {
1898
+ const out = new Set<string>();
1899
+ const pushModelRef = (value: unknown) => {
1900
+ const raw = String(value ?? "").trim();
1901
+ if (!raw) return;
1902
+ const provider = raw.includes("/") ? raw.slice(0, raw.indexOf("/")).trim().toLowerCase() : raw.toLowerCase();
1903
+ if (provider) out.add(provider);
1904
+ };
1905
+ const visitModelNode = (value: unknown) => {
1906
+ if (typeof value === "string") {
1907
+ if (value.includes("/")) pushModelRef(value);
1908
+ return;
1909
+ }
1910
+ if (Array.isArray(value)) {
1911
+ for (const item of value) visitModelNode(item);
1912
+ return;
1913
+ }
1914
+ if (!value || typeof value !== "object") return;
1915
+ for (const [key, nested] of Object.entries(value)) {
1916
+ if (key === "providers" || key === "mode") continue;
1917
+ if (typeof nested === "string") {
1918
+ if (key === "primary" || key === "fallback" || key === "model" || nested.includes("/")) {
1919
+ pushModelRef(nested);
1920
+ }
1921
+ continue;
1922
+ }
1923
+ visitModelNode(nested);
1924
+ }
1925
+ };
1926
+ pushModelRef(config?.agent?.model?.primary);
1927
+ pushModelRef(config?.agents?.defaults?.model?.primary);
1928
+ pushModelRef(config?.model?.primary);
1929
+ const modelMaps = [
1930
+ config?.agent?.models,
1931
+ config?.agents?.defaults?.models,
1932
+ config?.models,
1933
+ ];
1934
+ for (const map of modelMaps) {
1935
+ visitModelNode(map);
1936
+ }
1937
+ const providerMaps = [
1938
+ config?.agent?.models?.providers,
1939
+ config?.agents?.defaults?.models?.providers,
1940
+ config?.models?.providers,
1941
+ ];
1942
+ for (const map of providerMaps) {
1943
+ if (!map || typeof map !== "object") continue;
1944
+ for (const key of Object.keys(map)) pushModelRef(key);
1945
+ }
1946
+ return Array.from(out);
1947
+ }
1948
+
1949
+ function readInlineCredentialProviders(stateDir: string, config: Record<string, any> | null) {
1950
+ const providers = new Set<string>();
1951
+ const mergeProviderMap = (map: Record<string, any> | null | undefined) => {
1952
+ if (!map || typeof map !== "object") return;
1953
+ for (const [providerName, value] of Object.entries(map)) {
1954
+ if (!hasInlineProviderCredential(value)) continue;
1955
+ const normalized = String(providerName ?? "").trim().toLowerCase();
1956
+ if (normalized) providers.add(normalized);
1957
+ }
1958
+ };
1959
+
1960
+ mergeProviderMap(config?.agent?.models?.providers);
1961
+ mergeProviderMap(config?.agents?.defaults?.models?.providers);
1962
+ mergeProviderMap(config?.models?.providers);
1963
+
1964
+ try {
1965
+ const modelsPath = path.join(stateDir, "agents", "main", "agent", "models.json");
1966
+ if (fs.existsSync(modelsPath)) {
1967
+ const parsed = JSON.parse(fs.readFileSync(modelsPath, "utf-8"));
1968
+ mergeProviderMap(parsed?.providers);
1969
+ }
1970
+ } catch {}
1971
+
1972
+ return Array.from(providers);
1973
+ }
1974
+
1975
+ function hasInlineProviderCredential(value: any) {
1976
+ if (!value || typeof value !== "object") return false;
1977
+ const headers = value.headers && typeof value.headers === "object" ? value.headers : {};
1978
+ return hasNonEmptyString(value.apiKey)
1979
+ || hasNonEmptyString(value.api_key)
1980
+ || hasNonEmptyString(value.key)
1981
+ || hasNonEmptyString(value.token)
1982
+ || hasNonEmptyString(value.accessToken)
1983
+ || hasNonEmptyString(value.access_token)
1984
+ || hasNonEmptyString(value.authToken)
1985
+ || hasNonEmptyString(value.auth_token)
1986
+ || hasNonEmptyString(headers.Authorization)
1987
+ || hasNonEmptyString(headers.authorization)
1988
+ || hasNonEmptyString(headers["x-api-key"])
1989
+ || hasNonEmptyString(headers["X-API-Key"]);
1990
+ }
1991
+
1992
+ function formatAuthHealthFailure(health: AuthHealthCheck) {
1993
+ const parts: string[] = [`forum auth check failed: ${health.storePath}`];
1994
+ if (health.reason === "missing_store") {
1995
+ parts.push("auth-profiles.json not found");
1996
+ } else if (health.reason === "invalid_json") {
1997
+ parts.push("auth-profiles.json is not valid JSON");
1998
+ } else if (health.reason === "invalid_store") {
1999
+ parts.push("auth-profiles.json missing required profiles object");
2000
+ }
2001
+ if (health.invalidProfileIds.length) {
2002
+ parts.push(`invalid profiles: ${health.invalidProfileIds.slice(0, 5).join(", ")}`);
2003
+ }
2004
+ if (health.modelsPath) {
2005
+ parts.push(`models: ${health.modelsPath}`);
2006
+ }
2007
+ if (health.requiredProviders.length) {
2008
+ const available = health.availableProviders.length ? health.availableProviders.join(", ") : "none";
2009
+ parts.push(`required providers: ${health.requiredProviders.join(", ")}; available: ${available}`);
2010
+ }
2011
+ return parts.join(" | ");
2012
+ }
2013
+
1667
2014
  function syncReporterConfigToForum(
1668
2015
  sourceLocalConfig: ReturnType<typeof readLocalReporterConfig>,
1669
2016
  forumStateDir: string,
@@ -1685,7 +2032,10 @@ function syncAgentAuthProfiles(sourceStateDir: string, targetStateDir: string) {
1685
2032
  if (!fs.existsSync(sourceAgentsDir)) return false;
1686
2033
  let changed = false;
1687
2034
  const relFiles = listFilesRecursive(sourceAgentsDir)
1688
- .filter((rel) => path.basename(rel) === "auth-profiles.json");
2035
+ .filter((rel) => {
2036
+ const base = path.basename(rel);
2037
+ return base === "auth-profiles.json" || base === "models.json" || base === "auth.json";
2038
+ });
1689
2039
  for (const rel of relFiles) {
1690
2040
  const sourcePath = path.join(sourceAgentsDir, rel);
1691
2041
  const targetPath = path.join(targetStateDir, "agents", rel);
@@ -1759,6 +2109,7 @@ function normalizeForumHomeConfig(
1759
2109
  forumConfig: Record<string, any> | null,
1760
2110
  ) {
1761
2111
  const config = cloneJson(inputConfig);
2112
+ delete config.channels;
1762
2113
  const gateway = (config.gateway ??= {});
1763
2114
  const auth = (gateway.auth ??= {});
1764
2115
  const authToken = typeof auth.token === "string" ? auth.token.trim() : "";