@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,81 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { SeenTracker } from "./seen-tracker.js";
3
+
4
+ describe("SeenTracker", () => {
5
+ it("tracks seen message IDs", () => {
6
+ const tracker = new SeenTracker();
7
+ expect(tracker.hasSeen("msg-1")).toBe(false);
8
+ tracker.markSeen("msg-1");
9
+ expect(tracker.hasSeen("msg-1")).toBe(true);
10
+ });
11
+
12
+ it("checkAndMark returns false for new, true for seen", () => {
13
+ const tracker = new SeenTracker();
14
+ expect(tracker.checkAndMark("msg-1")).toBe(false);
15
+ expect(tracker.checkAndMark("msg-1")).toBe(true);
16
+ expect(tracker.checkAndMark("msg-2")).toBe(false);
17
+ });
18
+
19
+ it("tracks size correctly", () => {
20
+ const tracker = new SeenTracker();
21
+ expect(tracker.size).toBe(0);
22
+ tracker.markSeen("a");
23
+ tracker.markSeen("b");
24
+ tracker.markSeen("c");
25
+ expect(tracker.size).toBe(3);
26
+ });
27
+
28
+ it("evicts oldest entries when over capacity", () => {
29
+ const tracker = new SeenTracker(3);
30
+ tracker.markSeen("a");
31
+ tracker.markSeen("b");
32
+ tracker.markSeen("c");
33
+ expect(tracker.size).toBe(3);
34
+
35
+ // Adding a 4th should evict "a" (oldest)
36
+ tracker.markSeen("d");
37
+ expect(tracker.size).toBe(3);
38
+ expect(tracker.hasSeen("a")).toBe(false);
39
+ expect(tracker.hasSeen("b")).toBe(true);
40
+ expect(tracker.hasSeen("c")).toBe(true);
41
+ expect(tracker.hasSeen("d")).toBe(true);
42
+ });
43
+
44
+ it("re-marking refreshes position (LRU behavior)", () => {
45
+ const tracker = new SeenTracker(3);
46
+ tracker.markSeen("a");
47
+ tracker.markSeen("b");
48
+ tracker.markSeen("c");
49
+
50
+ // Re-mark "a" to move it to the end
51
+ tracker.markSeen("a");
52
+
53
+ // Now "b" is oldest; adding "d" should evict "b"
54
+ tracker.markSeen("d");
55
+ expect(tracker.hasSeen("b")).toBe(false);
56
+ expect(tracker.hasSeen("a")).toBe(true);
57
+ expect(tracker.hasSeen("c")).toBe(true);
58
+ expect(tracker.hasSeen("d")).toBe(true);
59
+ });
60
+
61
+ it("clear removes all entries", () => {
62
+ const tracker = new SeenTracker();
63
+ tracker.markSeen("a");
64
+ tracker.markSeen("b");
65
+ expect(tracker.size).toBe(2);
66
+ tracker.clear();
67
+ expect(tracker.size).toBe(0);
68
+ expect(tracker.hasSeen("a")).toBe(false);
69
+ });
70
+
71
+ it("handles default max size of 10000", () => {
72
+ const tracker = new SeenTracker();
73
+ for (let i = 0; i < 10001; i++) {
74
+ tracker.markSeen(`msg-${i}`);
75
+ }
76
+ expect(tracker.size).toBe(10000);
77
+ // First entry should have been evicted
78
+ expect(tracker.hasSeen("msg-0")).toBe(false);
79
+ expect(tracker.hasSeen("msg-10000")).toBe(true);
80
+ });
81
+ });
@@ -0,0 +1,56 @@
1
+ /**
2
+ * LRU-based dedup tracker for incoming NKN message IDs.
3
+ * Prevents duplicate processing when the same message arrives
4
+ * via multiple NKN sub-clients.
5
+ */
6
+ export class SeenTracker {
7
+ private seen: Map<string, true>;
8
+ private maxSize: number;
9
+
10
+ constructor(maxSize = 10_000) {
11
+ this.seen = new Map();
12
+ this.maxSize = maxSize;
13
+ }
14
+
15
+ /** Returns true if the ID was already seen. */
16
+ hasSeen(id: string): boolean {
17
+ return this.seen.has(id);
18
+ }
19
+
20
+ /** Mark an ID as seen. Evicts oldest entries if over capacity. */
21
+ markSeen(id: string): void {
22
+ if (this.seen.has(id)) {
23
+ // Move to end (most recent) by deleting and re-inserting
24
+ this.seen.delete(id);
25
+ }
26
+ this.seen.set(id, true);
27
+ this.evict();
28
+ }
29
+
30
+ /** Check + mark in one call. Returns true if already seen. */
31
+ checkAndMark(id: string): boolean {
32
+ if (this.seen.has(id)) {
33
+ return true;
34
+ }
35
+ this.markSeen(id);
36
+ return false;
37
+ }
38
+
39
+ get size(): number {
40
+ return this.seen.size;
41
+ }
42
+
43
+ clear(): void {
44
+ this.seen.clear();
45
+ }
46
+
47
+ private evict(): void {
48
+ while (this.seen.size > this.maxSize) {
49
+ // Map iterates in insertion order; first key is oldest
50
+ const oldest = this.seen.keys().next().value;
51
+ if (oldest !== undefined) {
52
+ this.seen.delete(oldest);
53
+ }
54
+ }
55
+ }
56
+ }
package/src/types.ts ADDED
@@ -0,0 +1,144 @@
1
+ /**
2
+ * D-Chat/nMobile wire format types.
3
+ * Matches the MessageData envelope sent over NKN relay for full interop
4
+ * with D-Chat Desktop and nMobile.
5
+ */
6
+
7
+ export type MessageContentType =
8
+ | "text"
9
+ | "textExtension"
10
+ | "image"
11
+ | "audio"
12
+ | "video"
13
+ | "file"
14
+ | "ipfs"
15
+ | "piece"
16
+ | "receipt"
17
+ | "contact"
18
+ | "contactOptions"
19
+ | "deviceInfo"
20
+ | "deviceRequest"
21
+ | "topic:subscribe"
22
+ | "topic:unsubscribe"
23
+ | "privateGroup:invitation"
24
+ | "privateGroup:accept"
25
+ | "privateGroup:subscribe"
26
+ | "privateGroup:quit"
27
+ | "privateGroup:optionRequest"
28
+ | "privateGroup:optionResponse"
29
+ | "privateGroup:memberRequest"
30
+ | "privateGroup:memberResponse"
31
+ | "read"
32
+ | "discovery:broadcast";
33
+
34
+ /** Control content types that should not be forwarded to the agent. */
35
+ export const CONTROL_CONTENT_TYPES: ReadonlySet<string> = new Set([
36
+ "receipt",
37
+ "read",
38
+ "contact",
39
+ "contactOptions",
40
+ "deviceInfo",
41
+ "deviceRequest",
42
+ "topic:subscribe",
43
+ "topic:unsubscribe",
44
+ "privateGroup:invitation",
45
+ "privateGroup:accept",
46
+ "privateGroup:subscribe",
47
+ "privateGroup:quit",
48
+ "privateGroup:optionRequest",
49
+ "privateGroup:optionResponse",
50
+ "privateGroup:memberRequest",
51
+ "privateGroup:memberResponse",
52
+ "discovery:broadcast",
53
+ ]);
54
+
55
+ /** Content types that carry displayable message content. */
56
+ export const DISPLAYABLE_CONTENT_TYPES: ReadonlySet<string> = new Set([
57
+ "text",
58
+ "textExtension",
59
+ "image",
60
+ "audio",
61
+ "video",
62
+ "file",
63
+ "ipfs",
64
+ ]);
65
+
66
+ export interface MessageOptions {
67
+ deleteAfterSeconds?: number;
68
+ updateBurnAfterAt?: number;
69
+ profileVersion?: string;
70
+
71
+ // Full image IPFS (nMobile format)
72
+ ipfsHash?: string;
73
+ ipfsIp?: string;
74
+ ipfsEncrypt?: number;
75
+ ipfsEncryptAlgorithm?: string; // "AES/GCM/NoPadding"
76
+ ipfsEncryptKeyBytes?: number[]; // byte array (16 bytes for AES-128)
77
+ ipfsEncryptNonceSize?: number; // 12 — nonce prepended to ciphertext
78
+
79
+ // Thumbnail IPFS (nMobile format)
80
+ ipfsThumbnailHash?: string;
81
+ ipfsThumbnailIp?: string;
82
+ ipfsThumbnailEncrypt?: number;
83
+ ipfsThumbnailEncryptAlgorithm?: string;
84
+ ipfsThumbnailEncryptKeyBytes?: number[];
85
+ ipfsThumbnailEncryptNonceSize?: number;
86
+
87
+ // File info
88
+ fileType?: number | string; // 0=file, 1=image, 2=audio, 3=video
89
+ fileName?: string;
90
+ fileExt?: string;
91
+ fileMimeType?: string;
92
+ fileSize?: number;
93
+ mediaWidth?: number;
94
+ mediaHeight?: number;
95
+ mediaDuration?: number; // seconds (float), used for audio/video
96
+ }
97
+
98
+ /** Wire format message envelope sent over NKN relay. */
99
+ export interface MessageData {
100
+ id: string;
101
+ contentType: MessageContentType;
102
+ content?: string;
103
+ options?: MessageOptions;
104
+ topic?: string;
105
+ groupId?: string;
106
+ targetID?: string; // receipt: references original message ID
107
+ readIds?: string[]; // read receipt: array of message IDs
108
+ timestamp: number;
109
+ }
110
+
111
+ export type NknConnectionState = "disconnected" | "connecting" | "connected";
112
+
113
+ export interface DchatAccountConfig {
114
+ seed?: string; // hex wallet seed (64 chars)
115
+ keystoreJson?: string; // alternative: nkn-sdk keystore JSON
116
+ keystorePassword?: string;
117
+ numSubClients?: number; // default: 4
118
+ ipfsGateway?: string; // default: "64.225.88.71:80"
119
+ enabled?: boolean;
120
+ name?: string;
121
+ dm?: {
122
+ allowFrom?: Array<string | number>;
123
+ policy?: string;
124
+ };
125
+ }
126
+
127
+ export interface ResolvedDchatAccount {
128
+ accountId: string;
129
+ name: string;
130
+ enabled: boolean;
131
+ configured: boolean;
132
+ seed?: string;
133
+ numSubClients: number;
134
+ ipfsGateway: string;
135
+ config: DchatAccountConfig;
136
+ }
137
+
138
+ /** NKN seed RPC servers for client bootstrap. */
139
+ export const NKN_SEED_RPC_SERVERS = [
140
+ "http://seed.nkn.org:30003",
141
+ "http://mainnet-seed-0001.nkn.org:30003",
142
+ "http://mainnet-seed-0002.nkn.org:30003",
143
+ "http://mainnet-seed-0003.nkn.org:30003",
144
+ ];
@@ -0,0 +1,266 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { MessageData } from "./types.js";
3
+ import {
4
+ extractDmAddressFromSessionKey,
5
+ extractGroupIdFromSessionKey,
6
+ extractTopicFromSessionKey,
7
+ genTopicHash,
8
+ isControlMessage,
9
+ isDisplayableMessage,
10
+ nknToInbound,
11
+ parseNknPayload,
12
+ receiptToNkn,
13
+ stripNknSubClientPrefix,
14
+ textToNkn,
15
+ } from "./wire.js";
16
+
17
+ describe("genTopicHash", () => {
18
+ it("generates topic hash with dchat prefix", () => {
19
+ const hash = genTopicHash("general");
20
+ expect(hash).toMatch(/^dchat[0-9a-f]{40}$/);
21
+ });
22
+
23
+ it("strips leading # characters", () => {
24
+ expect(genTopicHash("#general")).toBe(genTopicHash("general"));
25
+ expect(genTopicHash("##general")).toBe(genTopicHash("general"));
26
+ });
27
+
28
+ it("produces consistent hashes", () => {
29
+ expect(genTopicHash("d-chat")).toBe(genTopicHash("d-chat"));
30
+ });
31
+
32
+ it("produces different hashes for different topics", () => {
33
+ expect(genTopicHash("alpha")).not.toBe(genTopicHash("beta"));
34
+ });
35
+ });
36
+
37
+ describe("parseNknPayload", () => {
38
+ it("parses valid JSON message", () => {
39
+ const msg: MessageData = {
40
+ id: "test-123",
41
+ contentType: "text",
42
+ content: "hello",
43
+ timestamp: Date.now(),
44
+ };
45
+ const result = parseNknPayload(JSON.stringify(msg));
46
+ expect(result).toEqual(msg);
47
+ });
48
+
49
+ it("returns null for invalid JSON", () => {
50
+ expect(parseNknPayload("not json")).toBeNull();
51
+ });
52
+
53
+ it("returns null for missing required fields", () => {
54
+ expect(parseNknPayload(JSON.stringify({ id: "test" }))).toBeNull();
55
+ expect(parseNknPayload(JSON.stringify({ contentType: "text" }))).toBeNull();
56
+ });
57
+
58
+ it("returns null for non-object values", () => {
59
+ expect(parseNknPayload(JSON.stringify(42))).toBeNull();
60
+ expect(parseNknPayload(JSON.stringify(null))).toBeNull();
61
+ });
62
+ });
63
+
64
+ describe("isControlMessage / isDisplayableMessage", () => {
65
+ it("identifies control messages", () => {
66
+ expect(isControlMessage("receipt")).toBe(true);
67
+ expect(isControlMessage("read")).toBe(true);
68
+ expect(isControlMessage("contact")).toBe(true);
69
+ expect(isControlMessage("topic:subscribe")).toBe(true);
70
+ expect(isControlMessage("discovery:broadcast")).toBe(true);
71
+ });
72
+
73
+ it("identifies displayable messages", () => {
74
+ expect(isDisplayableMessage("text")).toBe(true);
75
+ expect(isDisplayableMessage("textExtension")).toBe(true);
76
+ expect(isDisplayableMessage("ipfs")).toBe(true);
77
+ expect(isDisplayableMessage("audio")).toBe(true);
78
+ });
79
+
80
+ it("control messages are not displayable", () => {
81
+ expect(isDisplayableMessage("receipt")).toBe(false);
82
+ expect(isControlMessage("text")).toBe(false);
83
+ });
84
+ });
85
+
86
+ describe("nknToInbound", () => {
87
+ const selfAddr = "self-address-abc123";
88
+
89
+ it("translates direct text message", () => {
90
+ const msg: MessageData = {
91
+ id: "msg-1",
92
+ contentType: "text",
93
+ content: "Hello from NKN",
94
+ timestamp: Date.now(),
95
+ };
96
+ const result = nknToInbound("sender-addr-xyz789", msg, selfAddr);
97
+ expect(result).not.toBeNull();
98
+ expect(result!.body).toBe("Hello from NKN");
99
+ expect(result!.chatType).toBe("direct");
100
+ expect(result!.sessionKey).toBe("dchat:dm:sender-addr-xyz789");
101
+ expect(result!.senderId).toBe("sender-addr-xyz789");
102
+ });
103
+
104
+ it("scopes DM session key to account identity", () => {
105
+ const msg: MessageData = {
106
+ id: "msg-acct",
107
+ contentType: "text",
108
+ content: "multi-account test",
109
+ timestamp: Date.now(),
110
+ };
111
+ const result = nknToInbound("sender-addr", msg, selfAddr, { accountId: "work" });
112
+ expect(result!.sessionKey).toBe("dchat:work:dm:sender-addr");
113
+
114
+ // default account omits the account prefix for backwards compat
115
+ const resultDefault = nknToInbound("sender-addr", msg, selfAddr, { accountId: "default" });
116
+ expect(resultDefault!.sessionKey).toBe("dchat:dm:sender-addr");
117
+
118
+ // no accountId also omits prefix
119
+ const resultNone = nknToInbound("sender-addr", msg, selfAddr);
120
+ expect(resultNone!.sessionKey).toBe("dchat:dm:sender-addr");
121
+ });
122
+
123
+ it("translates topic message", () => {
124
+ const msg: MessageData = {
125
+ id: "msg-2",
126
+ contentType: "text",
127
+ content: "Hello topic",
128
+ topic: "general",
129
+ timestamp: Date.now(),
130
+ };
131
+ const result = nknToInbound("sender-addr", msg, selfAddr);
132
+ expect(result).not.toBeNull();
133
+ expect(result!.chatType).toBe("group");
134
+ expect(result!.sessionKey).toBe("dchat:topic:general");
135
+ expect(result!.groupSubject).toBe("#general");
136
+ });
137
+
138
+ it("translates IPFS image message", () => {
139
+ const msg: MessageData = {
140
+ id: "msg-3",
141
+ contentType: "ipfs",
142
+ content: "QmXyz...",
143
+ options: { fileType: 1, ipfsHash: "QmXyz..." },
144
+ timestamp: Date.now(),
145
+ };
146
+ const result = nknToInbound("sender", msg, selfAddr);
147
+ expect(result).not.toBeNull();
148
+ expect(result!.body).toBe("[Image]");
149
+ expect(result!.ipfsHash).toBe("QmXyz...");
150
+ });
151
+
152
+ it("translates IPFS file message", () => {
153
+ const msg: MessageData = {
154
+ id: "msg-4",
155
+ contentType: "ipfs",
156
+ content: "QmAbc...",
157
+ options: { fileType: 0, fileName: "report.pdf" },
158
+ timestamp: Date.now(),
159
+ };
160
+ const result = nknToInbound("sender", msg, selfAddr);
161
+ expect(result!.body).toBe("[File: report.pdf]");
162
+ });
163
+
164
+ it("translates audio message", () => {
165
+ const msg: MessageData = {
166
+ id: "msg-5",
167
+ contentType: "audio",
168
+ content: "base64data...",
169
+ timestamp: Date.now(),
170
+ };
171
+ const result = nknToInbound("sender", msg, selfAddr);
172
+ expect(result!.body).toBe("[Voice Message]");
173
+ });
174
+
175
+ it("returns null for control messages", () => {
176
+ const receipt: MessageData = {
177
+ id: "msg-6",
178
+ contentType: "receipt",
179
+ targetID: "msg-1",
180
+ timestamp: Date.now(),
181
+ };
182
+ expect(nknToInbound("sender", receipt, selfAddr)).toBeNull();
183
+
184
+ const readReceipt: MessageData = {
185
+ id: "msg-7",
186
+ contentType: "read",
187
+ readIds: ["msg-1"],
188
+ timestamp: Date.now(),
189
+ };
190
+ expect(nknToInbound("sender", readReceipt, selfAddr)).toBeNull();
191
+ });
192
+
193
+ it("handles textExtension content type", () => {
194
+ const msg: MessageData = {
195
+ id: "msg-8",
196
+ contentType: "textExtension",
197
+ content: "burn after read message",
198
+ options: { deleteAfterSeconds: 3600 },
199
+ timestamp: Date.now(),
200
+ };
201
+ const result = nknToInbound("sender", msg, selfAddr);
202
+ expect(result!.body).toBe("burn after read message");
203
+ });
204
+ });
205
+
206
+ describe("textToNkn", () => {
207
+ it("creates text MessageData", () => {
208
+ const msg = textToNkn("Hello world");
209
+ expect(msg.contentType).toBe("text");
210
+ expect(msg.content).toBe("Hello world");
211
+ expect(msg.id).toBeTruthy();
212
+ expect(msg.timestamp).toBeGreaterThan(0);
213
+ expect(msg.topic).toBeUndefined();
214
+ });
215
+
216
+ it("sets topic field for topic messages", () => {
217
+ const msg = textToNkn("Hello topic", { topic: "general" });
218
+ expect(msg.topic).toBe("general");
219
+ });
220
+
221
+ it("sets groupId for group messages", () => {
222
+ const msg = textToNkn("Hello group", { groupId: "group-123" });
223
+ expect(msg.groupId).toBe("group-123");
224
+ });
225
+ });
226
+
227
+ describe("receiptToNkn", () => {
228
+ it("creates receipt MessageData", () => {
229
+ const msg = receiptToNkn("original-msg-id");
230
+ expect(msg.contentType).toBe("receipt");
231
+ expect(msg.targetID).toBe("original-msg-id");
232
+ });
233
+ });
234
+
235
+ describe("session key extractors", () => {
236
+ it("extracts topic from session key", () => {
237
+ expect(extractTopicFromSessionKey("dchat:topic:general")).toBe("general");
238
+ expect(extractTopicFromSessionKey("dchat:dm:addr")).toBeUndefined();
239
+ });
240
+
241
+ it("extracts group ID from session key", () => {
242
+ expect(extractGroupIdFromSessionKey("dchat:group:abc123")).toBe("abc123");
243
+ expect(extractGroupIdFromSessionKey("dchat:dm:addr")).toBeUndefined();
244
+ });
245
+
246
+ it("extracts DM address from session key", () => {
247
+ expect(extractDmAddressFromSessionKey("dchat:dm:some-nkn-addr")).toBe("some-nkn-addr");
248
+ expect(extractDmAddressFromSessionKey("dchat:topic:general")).toBeUndefined();
249
+ });
250
+
251
+ it("extracts DM address from account-scoped session key", () => {
252
+ expect(extractDmAddressFromSessionKey("dchat:work:dm:some-nkn-addr")).toBe("some-nkn-addr");
253
+ });
254
+ });
255
+
256
+ describe("stripNknSubClientPrefix", () => {
257
+ it("strips __N__. prefix", () => {
258
+ expect(stripNknSubClientPrefix("__0__.cd3530abcdef")).toBe("cd3530abcdef");
259
+ expect(stripNknSubClientPrefix("__3__.cd3530abcdef")).toBe("cd3530abcdef");
260
+ expect(stripNknSubClientPrefix("__12__.cd3530abcdef")).toBe("cd3530abcdef");
261
+ });
262
+
263
+ it("leaves plain addresses unchanged", () => {
264
+ expect(stripNknSubClientPrefix("cd3530abcdef")).toBe("cd3530abcdef");
265
+ });
266
+ });