@zbruceli/openclaw-dchat 0.2.0 → 0.4.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.
@@ -4,7 +4,9 @@
4
4
  "WebFetch(domain:docs.openclaw.ai)",
5
5
  "Bash(node:*)",
6
6
  "Bash(ls:*)",
7
- "Bash(npm test:*)"
7
+ "Bash(npm test:*)",
8
+ "Bash(npm run:*)",
9
+ "Bash(openclaw status:*)"
8
10
  ]
9
11
  }
10
12
  }
package/index.ts CHANGED
@@ -1,5 +1,5 @@
1
- import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
2
- import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
1
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
2
+ import { emptyPluginConfigSchema } from "openclaw/plugin-sdk/core";
3
3
  import { dchatPlugin } from "./src/channel.js";
4
4
  import { setDchatRuntime } from "./src/runtime.js";
5
5
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zbruceli/openclaw-dchat",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "OpenClaw D-Chat/nMobile channel plugin — decentralized E2E encrypted messaging over the NKN relay network",
5
5
  "type": "module",
6
6
  "main": "index.ts",
package/src/channel.ts CHANGED
@@ -1,19 +1,21 @@
1
1
  import {
2
2
  applyAccountNameToChannelSection,
3
- buildBaseAccountStatusSnapshot,
4
- buildBaseChannelStatusSummary,
5
3
  buildChannelConfigSchema,
6
- collectStatusIssuesFromLastError,
7
- createDefaultChannelRuntimeState,
8
4
  DEFAULT_ACCOUNT_ID,
9
5
  deleteAccountFromConfigSection,
10
6
  normalizeAccountId,
11
- resolveOutboundMediaUrls,
12
- resolveSenderCommandAuthorization,
13
7
  setAccountEnabledInConfigSection,
14
8
  type ChannelPlugin,
15
- } from "openclaw/plugin-sdk";
16
- import type { PluginRuntime } from "openclaw/plugin-sdk";
9
+ type PluginRuntime,
10
+ } from "openclaw/plugin-sdk/core";
11
+ import {
12
+ buildBaseAccountStatusSnapshot,
13
+ buildBaseChannelStatusSummary,
14
+ collectStatusIssuesFromLastError,
15
+ createDefaultChannelRuntimeState,
16
+ } from "openclaw/plugin-sdk/status-helpers";
17
+ import { resolveOutboundMediaUrls } from "openclaw/plugin-sdk/reply-payload";
18
+ import { resolveSenderCommandAuthorization } from "openclaw/plugin-sdk/command-auth";
17
19
 
18
20
  /* ── Inline helpers that may not exist in older OpenClaw versions ── */
19
21
 
@@ -30,7 +32,7 @@ function createScopedPairingAccess(params: {
30
32
  channel: params.channel,
31
33
  accountId: resolvedAccountId,
32
34
  }),
