@zbruceli/openclaw-dchat 0.1.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.
@@ -0,0 +1,124 @@
1
+ import type { OpenClawConfig } from "openclaw/plugin-sdk";
2
+ import { z } from "zod";
3
+ import type { DchatAccountConfig, ResolvedDchatAccount } from "./types.js";
4
+
5
+ type CoreConfig = OpenClawConfig & {
6
+ channels?: {
7
+ dchat?: DchatAccountConfig & {
8
+ accounts?: Record<string, DchatAccountConfig>;
9
+ defaultAccount?: string;
10
+ };
11
+ };
12
+ };
13
+
14
+ export type { CoreConfig };
15
+
16
+ const DEFAULT_ACCOUNT_ID = "default";
17
+
18
+ /* ── Zod config schema (powers web UI form via buildChannelConfigSchema) ── */
19
+
20
+ const dchatAccountSchema = z.object({
21
+ name: z.string().optional(),
22
+ enabled: z.boolean().optional(),
23
+ seed: z.string().optional(),
24
+ keystoreJson: z.string().optional(),
25
+ keystorePassword: z.string().optional(),
26
+ numSubClients: z.number().optional(),
27
+ ipfsGateway: z.string().optional(),
28
+ dm: z
29
+ .object({
30
+ policy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
31
+ allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
32
+ })
33
+ .optional(),
34
+ });
35
+
36
+ export const DchatConfigSchema = dchatAccountSchema.extend({
37
+ accounts: z.record(z.string(), dchatAccountSchema.optional()).optional(),
38
+ defaultAccount: z.string().optional(),
39
+ });
40
+
41
+ /* ── Config helpers ── */
42
+
43
+ export function listDchatAccountIds(cfg: CoreConfig): string[] {
44
+ const dchatConfig = cfg.channels?.dchat;
45
+ if (!dchatConfig) return [];
46
+
47
+ const ids: string[] = [];
48
+
49
+ // Top-level config counts as the default account
50
+ if (dchatConfig.seed || dchatConfig.keystoreJson) {
51
+ ids.push(DEFAULT_ACCOUNT_ID);
52
+ }
53
+
54
+ // Named accounts
55
+ if (dchatConfig.accounts) {
56
+ for (const id of Object.keys(dchatConfig.accounts)) {
57
+ if (!ids.includes(id)) {
58
+ ids.push(id);
59
+ }
60
+ }
61
+ }
62
+
63
+ if (ids.length === 0 && dchatConfig.enabled !== false) {
64
+ ids.push(DEFAULT_ACCOUNT_ID);
65
+ }
66
+
67
+ return ids;
68
+ }
69
+
70
+ export function resolveDefaultDchatAccountId(cfg: CoreConfig): string {
71
+ const dchatConfig = cfg.channels?.dchat;
72
+ if (dchatConfig?.defaultAccount) return dchatConfig.defaultAccount;
73
+ // If top-level seed exists, treat as the default account
74
+ if (dchatConfig?.seed?.trim()) return DEFAULT_ACCOUNT_ID;
75
+ // Otherwise pick the first named account
76
+ const named = dchatConfig?.accounts ? Object.keys(dchatConfig.accounts) : [];
77
+ return named[0] ?? DEFAULT_ACCOUNT_ID;
78
+ }
79
+
80
+ export function resolveDchatAccountConfig(params: {
81
+ cfg: CoreConfig;
82
+ accountId?: string | null;
83
+ }): DchatAccountConfig {
84
+ const { cfg, accountId } = params;
85
+ const dchatConfig = cfg.channels?.dchat;
86
+ if (!dchatConfig) return { enabled: false };
87
+
88
+ if (accountId && dchatConfig.accounts?.[accountId]) {
89
+ // Named account: merge channel-level fields (e.g. dm policy) as defaults
90
+ const acct = dchatConfig.accounts[accountId];
91
+ return {
92
+ ...dchatConfig,
93
+ ...acct,
94
+ dm: { ...dchatConfig.dm, ...acct.dm },
95
+ };
96
+ }
97
+
98
+ // Top-level config (default account without explicit accounts.default entry)
99
+ return dchatConfig;
100
+ }
101
+
102
+ export function resolveDchatAccount(params: {
103
+ cfg: CoreConfig;
104
+ accountId?: string | null;
105
+ }): ResolvedDchatAccount {
106
+ const { cfg, accountId: rawAccountId } = params;
107
+ const accountId = rawAccountId || DEFAULT_ACCOUNT_ID;
108
+ const accountConfig = resolveDchatAccountConfig({ cfg, accountId });
109
+
110
+ const hasSeed = Boolean(accountConfig.seed?.trim());
111
+ const hasKeystore = Boolean(accountConfig.keystoreJson?.trim());
112
+
113
+ const baseEnabled = cfg.channels?.dchat?.enabled !== false;
114
+ return {
115
+ accountId,
116
+ name: accountConfig.name || accountId,
117
+ enabled: baseEnabled && accountConfig.enabled !== false,
118
+ configured: hasSeed,
119
+ seed: accountConfig.seed?.trim(),
120
+ numSubClients: accountConfig.numSubClients ?? 4,
121
+ ipfsGateway: accountConfig.ipfsGateway ?? "64.225.88.71:80",
122
+ config: accountConfig,
123
+ };
124
+ }
@@ -0,0 +1,95 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { byteArrayToKey, decryptAesGcm, encryptAesGcm, keyToByteArray } from "./crypto.js";
3
+
4
+ describe("AES-128-GCM crypto", () => {
5
+ it("encrypts and decrypts round-trip", () => {
6
+ const plaintext = Buffer.from("Hello, D-Chat!");
7
+ const { ciphertext, key } = encryptAesGcm(plaintext);
8
+
9
+ const decrypted = decryptAesGcm(ciphertext, key);
10
+ expect(decrypted.toString()).toBe("Hello, D-Chat!");
11
+ });
12
+
13
+ it("produces different ciphertext for same plaintext", () => {
14
+ const plaintext = Buffer.from("Same input");
15
+ const result1 = encryptAesGcm(plaintext);
16
+ const result2 = encryptAesGcm(plaintext);
17
+
18
+ // Different random keys/nonces should produce different ciphertext
19
+ expect(result1.ciphertext).not.toEqual(result2.ciphertext);
20
+ });
21
+
22
+ it("uses correct nonce and key sizes", () => {
23
+ const { key, nonce } = encryptAesGcm(Buffer.from("test"));
24
+ expect(key.length).toBe(16); // AES-128 = 16-byte key
25
+ expect(nonce.length).toBe(12); // GCM standard nonce
26
+ });
27
+
28
+ it("ciphertext format: nonce (12B) + encrypted + auth tag (16B)", () => {
29
+ const plaintext = Buffer.from("test data");
30
+ const { ciphertext, nonce } = encryptAesGcm(plaintext);
31
+
32
+ // First 12 bytes should be the nonce
33
+ expect(ciphertext.subarray(0, 12)).toEqual(nonce);
34
+
35
+ // Minimum size: 12 (nonce) + 1 (data) + 16 (auth tag) = 29
36
+ expect(ciphertext.length).toBeGreaterThanOrEqual(12 + 1 + 16);
37
+ });
38
+
39
+ it("fails to decrypt with wrong key", () => {
40
+ const plaintext = Buffer.from("secret");
41
+ const { ciphertext } = encryptAesGcm(plaintext);
42
+ const wrongKey = Buffer.alloc(16, 0xff);
43
+
44
+ expect(() => decryptAesGcm(ciphertext, wrongKey)).toThrow();
45
+ });
46
+
47
+ it("handles empty plaintext", () => {
48
+ const plaintext = Buffer.alloc(0);
49
+ const { ciphertext, key } = encryptAesGcm(plaintext);
50
+
51
+ const decrypted = decryptAesGcm(ciphertext, key);
52
+ expect(decrypted.length).toBe(0);
53
+ });
54
+
55
+ it("handles large plaintext", () => {
56
+ const plaintext = Buffer.alloc(1024 * 100, 0x42); // 100KB
57
+ const { ciphertext, key } = encryptAesGcm(plaintext);
58
+
59
+ const decrypted = decryptAesGcm(ciphertext, key);
60
+ expect(decrypted).toEqual(plaintext);
61
+ });
62
+ });
63
+
64
+ describe("key conversion helpers", () => {
65
+ it("converts key to byte array (nMobile wire format)", () => {
66
+ const key = Buffer.from([
67
+ 0xb0, 0x71, 0x5a, 0xff, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a,
68
+ 0x0b,
69
+ ]);
70
+ const bytes = keyToByteArray(key);
71
+ expect(bytes).toEqual([176, 113, 90, 255, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]);
72
+ });
73
+
74
+ it("round-trips key through byte array", () => {
75
+ const original = Buffer.from([
76
+ 0xde, 0xad, 0xbe, 0xef, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b,
77
+ 0x0c,
78
+ ]);
79
+ const bytes = keyToByteArray(original);
80
+ const restored = byteArrayToKey(bytes);
81
+ expect(restored).toEqual(original);
82
+ });
83
+
84
+ it("encryption + byte array round-trip", () => {
85
+ const plaintext = Buffer.from("nMobile interop test");
86
+ const { ciphertext, key } = encryptAesGcm(plaintext);
87
+
88
+ // Simulate nMobile wire format: convert key to number[] and back
89
+ const keyBytes = keyToByteArray(key);
90
+ const restoredKey = byteArrayToKey(keyBytes);
91
+
92
+ const decrypted = decryptAesGcm(ciphertext, restoredKey);
93
+ expect(decrypted.toString()).toBe("nMobile interop test");
94
+ });
95
+ });
package/src/crypto.ts ADDED
@@ -0,0 +1,56 @@
1
+ import crypto from "crypto";
2
+
3
+ const ALGORITHM = "aes-128-gcm";
4
+ const KEY_BYTES = 16;
5
+ const NONCE_BYTES = 12;
6
+ const AUTH_TAG_BYTES = 16;
7
+
8
+ export interface EncryptResult {
9
+ ciphertext: Buffer; // nonce (12 bytes) + encrypted data + auth tag (16 bytes)
10
+ key: Buffer; // 16-byte AES key
11
+ nonce: Buffer; // 12-byte nonce (also prepended to ciphertext)
12
+ }
13
+
14
+ /**
15
+ * Encrypt with AES-128-GCM, prepending nonce to ciphertext (nMobile convention).
16
+ * Output format: [nonce (12 bytes)] [encrypted data] [auth tag (16 bytes)]
17
+ */
18
+ export function encryptAesGcm(plaintext: Buffer): EncryptResult {
19
+ const key = crypto.randomBytes(KEY_BYTES);
20
+ const nonce = crypto.randomBytes(NONCE_BYTES);
21
+
22
+ const cipher = crypto.createCipheriv(ALGORITHM, key, nonce);
23
+ const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]);
24
+ const authTag = cipher.getAuthTag();
25
+
26
+ const ciphertext = Buffer.concat([nonce, encrypted, authTag]);
27
+
28
+ return { ciphertext, key, nonce };
29
+ }
30
+
31
+ /**
32
+ * Decrypt AES-128-GCM with nonce prepended to ciphertext (nMobile convention).
33
+ * Input format: [nonce (12 bytes)] [encrypted data] [auth tag (16 bytes)]
34
+ */
35
+ export function decryptAesGcm(data: Buffer, key: Buffer, nonceSize: number = NONCE_BYTES): Buffer {
36
+ const nonce = data.subarray(0, nonceSize);
37
+ const ciphertextWithTag = data.subarray(nonceSize);
38
+
39
+ const encrypted = ciphertextWithTag.subarray(0, ciphertextWithTag.length - AUTH_TAG_BYTES);
40
+ const authTag = ciphertextWithTag.subarray(ciphertextWithTag.length - AUTH_TAG_BYTES);
41
+
42
+ const decipher = crypto.createDecipheriv(ALGORITHM, key, nonce);
43
+ decipher.setAuthTag(authTag);
44
+
45
+ return Buffer.concat([decipher.update(encrypted), decipher.final()]);
46
+ }
47
+
48
+ /** Convert a Buffer key to a number[] array (nMobile wire format). */
49
+ export function keyToByteArray(key: Buffer): number[] {
50
+ return Array.from(key);
51
+ }
52
+
53
+ /** Convert a number[] byte array back to Buffer. */
54
+ export function byteArrayToKey(bytes: number[]): Buffer {
55
+ return Buffer.from(bytes);
56
+ }
package/src/nkn-bus.ts ADDED
@@ -0,0 +1,213 @@
1
+ import { EventEmitter } from "events";
2
+ import nkn from "nkn-sdk";
3
+ import { NKN_SEED_RPC_SERVERS, type NknConnectionState } from "./types.js";
4
+
5
+ export interface NknBusOptions {
6
+ seed: string;
7
+ numSubClients?: number;
8
+ }
9
+
10
+ /**
11
+ * NKN MultiClient wrapper for D-Chat wire-format messaging.
12
+ * Handles connect, send, receive, subscribe, and reconnection.
13
+ */
14
+ export class NknBus extends EventEmitter {
15
+ private client: nkn.MultiClient | null = null;
16
+ private state: NknConnectionState = "disconnected";
17
+ private address: string | undefined;
18
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
19
+ private seed: string | undefined;
20
+ private numSubClients: number;
21
+ private abortSignal: AbortSignal | undefined;
22
+
23
+ constructor() {
24
+ super();
25
+ this.numSubClients = 4;
26
+ }
27
+
28
+ getState(): NknConnectionState {
29
+ return this.state;
30
+ }
31
+
32
+ getAddress(): string | undefined {
33
+ return this.address;
34
+ }
35
+
36
+ async connect(opts: NknBusOptions, abortSignal?: AbortSignal): Promise<string> {
37
+ if (this.client) {
38
+ await this.disconnect();
39
+ }
40
+
41
+ this.seed = opts.seed;
42
+ this.numSubClients = opts.numSubClients ?? 4;
43
+ this.abortSignal = abortSignal;
44
+ this.setState("connecting");
45
+
46
+ try {
47
+ this.client = new nkn.MultiClient({
48
+ seed: opts.seed,
49
+ numSubClients: this.numSubClients,
50
+ originalClient: false,
51
+ rpcServerAddr: NKN_SEED_RPC_SERVERS[0],
52
+ });
53
+
54
+ await new Promise<void>((resolve, reject) => {
55
+ const timeout = setTimeout(() => {
56
+ reject(new Error("NKN connection timeout after 30s"));
57
+ }, 30000);
58
+
59
+ if (abortSignal?.aborted) {
60
+ clearTimeout(timeout);
61
+ reject(new Error("Aborted"));
62
+ return;
63
+ }
64
+
65
+ const onAbort = () => {
66
+ clearTimeout(timeout);
67
+ reject(new Error("Aborted"));
68
+ };
69
+ abortSignal?.addEventListener("abort", onAbort, { once: true });
70
+
71
+ this.client!.onConnect(() => {
72
+ clearTimeout(timeout);
73
+ abortSignal?.removeEventListener("abort", onAbort);
74
+ resolve();
75
+ });
76
+ });
77
+
78
+ this.address = this.client.addr;
79
+ this.setState("connected");
80
+
81
+ // Register message handler: src may include __N__. sub-client prefix; caller should normalize
82
+ this.client.onMessage(({ src, payload }: { src: string; payload: Uint8Array | string }) => {
83
+ let data: string;
84
+ if (payload instanceof Uint8Array) {
85
+ data = new TextDecoder().decode(payload);
86
+ } else {
87
+ data = payload;
88
+ }
89
+ this.emit("message", src, data);
90
+ });
91
+
92
+ return this.address;
93
+ } catch (err) {
94
+ this.setState("disconnected");
95
+ if (this.client) {
96
+ try {
97
+ this.client.close();
98
+ } catch {
99
+ // ignore close errors during cleanup
100
+ }
101
+ this.client = null;
102
+ }
103
+ throw err;
104
+ }
105
+ }
106
+
107
+ async disconnect(): Promise<void> {
108
+ if (this.reconnectTimer) {
109
+ clearTimeout(this.reconnectTimer);
110
+ this.reconnectTimer = null;
111
+ }
112
+ if (this.client) {
113
+ try {
114
+ this.client.close();
115
+ } catch {
116
+ // ignore close errors
117
+ }
118
+ this.client = null;
119
+ }
120
+ this.address = undefined;
121
+ this.setState("disconnected");
122
+ }
123
+
124
+ /**
125
+ * Send a message and wait for recipient ACK.
126
+ * Used for direct text messages.
127
+ */
128
+ async send(dest: string, payload: string): Promise<void> {
129
+ this.ensureConnected();
130
+ await this.client!.send(dest, payload, {
131
+ msgHoldingSeconds: 3600,
132
+ });
133
+ }
134
+
135
+ /**
136
+ * Send without waiting for ACK (fire-and-forget).
137
+ * Used for media messages and topic broadcasts.
138
+ */
139
+ sendNoReply(dest: string, payload: string): void {
140
+ this.ensureConnected();
141
+ this.client!.send(dest, payload, {
142
+ noReply: true,
143
+ msgHoldingSeconds: 3600,
144
+ });
145
+ }
146
+
147
+ /**
148
+ * Send to multiple destinations (topic broadcast).
149
+ * Fire-and-forget.
150
+ */
151
+ sendToMultiple(dests: string[], payload: string): void {
152
+ this.ensureConnected();
153
+ if (dests.length === 0) return;
154
+ this.client!.send(dests, payload, {
155
+ noReply: true,
156
+ msgHoldingSeconds: 3600,
157
+ });
158
+ }
159
+
160
+ /** Subscribe to a topic on the NKN blockchain. */
161
+ async subscribe(topicHash: string, duration = 400000, fee = "0"): Promise<string> {
162
+ this.ensureConnected();
163
+ const txnHash = await this.client!.subscribe(topicHash, duration, "", "", {
164
+ fee,
165
+ attrs: undefined,
166
+ buildOnly: undefined,
167
+ } as nkn.TransactionOptions);
168
+ return String(txnHash);
169
+ }
170
+
171
+ /** Unsubscribe from a topic. */
172
+ async unsubscribe(topicHash: string, fee = "0"): Promise<string> {
173
+ this.ensureConnected();
174
+ const txnHash = await this.client!.unsubscribe(topicHash, "", {
175
+ fee,
176
+ attrs: undefined,
177
+ buildOnly: undefined,
178
+ } as nkn.TransactionOptions);
179
+ return String(txnHash);
180
+ }
181
+
182
+ /** Fetch subscriber addresses for a topic. */
183
+ async getSubscribers(topicHash: string): Promise<string[]> {
184
+ this.ensureConnected();
185
+ const result = await this.client!.getSubscribers(topicHash, {
186
+ offset: 0,
187
+ limit: 1000,
188
+ txPool: true,
189
+ });
190
+ const subs = result.subscribers;
191
+ if (Array.isArray(subs)) {
192
+ return subs;
193
+ }
194
+ // Record<string, string> form — keys are addresses
195
+ return Object.keys(subs);
196
+ }
197
+
198
+ /** Register a handler for incoming NKN messages. */
199
+ onMessage(handler: (src: string, data: string) => void): void {
200
+ this.on("message", handler);
201
+ }
202
+
203
+ private ensureConnected(): void {
204
+ if (!this.client || this.state !== "connected") {
205
+ throw new Error("NKN client not connected");
206
+ }
207
+ }
208
+
209
+ private setState(next: NknConnectionState): void {
210
+ this.state = next;
211
+ this.emit("stateChange", next);
212
+ }
213
+ }
@@ -0,0 +1,195 @@
1
+ import type { DmPolicy } from "openclaw/plugin-sdk";
2
+ import {
3
+ addWildcardAllowFrom,
4
+ mergeAllowFromEntries,
5
+ formatDocsLink,
6
+ type ChannelOnboardingAdapter,
7
+ type ChannelOnboardingDmPolicy,
8
+ type WizardPrompter,
9
+ } from "openclaw/plugin-sdk";
10
+ import { listDchatAccountIds, resolveDchatAccount, type CoreConfig } from "./config-schema.js";
11
+
12
+ const channel = "dchat" as const;
13
+
14
+ function setDchatDmPolicy(cfg: CoreConfig, policy: DmPolicy) {
15
+ const existingAllowFrom = cfg.channels?.dchat?.dm?.allowFrom ?? [];
16
+ const allowFrom =
17
+ policy === "open"
18
+ ? addWildcardAllowFrom(existingAllowFrom)
19
+ : existingAllowFrom.filter((e) => String(e) !== "*");
20
+ return {
21
+ ...cfg,
22
+ channels: {
23
+ ...cfg.channels,
24
+ dchat: {
25
+ ...cfg.channels?.dchat,
26
+ dm: {
27
+ ...cfg.channels?.dchat?.dm,
28
+ policy,
29
+ allowFrom,
30
+ },
31
+ },
32
+ },
33
+ };
34
+ }
35
+
36
+ async function promptDchatAllowFrom(params: {
37
+ cfg: CoreConfig;
38
+ prompter: WizardPrompter;
39
+ }): Promise<CoreConfig> {
40
+ const { cfg, prompter } = params;
41
+ const existingAllowFrom = cfg.channels?.dchat?.dm?.allowFrom ?? [];
42
+
43
+ const entry = await prompter.text({
44
+ message: "NKN address to allow (full public key hex)",
45
+ placeholder: "abc123...def456 (64-char hex NKN address)",
46
+ initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined,
47
+ validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
48
+ });
49
+
50
+ const parts = String(entry)
51
+ .split(/[\n,;]+/g)
52
+ .map((e) => e.trim())
53
+ .filter(Boolean);
54
+
55
+ const unique = mergeAllowFromEntries(existingAllowFrom, parts);
56
+ return {
57
+ ...cfg,
58
+ channels: {
59
+ ...cfg.channels,
60
+ dchat: {
61
+ ...cfg.channels?.dchat,
62
+ enabled: true,
63
+ dm: {
64
+ ...cfg.channels?.dchat?.dm,
65
+ policy: "allowlist",
66
+ allowFrom: unique,
67
+ },
68
+ },
69
+ },
70
+ };
71
+ }
72
+
73
+ const dmPolicy: ChannelOnboardingDmPolicy = {
74
+ label: "D-Chat",
75
+ channel,
76
+ policyKey: "channels.dchat.dm.policy",
77
+ allowFromKey: "channels.dchat.dm.allowFrom",
78
+ getCurrent: (cfg) => ((cfg as CoreConfig).channels?.dchat?.dm?.policy as DmPolicy) ?? "pairing",
79
+ setPolicy: (cfg, policy) => setDchatDmPolicy(cfg as CoreConfig, policy),
80
+ promptAllowFrom: promptDchatAllowFrom,
81
+ };
82
+
83
+ export const dchatOnboardingAdapter: ChannelOnboardingAdapter = {
84
+ channel,
85
+ getStatus: async ({ cfg }) => {
86
+ const typedCfg = cfg as CoreConfig;
87
+ const accountIds = listDchatAccountIds(typedCfg);
88
+ const anyConfigured = accountIds.some(
89
+ (id) => resolveDchatAccount({ cfg: typedCfg, accountId: id }).configured,
90
+ );
91
+ return {
92
+ channel,
93
+ configured: anyConfigured,
94
+ statusLines: [`D-Chat: ${anyConfigured ? "configured" : "needs wallet seed"}`],
95
+ selectionHint: anyConfigured ? "configured" : "needs seed",
96
+ };
97
+ },
98
+ configure: async ({ cfg, prompter, forceAllowFrom }) => {
99
+ let next = cfg as CoreConfig;
100
+ const existing = next.channels?.dchat ?? {};
101
+ const account = resolveDchatAccount({ cfg: next });
102
+
103
+ if (!account.configured) {
104
+ await prompter.note(
105
+ [
106
+ "D-Chat uses the NKN relay network for decentralized E2E encrypted messaging.",
107
+ "You need a wallet seed (64-character hex string) to connect.",
108
+ "Generate one with nkn-sdk or use an existing seed from D-Chat/nMobile.",
109
+ `Docs: ${formatDocsLink("/channels/dchat", "channels/dchat")}`,
110
+ ].join("\n"),
111
+ "D-Chat setup",
112
+ );
113
+ }
114
+
115
+ // Check for env var (validate same 64-hex format as prompted input)
116
+ const envSeed = process.env.DCHAT_SEED?.trim() || process.env.NKN_SEED?.trim();
117
+ if (envSeed && /^[0-9a-f]{64}$/i.test(envSeed) && !existing.seed) {
118
+ const useEnv = await prompter.confirm({
119
+ message: "NKN seed env var detected. Use env value?",
120
+ initialValue: true,
121
+ });
122
+ if (useEnv) {
123
+ next = {
124
+ ...next,
125
+ channels: {
126
+ ...next.channels,
127
+ dchat: {
128
+ ...next.channels?.dchat,
129
+ enabled: true,
130
+ seed: envSeed,
131
+ },
132
+ },
133
+ };
134
+ if (forceAllowFrom) {
135
+ next = await promptDchatAllowFrom({ cfg: next, prompter });
136
+ }
137
+ return { cfg: next };
138
+ }
139
+ }
140
+
141
+ // Prompt for seed
142
+ let seed = existing.seed ?? "";
143
+ if (seed) {
144
+ const keep = await prompter.confirm({
145
+ message: "Wallet seed already configured. Keep it?",
146
+ initialValue: true,
147
+ });
148
+ if (!keep) {
149
+ seed = "";
150
+ }
151
+ }
152
+
153
+ if (!seed) {
154
+ seed = String(
155
+ await prompter.text({
156
+ message: "NKN wallet seed (64-char hex)",
157
+ validate: (value) => {
158
+ const raw = String(value ?? "").trim();
159
+ if (!raw) return "Required";
160
+ if (!/^[0-9a-f]{64}$/i.test(raw)) {
161
+ return "Must be a 64-character hex string";
162
+ }
163
+ return undefined;
164
+ },
165
+ }),
166
+ ).trim();
167
+ }
168
+
169
+ next = {
170
+ ...next,
171
+ channels: {
172
+ ...next.channels,
173
+ dchat: {
174
+ ...next.channels?.dchat,
175
+ enabled: true,
176
+ seed,
177
+ },
178
+ },
179
+ };
180
+
181
+ if (forceAllowFrom) {
182
+ next = await promptDchatAllowFrom({ cfg: next, prompter });
183
+ }
184
+
185
+ return { cfg: next };
186
+ },
187
+ dmPolicy,
188
+ disable: (cfg) => ({
189
+ ...(cfg as CoreConfig),
190
+ channels: {
191
+ ...(cfg as CoreConfig).channels,
192
+ dchat: { ...(cfg as CoreConfig).channels?.dchat, enabled: false },
193
+ },
194
+ }),
195
+ };
package/src/runtime.ts ADDED
@@ -0,0 +1,14 @@
1
+ import type { PluginRuntime } from "openclaw/plugin-sdk";
2
+
3
+ let runtime: PluginRuntime | null = null;
4
+
5
+ export function setDchatRuntime(next: PluginRuntime) {
6
+ runtime = next;
7
+ }
8
+
9
+ export function getDchatRuntime(): PluginRuntime {
10
+ if (!runtime) {
11
+ throw new Error("D-Chat runtime not initialized");
12
+ }
13
+ return runtime;
14
+ }