@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.
- package/LICENSE +21 -0
- package/README.md +75 -0
- package/index.ts +17 -0
- package/openclaw.plugin.json +9 -0
- package/package.json +58 -0
- package/src/channel.ts +633 -0
- package/src/config-schema.ts +124 -0
- package/src/crypto.test.ts +95 -0
- package/src/crypto.ts +56 -0
- package/src/nkn-bus.ts +213 -0
- package/src/onboarding.ts +195 -0
- package/src/runtime.ts +14 -0
- package/src/seen-tracker.test.ts +81 -0
- package/src/seen-tracker.ts +56 -0
- package/src/types.ts +144 -0
- package/src/wire.test.ts +266 -0
- package/src/wire.ts +230 -0
- package/tsconfig.json +19 -0
- package/vitest.config.ts +7 -0
package/src/wire.ts
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import crypto from "crypto";
|
|
2
|
+
import {
|
|
3
|
+
CONTROL_CONTENT_TYPES,
|
|
4
|
+
DISPLAYABLE_CONTENT_TYPES,
|
|
5
|
+
type MessageContentType,
|
|
6
|
+
type MessageData,
|
|
7
|
+
type MessageOptions,
|
|
8
|
+
} from "./types.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Generate the NKN topic hash from a human-readable topic name.
|
|
12
|
+
* nMobile convention: strip leading '#', SHA-1 hash, hex-encode, prefix with "dchat".
|
|
13
|
+
*/
|
|
14
|
+
export function genTopicHash(topicName: string): string {
|
|
15
|
+
const cleaned = topicName.replace(/^#+/, "");
|
|
16
|
+
const hash = crypto.createHash("sha1").update(cleaned).digest("hex");
|
|
17
|
+
return "dchat" + hash;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Parse a raw NKN payload string into a MessageData object.
|
|
22
|
+
* Returns null if the payload is not valid JSON or missing required fields.
|
|
23
|
+
*/
|
|
24
|
+
export function parseNknPayload(raw: string): MessageData | null {
|
|
25
|
+
try {
|
|
26
|
+
const parsed = JSON.parse(raw);
|
|
27
|
+
if (!parsed || typeof parsed !== "object") return null;
|
|
28
|
+
if (!parsed.id || !parsed.contentType) return null;
|
|
29
|
+
return parsed as MessageData;
|
|
30
|
+
} catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Strip NKN MultiClient sub-client prefix (__N__.) from an address.
|
|
37
|
+
* e.g. "__0__.cd3530..." → "cd3530..."
|
|
38
|
+
*/
|
|
39
|
+
export function stripNknSubClientPrefix(addr: string): string {
|
|
40
|
+
return addr.replace(/^__\d+__\./, "");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Returns true if the content type is a control message (not forwarded to agent). */
|
|
44
|
+
export function isControlMessage(contentType: string): boolean {
|
|
45
|
+
return CONTROL_CONTENT_TYPES.has(contentType);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Returns true if the content type carries displayable content. */
|
|
49
|
+
export function isDisplayableMessage(contentType: string): boolean {
|
|
50
|
+
return DISPLAYABLE_CONTENT_TYPES.has(contentType);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface InboundMessageResult {
|
|
54
|
+
body: string;
|
|
55
|
+
chatType: "direct" | "group";
|
|
56
|
+
sessionKey: string;
|
|
57
|
+
senderId: string;
|
|
58
|
+
senderName: string;
|
|
59
|
+
groupSubject?: string;
|
|
60
|
+
ipfsHash?: string;
|
|
61
|
+
ipfsOptions?: MessageOptions;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Translate an inbound NKN MessageData to OpenClaw inbound context fields.
|
|
66
|
+
* Returns null for control messages that should not be forwarded.
|
|
67
|
+
*/
|
|
68
|
+
export function nknToInbound(
|
|
69
|
+
src: string,
|
|
70
|
+
msg: MessageData,
|
|
71
|
+
selfAddress: string,
|
|
72
|
+
opts?: { accountId?: string },
|
|
73
|
+
): InboundMessageResult | null {
|
|
74
|
+
if (isControlMessage(msg.contentType)) {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (!isDisplayableMessage(msg.contentType)) {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Determine chat type and session key
|
|
83
|
+
const hasTopic = Boolean(msg.topic);
|
|
84
|
+
const hasGroupId = Boolean(msg.groupId);
|
|
85
|
+
const chatType: "direct" | "group" = hasTopic || hasGroupId ? "group" : "direct";
|
|
86
|
+
|
|
87
|
+
let sessionKey: string;
|
|
88
|
+
let groupSubject: string | undefined;
|
|
89
|
+
if (hasTopic) {
|
|
90
|
+
sessionKey = `dchat:topic:${msg.topic}`;
|
|
91
|
+
groupSubject = `#${msg.topic}`;
|
|
92
|
+
} else if (hasGroupId) {
|
|
93
|
+
sessionKey = `dchat:group:${msg.groupId}`;
|
|
94
|
+
groupSubject = msg.groupId;
|
|
95
|
+
} else {
|
|
96
|
+
// Include account identity to prevent session key collisions in multi-account setups
|
|
97
|
+
const acct = opts?.accountId;
|
|
98
|
+
sessionKey = acct && acct !== "default" ? `dchat:${acct}:dm:${src}` : `dchat:dm:${src}`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Extract body text
|
|
102
|
+
let body: string;
|
|
103
|
+
const ct = msg.contentType;
|
|
104
|
+
|
|
105
|
+
if (ct === "text" || ct === "textExtension") {
|
|
106
|
+
body = msg.content ?? "";
|
|
107
|
+
} else if (ct === "ipfs") {
|
|
108
|
+
const fileType = msg.options?.fileType;
|
|
109
|
+
const isImage = fileType === 1 || fileType === "1" || fileType === undefined;
|
|
110
|
+
const isAudio = fileType === 2 || fileType === "2";
|
|
111
|
+
const isFile = fileType === 0 || fileType === "0";
|
|
112
|
+
if (isImage) {
|
|
113
|
+
body = "[Image]";
|
|
114
|
+
} else if (isAudio) {
|
|
115
|
+
body = "[Audio]";
|
|
116
|
+
} else if (isFile) {
|
|
117
|
+
const fileName = msg.options?.fileName;
|
|
118
|
+
body = fileName ? `[File: ${fileName}]` : "[File]";
|
|
119
|
+
} else {
|
|
120
|
+
body = "[IPFS content]";
|
|
121
|
+
}
|
|
122
|
+
} else if (ct === "audio") {
|
|
123
|
+
body = "[Voice Message]";
|
|
124
|
+
} else if (ct === "image") {
|
|
125
|
+
body = "[Image]";
|
|
126
|
+
} else if (ct === "video") {
|
|
127
|
+
body = "[Video]";
|
|
128
|
+
} else if (ct === "file") {
|
|
129
|
+
body = "[File]";
|
|
130
|
+
} else {
|
|
131
|
+
body = msg.content ?? "";
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Short sender name: first 8 chars of NKN address
|
|
135
|
+
const senderName = src.length > 16 ? src.substring(0, 8) + "..." : src;
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
body,
|
|
139
|
+
chatType,
|
|
140
|
+
sessionKey,
|
|
141
|
+
senderId: src,
|
|
142
|
+
senderName,
|
|
143
|
+
groupSubject,
|
|
144
|
+
ipfsHash: msg.options?.ipfsHash || (ct === "ipfs" ? msg.content : undefined),
|
|
145
|
+
ipfsOptions: ct === "ipfs" || ct === "audio" ? msg.options : undefined,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Build an outbound NKN MessageData from text.
|
|
151
|
+
* Sets appropriate content type and topic/groupId fields.
|
|
152
|
+
*/
|
|
153
|
+
export function textToNkn(
|
|
154
|
+
text: string,
|
|
155
|
+
opts?: {
|
|
156
|
+
topic?: string;
|
|
157
|
+
groupId?: string;
|
|
158
|
+
},
|
|
159
|
+
): MessageData {
|
|
160
|
+
return {
|
|
161
|
+
id: crypto.randomUUID(),
|
|
162
|
+
contentType: "text" as MessageContentType,
|
|
163
|
+
content: text,
|
|
164
|
+
...(opts?.topic ? { topic: opts.topic } : {}),
|
|
165
|
+
...(opts?.groupId ? { groupId: opts.groupId } : {}),
|
|
166
|
+
timestamp: Date.now(),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Build an outbound NKN MessageData for IPFS media (image/file).
|
|
172
|
+
*/
|
|
173
|
+
export function ipfsToNkn(
|
|
174
|
+
options: MessageOptions,
|
|
175
|
+
opts?: {
|
|
176
|
+
topic?: string;
|
|
177
|
+
groupId?: string;
|
|
178
|
+
},
|
|
179
|
+
): MessageData {
|
|
180
|
+
return {
|
|
181
|
+
id: crypto.randomUUID(),
|
|
182
|
+
contentType: "ipfs" as MessageContentType,
|
|
183
|
+
content: options.ipfsHash,
|
|
184
|
+
options,
|
|
185
|
+
...(opts?.topic ? { topic: opts.topic } : {}),
|
|
186
|
+
...(opts?.groupId ? { groupId: opts.groupId } : {}),
|
|
187
|
+
timestamp: Date.now(),
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Build a delivery receipt MessageData.
|
|
193
|
+
*/
|
|
194
|
+
export function receiptToNkn(targetMessageId: string): MessageData {
|
|
195
|
+
return {
|
|
196
|
+
id: crypto.randomUUID(),
|
|
197
|
+
contentType: "receipt" as MessageContentType,
|
|
198
|
+
targetID: targetMessageId,
|
|
199
|
+
timestamp: Date.now(),
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Extract a topic name from a session key.
|
|
205
|
+
* dchat:topic:general -> "general"
|
|
206
|
+
* dchat:dm:addr -> undefined
|
|
207
|
+
*/
|
|
208
|
+
export function extractTopicFromSessionKey(sessionKey: string): string | undefined {
|
|
209
|
+
const match = sessionKey.match(/^dchat:topic:(.+)$/);
|
|
210
|
+
return match?.[1];
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Extract a group ID from a session key.
|
|
215
|
+
* dchat:group:abc123 -> "abc123"
|
|
216
|
+
*/
|
|
217
|
+
export function extractGroupIdFromSessionKey(sessionKey: string): string | undefined {
|
|
218
|
+
const match = sessionKey.match(/^dchat:group:(.+)$/);
|
|
219
|
+
return match?.[1];
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Extract a direct message address from a session key.
|
|
224
|
+
* dchat:dm:addr -> "addr"
|
|
225
|
+
* dchat:<accountId>:dm:addr -> "addr"
|
|
226
|
+
*/
|
|
227
|
+
export function extractDmAddressFromSessionKey(sessionKey: string): string | undefined {
|
|
228
|
+
const match = sessionKey.match(/^dchat:(?:[^:]+:)?dm:(.+)$/);
|
|
229
|
+
return match?.[1];
|
|
230
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"esModuleInterop": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"forceConsistentCasingInFileNames": true,
|
|
10
|
+
"resolveJsonModule": true,
|
|
11
|
+
"declaration": true,
|
|
12
|
+
"declarationMap": true,
|
|
13
|
+
"sourceMap": true,
|
|
14
|
+
"outDir": "dist",
|
|
15
|
+
"rootDir": "."
|
|
16
|
+
},
|
|
17
|
+
"include": ["index.ts", "src/**/*.ts"],
|
|
18
|
+
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
|
19
|
+
}
|