33
- upsertPairingRequest: (input: { id: string; meta?: Record<string, unknown> }) =>
35
+ upsertPairingRequest: (input: { id: string; meta?: Record<string, string | null | undefined> }) =>
34
36
  params.core.channel.pairing.upsertPairingRequest({
35
37
  channel: params.channel,
36
38
  accountId: resolvedAccountId,
@@ -51,7 +53,7 @@ import {
51
53
  resolveDefaultDchatAccountId,
52
54
  } from "./config-schema.js";
53
55
  import { NknBus } from "./nkn-bus.js";
54
- import { dchatOnboardingAdapter } from "./onboarding.js";
56
+ import { dchatSetupWizard } from "./onboarding.js";
55
57
  import { getDchatRuntime } from "./runtime.js";
56
58
  import { SeenTracker } from "./seen-tracker.js";
57
59
  import type { ResolvedDchatAccount } from "./types.js";
@@ -92,7 +94,7 @@ function getBusForAccount(accountId: string): NknBus | undefined {
92
94
  export const dchatPlugin: ChannelPlugin<ResolvedDchatAccount> = {
93
95
  id: "dchat",
94
96
  meta,
95
- onboarding: dchatOnboardingAdapter,
97
+ setupWizard: dchatSetupWizard,
96
98
  capabilities: {
97
99
  chatTypes: ["direct", "group"],
98
100
  media: true,
@@ -138,7 +140,7 @@ export const dchatPlugin: ChannelPlugin<ResolvedDchatAccount> = {
138
140
  name: account.name,
139
141
  enabled: account.enabled,
140
142
  configured: account.configured,
141
- nknAddress: account.nknAddress ?? null,
143
+ publicKey: account.nknAddress ?? null,
142
144
  }),
143
145
  resolveAllowFrom: ({ cfg, accountId }) => {
144
146
  const dchatConfig = resolveDchatAccountConfig({ cfg: cfg as CoreConfig, accountId });
@@ -407,15 +409,15 @@ export const dchatPlugin: ChannelPlugin<ResolvedDchatAccount> = {
407
409
  },
408
410
  buildChannelSummary: ({ snapshot }) => ({
409
411
  ...buildBaseChannelStatusSummary(snapshot),
410
- nknAddress: snapshot.nknAddress ?? null,
412
+ publicKey: snapshot.publicKey ?? null,
411
413
  connected: snapshot.connected ?? false,
412
414
  lastConnectedAt: snapshot.lastConnectedAt ?? null,
413
415
  }),
414
416
  buildAccountSnapshot: ({ account, runtime }) => ({
415
417
  ...buildBaseAccountStatusSnapshot({ account, runtime }),
416
- nknAddress: account.nknAddress ?? null,
417
- connected: (runtime as Record<string, unknown>)?.connected ?? false,
418
- lastConnectedAt: (runtime as Record<string, unknown>)?.lastConnectedAt ?? null,
418
+ publicKey: account.nknAddress ?? null,
419
+ connected: Boolean(runtime?.connected),
420
+ lastConnectedAt: runtime?.lastConnectedAt ?? null,
419
421
  }),
420
422
  collectStatusIssues: (accounts) => collectStatusIssuesFromLastError("dchat", accounts),
421
423
  },
@@ -438,6 +440,39 @@ export const dchatPlugin: ChannelPlugin<ResolvedDchatAccount> = {
438
440
  const seenTracker = new SeenTracker();
439
441
  seenMap.set(account.accountId, seenTracker);
440
442
 
443
+ // Listen for heartbeat events
444
+ bus.on("heartbeat", ({ success, failures }: { success: boolean; failures: number }) => {
445
+ if (!success) {
446
+ logger.warn(`[${account.accountId}] heartbeat echo failed (${failures} consecutive)`);
447
+ }
448
+ });
449
+ bus.on("heartbeatReconnect", ({ failures }: { failures: number }) => {
450
+ logger.warn(
451
+ `[${account.accountId}] heartbeat failed ${failures} times, reconnecting...`,
452
+ );
453
+ ctx.setStatus({ accountId: account.accountId, connected: false });
454
+ });
455
+ let initialConnectDone = false;
456
+ bus.on("stateChange", (state: string) => {
457
+ if (state === "connected" && initialConnectDone) {
458
+ ctx.setStatus({
459
+ accountId: account.accountId,
460
+ connected: true,
461
+ lastConnectedAt: Date.now(),
462
+ });
463
+ logger.info(`[${account.accountId}] reconnected as ${bus.getAddress()}`);
464
+ }
465
+ });
466
+ bus.on("reconnectFailed", (err: unknown) => {
467
+ const msg = err instanceof Error ? err.message : String(err);
468
+ logger.error(`[${account.accountId}] reconnect failed: ${msg}`);
469
+ ctx.setStatus({
470
+ accountId: account.accountId,
471
+ connected: false,
472
+ lastError: `reconnect failed: ${msg}`,
473
+ });
474
+ });
475
+
441
476
  try {
442
477
  const address = await bus.connect(
443
478
  { seed: account.seed, numSubClients: account.numSubClients },
@@ -453,6 +488,7 @@ export const dchatPlugin: ChannelPlugin<ResolvedDchatAccount> = {
453
488
  lastConnectedAt: Date.now(),
454
489
  });
455
490
  logger.info(`[${account.accountId}] connected as ${address}`);
491
+ initialConnectDone = true;
456
492
 
457
493
  // Register inbound message handler
458
494
  bus.onMessage((rawSrc, rawPayload) => {
@@ -1,5 +1,5 @@
1
1
  import nkn from "nkn-sdk";
2
- import type { OpenClawConfig } from "openclaw/plugin-sdk";
2
+ import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
3
3
  import { z } from "zod";
4
4
  import type { DchatAccountConfig, ResolvedDchatAccount } from "./types.js";
5
5
 
package/src/nkn-bus.ts CHANGED
@@ -5,12 +5,20 @@ import { NKN_SEED_RPC_SERVERS, type NknConnectionState } from "./types.js";
5
5
  export interface NknBusOptions {
6
6
  seed: string;
7
7
  numSubClients?: number;
8
+ /** Heartbeat echo-test interval in ms (default 60 000). Set 0 to disable. */
9
+ heartbeatIntervalMs?: number;
10
+ /** Consecutive heartbeat failures before reconnecting (default 3). */
11
+ heartbeatMaxFailures?: number;
8
12
  }
9
13
 
14
+ /** Payload used for heartbeat echo-test messages (self → self). */
15
+ const HEARTBEAT_ECHO_PREFIX = "__nkn_heartbeat_echo__:";
16
+
10
17
  /**
11
18
  * NKN MultiClient wrapper for D-Chat wire-format messaging.
12
- * Handles connect, send, receive, subscribe, and reconnection.
19
+ * Handles connect, send, receive, subscribe, heartbeat, and reconnection.
13
20
  */
21
+
14
22
  export class NknBus extends EventEmitter {
15
23
  private client: nkn.MultiClient | null = null;
16
24
  private state: NknConnectionState = "disconnected";
@@ -20,6 +28,14 @@ export class NknBus extends EventEmitter {
20
28
  private numSubClients: number;
21
29
  private abortSignal: AbortSignal | undefined;
22
30
 
31
+ private heartbeatIntervalMs: number = 60_000;
32
+ private heartbeatMaxFailures: number = 3;
33
+ private heartbeatTimer: ReturnType<typeof setTimeout> | null = null;
34
+ private heartbeatFailures: number = 0;
35
+ private pendingEchoId: string | null = null;
36
+ private pendingEchoResolve: (() => void) | null = null;
37
+ private isReconnecting: boolean = false;
38
+
23
39
  constructor() {
24
40
  super();
25
41
  this.numSubClients = 4;
@@ -40,6 +56,8 @@ export class NknBus extends EventEmitter {
40
56
 
41
57
  this.seed = opts.seed;
42
58
  this.numSubClients = opts.numSubClients ?? 4;
59
+ this.heartbeatIntervalMs = opts.heartbeatIntervalMs ?? 60_000;
60
+ this.heartbeatMaxFailures = opts.heartbeatMaxFailures ?? 3;
43
61
  this.abortSignal = abortSignal;
44
62
  this.setState("connecting");
45
63
 
@@ -86,9 +104,23 @@ export class NknBus extends EventEmitter {
86
104
  } else {
87
105
  data = payload;
88
106
  }
107
+
108
+ // Intercept heartbeat echo responses — don't emit as regular messages
109
+ if (data.startsWith(HEARTBEAT_ECHO_PREFIX)) {
110
+ const echoId = data.slice(HEARTBEAT_ECHO_PREFIX.length);
111
+ if (echoId === this.pendingEchoId && this.pendingEchoResolve) {
112
+ this.pendingEchoResolve();
113
+ this.pendingEchoResolve = null;
114
+ this.pendingEchoId = null;
115
+ }
116
+ return;
117
+ }
118
+
89
119
  this.emit("message", src, data);
90
120
  });
91
121
 
122
+ this.startHeartbeat();
123
+
92
124
  return this.address;
93
125
  } catch (err) {
94
126
  this.setState("disconnected");
@@ -105,6 +137,7 @@ export class NknBus extends EventEmitter {
105
137
  }
106
138
 
107
139
  async disconnect(): Promise<void> {
140
+ this.stopHeartbeat();
108
141
  if (this.reconnectTimer) {
109
142
  clearTimeout(this.reconnectTimer);
110
143
  this.reconnectTimer = null;
@@ -200,6 +233,185 @@ export class NknBus extends EventEmitter {
200
233
  this.on("message", handler);
201
234
  }
202
235
 
236
+ /** Start periodic heartbeat echo test (self → self). */
237
+ private startHeartbeat(): void {
238
+ if (this.heartbeatIntervalMs <= 0) return;
239
+ this.heartbeatFailures = 0;
240
+ this.scheduleNextHeartbeat();
241
+ }
242
+
243
+ private stopHeartbeat(): void {
244
+ if (this.heartbeatTimer) {
245
+ clearTimeout(this.heartbeatTimer);
246
+ this.heartbeatTimer = null;
247
+ }
248
+ // Reject any pending echo wait
249
+ this.pendingEchoResolve = null;
250
+ this.pendingEchoId = null;
251
+ }
252
+
253
+ private scheduleNextHeartbeat(): void {
254
+ if (this.heartbeatTimer) {
255
+ clearTimeout(this.heartbeatTimer);
256
+ }
257
+ this.heartbeatTimer = setTimeout(() => {
258
+ void this.runHeartbeat();
259
+ }, this.heartbeatIntervalMs);
260
+ }
261
+
262
+ private async runHeartbeat(): Promise<void> {
263
+ if (!this.client || this.state !== "connected" || !this.address) {
264
+ return;
265
+ }
266
+
267
+ const echoId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
268
+ const echoPayload = HEARTBEAT_ECHO_PREFIX + echoId;
269
+
270
+ try {
271
+ const echoReceived = await new Promise<boolean>((resolve) => {
272
+ this.pendingEchoId = echoId;
273
+ // Timeout: half the heartbeat interval or 15s, whichever is smaller
274
+ const timeout = Math.min(this.heartbeatIntervalMs / 2, 15_000);
275
+ const timer = setTimeout(() => {
276
+ this.pendingEchoResolve = null;
277
+ this.pendingEchoId = null;
278
+ resolve(false);
279
+ }, timeout);
280
+
281
+ this.pendingEchoResolve = () => {
282
+ clearTimeout(timer);
283
+ resolve(true);
284
+ };
285
+
286
+ // Send echo to self (fire-and-forget send, we wait for the message handler)
287
+ this.client!.send(this.address!, echoPayload, { noReply: true });
288
+ });
289
+
290
+ if (echoReceived) {
291
+ this.heartbeatFailures = 0;
292
+ this.emit("heartbeat", { success: true, failures: 0 });
293
+ } else {
294
+ this.heartbeatFailures++;
295
+ this.emit("heartbeat", { success: false, failures: this.heartbeatFailures });
296
+
297
+ if (this.heartbeatFailures >= this.heartbeatMaxFailures) {
298
+ this.emit("heartbeatReconnect", { failures: this.heartbeatFailures });
299
+ await this.reconnect();
300
+ return; // reconnect starts a new heartbeat loop
301
+ }
302
+ }
303
+ } catch {
304
+ this.heartbeatFailures++;
305
+ this.emit("heartbeat", { success: false, failures: this.heartbeatFailures });
306
+
307
+ if (this.heartbeatFailures >= this.heartbeatMaxFailures) {
308
+ this.emit("heartbeatReconnect", { failures: this.heartbeatFailures });
309
+ await this.reconnect();
310
+ return;
311
+ }
312
+ }
313
+
314
+ // Schedule next heartbeat if still connected
315
+ if (this.state === "connected") {
316
+ this.scheduleNextHeartbeat();
317
+ }
318
+ }
319
+
320
+ /** Close the current connection and create a new one using stored options. */
321
+ private async reconnect(): Promise<void> {
322
+ if (this.isReconnecting || !this.seed) return;
323
+ this.isReconnecting = true;
324
+
325
+ try {
326
+ this.stopHeartbeat();
327
+
328
+ // Close existing client
329
+ if (this.client) {
330
+ try {
331
+ this.client.close();
332
+ } catch {
333
+ // ignore close errors
334
+ }
335
+ this.client = null;
336
+ }
337
+ this.address = undefined;
338
+ this.setState("connecting");
339
+
340
+ // Create a fresh client
341
+ this.client = new nkn.MultiClient({
342
+ seed: this.seed,
343
+ numSubClients: this.numSubClients,
344
+ originalClient: false,
345
+ rpcServerAddr: NKN_SEED_RPC_SERVERS[0],
346
+ });
347
+
348
+ await new Promise<void>((resolve, reject) => {
349
+ const timeout = setTimeout(() => {
350
+ reject(new Error("NKN reconnection timeout after 30s"));
351
+ }, 30_000);
352
+
353
+ if (this.abortSignal?.aborted) {
354
+ clearTimeout(timeout);
355
+ reject(new Error("Aborted"));
356
+ return;
357
+ }
358
+
359
+ const onAbort = () => {
360
+ clearTimeout(timeout);
361
+ reject(new Error("Aborted"));
362
+ };
363
+ this.abortSignal?.addEventListener("abort", onAbort, { once: true });
364
+
365
+ this.client!.onConnect(() => {
366
+ clearTimeout(timeout);
367
+ this.abortSignal?.removeEventListener("abort", onAbort);
368
+ resolve();
369
+ });
370
+ });
371
+
372
+ this.address = this.client.addr;
373
+ this.setState("connected");
374
+
375
+ // Re-register message handler
376
+ this.client.onMessage(({ src, payload }: { src: string; payload: Uint8Array | string }) => {
377
+ let data: string;
378
+ if (payload instanceof Uint8Array) {
379
+ data = new TextDecoder().decode(payload);
380
+ } else {
381
+ data = payload;
382
+ }
383
+
384
+ if (data.startsWith(HEARTBEAT_ECHO_PREFIX)) {
385
+ const id = data.slice(HEARTBEAT_ECHO_PREFIX.length);
386
+ if (id === this.pendingEchoId && this.pendingEchoResolve) {
387
+ this.pendingEchoResolve();
388
+ this.pendingEchoResolve = null;
389
+ this.pendingEchoId = null;
390
+ }
391
+ return;
392
+ }
393
+
394
+ this.emit("message", src, data);
395
+ });
396
+
397
+ this.heartbeatFailures = 0;
398
+ this.startHeartbeat();
399
+ } catch (err) {
400
+ this.setState("disconnected");
401
+ if (this.client) {
402
+ try {
403
+ this.client.close();
404
+ } catch {
405
+ // ignore
406
+ }
407
+ this.client = null;
408
+ }
409
+ this.emit("reconnectFailed", err);
410
+ } finally {
411
+ this.isReconnecting = false;
412
+ }
413
+ }
414
+
203
415
  private ensureConnected(): void {
204
416
  if (!this.client || this.state !== "connected") {
205
417
  throw new Error("NKN client not connected");
package/src/onboarding.ts CHANGED
@@ -1,13 +1,14 @@
1
- import type { DmPolicy } from "openclaw/plugin-sdk";
1
+ import type { DmPolicy } from "openclaw/plugin-sdk/setup";
2
2
  import {
3
3
  addWildcardAllowFrom,
4
4
  mergeAllowFromEntries,
5
5
  formatDocsLink,
6
- type ChannelOnboardingAdapter,
7
- type ChannelOnboardingDmPolicy,
6
+ type ChannelSetupDmPolicy,
8
7
  type WizardPrompter,
9
- } from "openclaw/plugin-sdk";
10
- import { listDchatAccountIds, resolveDchatAccount, type CoreConfig } from "./config-schema.js";
8
+ } from "openclaw/plugin-sdk/setup";
9
+ import type { ChannelSetupWizard } from "openclaw/plugin-sdk/setup";
10
+ import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
11
+ import { listDchatAccountIds, resolveDchatAccount, resolveDchatAccountConfig, type CoreConfig } from "./config-schema.js";
11
12
 
12
13
  const channel = "dchat" as const;
13
14
 
@@ -30,154 +31,134 @@ function setDchatDmPolicy(cfg: CoreConfig, policy: DmPolicy) {
30
31
  };
31
32
  }
32
33
 
33
- async function promptDchatAllowFrom(params: {
34
- cfg: CoreConfig;
35
- prompter: WizardPrompter;
36
- }): Promise<CoreConfig> {
37
- const { cfg, prompter } = params;
38
- const existingAllowFrom = cfg.channels?.dchat?.allowFrom ?? [];
39
-
40
- const entry = await prompter.text({
41
- message: "NKN address to allow (full public key hex)",
42
- placeholder: "abc123...def456 (64-char hex NKN address)",
43
- initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined,
44
- validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
45
- });
46
-
47
- const parts = String(entry)
48
- .split(/[\n,;]+/g)
49
- .map((e) => e.trim())
50
- .filter(Boolean);
51
-
52
- const unique = mergeAllowFromEntries(existingAllowFrom, parts);
53
- return {
54
- ...cfg,
55
- channels: {
56
- ...cfg.channels,
57
- dchat: {
58
- ...cfg.channels?.dchat,
59
- enabled: true,
60
- dmPolicy: "allowlist",
61
- allowFrom: unique,
62
- },
63
- },
64
- };
65
- }
66
-
67
- const dmPolicy: ChannelOnboardingDmPolicy = {
34
+ const dmPolicy: ChannelSetupDmPolicy = {
68
35
  label: "D-Chat",
69
36
  channel,
70
37
  policyKey: "channels.dchat.dmPolicy",
71
38
  allowFromKey: "channels.dchat.allowFrom",
72
39
  getCurrent: (cfg) => ((cfg as CoreConfig).channels?.dchat?.dmPolicy as DmPolicy) ?? "pairing",
73
40
  setPolicy: (cfg, policy) => setDchatDmPolicy(cfg as CoreConfig, policy),
74
- promptAllowFrom: promptDchatAllowFrom,
75
- };
41
+ promptAllowFrom: async (params) => {
42
+ const cfg = params.cfg as CoreConfig;
43
+ const { prompter } = params;
44
+ const existingAllowFrom = cfg.channels?.dchat?.allowFrom ?? [];
76
45
 
77
- export const dchatOnboardingAdapter: ChannelOnboardingAdapter = {
78
- channel,
79
- getStatus: async ({ cfg }) => {
80
- const typedCfg = cfg as CoreConfig;
81
- const accountIds = listDchatAccountIds(typedCfg);
82
- const anyConfigured = accountIds.some(
83
- (id) => resolveDchatAccount({ cfg: typedCfg, accountId: id }).configured,
84
- );
46
+ const entry = await prompter.text({
47
+ message: "NKN address to allow (full public key hex)",
48
+ placeholder: "abc123...def456 (64-char hex NKN address)",
49
+ initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined,
50
+ validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
51
+ });
52
+
53
+ const parts = String(entry)
54
+ .split(/[\n,;]+/g)
55
+ .map((e) => e.trim())
56
+ .filter(Boolean);
57
+
58
+ const unique = mergeAllowFromEntries(existingAllowFrom, parts);
85
59
  return {
86
- channel,
87
- configured: anyConfigured,
88
- statusLines: [`D-Chat: ${anyConfigured ? "configured" : "needs wallet seed"}`],
89
- selectionHint: anyConfigured ? "configured" : "needs seed",
60
+ ...cfg,
61
+ channels: {
62
+ ...cfg.channels,
63
+ dchat: {
64
+ ...cfg.channels?.dchat,
65
+ enabled: true,
66
+ dmPolicy: "allowlist",
67
+ allowFrom: unique,
68
+ },
69
+ },
90
70
  };
91
71
  },
92
- configure: async ({ cfg, prompter, forceAllowFrom }) => {
93
- let next = cfg as CoreConfig;
94
- const existing = next.channels?.dchat ?? {};
95
- const account = resolveDchatAccount({ cfg: next });
72
+ };
96
73
 
97
- if (!account.configured) {
98
- await prompter.note(
99
- [
100
- "D-Chat uses the NKN relay network for decentralized E2E encrypted messaging.",
101
- "You need a wallet seed (64-character hex string) to connect.",
102
- "Generate one with nkn-sdk or use an existing seed from D-Chat/nMobile.",
103
- `Docs: ${formatDocsLink("/channels/dchat", "channels/dchat")}`,
104
- ].join("\n"),
105
- "D-Chat setup",
74
+ export const dchatSetupWizard: ChannelSetupWizard = {
75
+ channel,
76
+ status: {
77
+ configuredLabel: "configured",
78
+ unconfiguredLabel: "needs wallet seed",
79
+ resolveConfigured: ({ cfg }) => {
80
+ const typedCfg = cfg as CoreConfig;
81
+ const accountIds = listDchatAccountIds(typedCfg);
82
+ return accountIds.some(
83
+ (id) => resolveDchatAccount({ cfg: typedCfg, accountId: id }).configured,
106
84
  );
107
- }
108
-
109
- // Check for env var (validate same 64-hex format as prompted input)
110
- const envSeed = process.env.DCHAT_SEED?.trim() || process.env.NKN_SEED?.trim();
111
- if (envSeed && /^[0-9a-f]{64}$/i.test(envSeed) && !existing.seed) {
112
- const useEnv = await prompter.confirm({
113
- message: "NKN seed env var detected. Use env value?",
114
- initialValue: true,
115
- });
116
- if (useEnv) {
117
- next = {
118
- ...next,
85
+ },
86
+ resolveStatusLines: ({ cfg, configured }) => [
87
+ `D-Chat: ${configured ? "configured" : "needs wallet seed"}`,
88
+ ],
89
+ resolveSelectionHint: ({ configured }) =>
90
+ configured ? "configured" : "needs seed",
91
+ },
92
+ introNote: {
93
+ title: "D-Chat setup",
94
+ lines: [
95
+ "D-Chat uses the NKN relay network for decentralized E2E encrypted messaging.",
96
+ "You need a wallet seed (64-character hex string) to connect.",
97
+ "Generate one with nkn-sdk or use an existing seed from D-Chat/nMobile.",
98
+ `Docs: ${formatDocsLink("/channels/dchat", "channels/dchat")}`,
99
+ ],
100
+ shouldShow: ({ cfg }) => {
101
+ const account = resolveDchatAccount({ cfg: cfg as CoreConfig });
102
+ return !account.configured;
103
+ },
104
+ },
105
+ envShortcut: {
106
+ prompt: "NKN seed env var detected. Use env value?",
107
+ preferredEnvVar: "DCHAT_SEED",
108
+ isAvailable: ({ cfg }) => {
109
+ const envSeed = process.env.DCHAT_SEED?.trim() || process.env.NKN_SEED?.trim();
110
+ const existing = (cfg as CoreConfig).channels?.dchat?.seed;
111
+ return Boolean(envSeed && /^[0-9a-f]{64}$/i.test(envSeed) && !existing);
112
+ },
113
+ apply: ({ cfg, accountId }) => {
114
+ const envSeed = (process.env.DCHAT_SEED?.trim() || process.env.NKN_SEED?.trim())!;
115
+ return {
116
+ ...cfg,
117
+ channels: {
118
+ ...(cfg as CoreConfig).channels,
119
+ dchat: {
120
+ ...(cfg as CoreConfig).channels?.dchat,
121
+ enabled: true,
122
+ seed: envSeed,
123
+ },
124
+ },
125
+ } as OpenClawConfig;
126
+ },
127
+ },
128
+ credentials: [
129
+ {
130
+ inputKey: "accessToken",
131
+ providerHint: "NKN",
132
+ credentialLabel: "wallet seed",
133
+ preferredEnvVar: "DCHAT_SEED",
134
+ envPrompt: "NKN seed env var detected. Use env value?",
135
+ keepPrompt: "Wallet seed already configured. Keep it?",
136
+ inputPrompt: "NKN wallet seed (64-char hex)",
137
+ inspect: ({ cfg, accountId }) => {
138
+ const acctCfg = resolveDchatAccountConfig({ cfg: cfg as CoreConfig, accountId });
139
+ const seed = acctCfg.seed?.trim();
140
+ return {
141
+ accountConfigured: Boolean(seed),
142
+ hasConfiguredValue: Boolean(seed),
143
+ resolvedValue: seed,
144
+ };
145
+ },
146
+ applySet: ({ cfg, accountId, resolvedValue }) => {
147
+ const seed = resolvedValue.trim();
148
+ return {
149
+ ...cfg,
119
150
  channels: {
120
- ...next.channels,
151
+ ...(cfg as CoreConfig).channels,
121
152
  dchat: {
122
- ...next.channels?.dchat,
153
+ ...(cfg as CoreConfig).channels?.dchat,
123
154
  enabled: true,
124
- seed: envSeed,
155
+ seed,
125
156
  },
126
157
  },
127
- };
128
- if (forceAllowFrom) {
129
- next = await promptDchatAllowFrom({ cfg: next, prompter });
130
- }
131
- return { cfg: next };
132
- }
133
- }
134
-
135
- // Prompt for seed
136
- let seed = existing.seed ?? "";
137
- if (seed) {
138
- const keep = await prompter.confirm({
139
- message: "Wallet seed already configured. Keep it?",
140
- initialValue: true,
141
- });
142
- if (!keep) {
143
- seed = "";
144
- }
145
- }
146
-
147
- if (!seed) {
148
- seed = String(
149
- await prompter.text({
150
- message: "NKN wallet seed (64-char hex)",
151
- validate: (value) => {
152
- const raw = String(value ?? "").trim();
153
- if (!raw) return "Required";
154
- if (!/^[0-9a-f]{64}$/i.test(raw)) {
155
- return "Must be a 64-character hex string";
156
- }
157
- return undefined;
158
- },
159
- }),
160
- ).trim();
161
- }
162
-
163
- next = {
164
- ...next,
165
- channels: {
166
- ...next.channels,
167
- dchat: {
168
- ...next.channels?.dchat,
169
- enabled: true,
170
- seed,
171
- },
158
+ } as OpenClawConfig;
172
159
  },
173
- };
174
-
175
- if (forceAllowFrom) {
176
- next = await promptDchatAllowFrom({ cfg: next, prompter });
177
- }
178
-
179
- return { cfg: next };
180
- },
160
+ },
161
+ ],
181
162
  dmPolicy,
182
163
  disable: (cfg) => ({
183
164
  ...(cfg as CoreConfig),
package/src/runtime.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { PluginRuntime } from "openclaw/plugin-sdk";
1
+ import type { PluginRuntime } from "openclaw/plugin-sdk/core";
2
2
 
3
3
  let runtime: PluginRuntime | null = null;
4
4