@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/channel.ts
ADDED
|
@@ -0,0 +1,633 @@
|
|
|
1
|
+
import {
|
|
2
|
+
applyAccountNameToChannelSection,
|
|
3
|
+
buildChannelConfigSchema,
|
|
4
|
+
createScopedPairingAccess,
|
|
5
|
+
DEFAULT_ACCOUNT_ID,
|
|
6
|
+
deleteAccountFromConfigSection,
|
|
7
|
+
formatPairingApproveHint,
|
|
8
|
+
normalizeAccountId,
|
|
9
|
+
resolveSenderCommandAuthorization,
|
|
10
|
+
setAccountEnabledInConfigSection,
|
|
11
|
+
type ChannelPlugin,
|
|
12
|
+
} from "openclaw/plugin-sdk";
|
|
13
|
+
import {
|
|
14
|
+
type CoreConfig,
|
|
15
|
+
DchatConfigSchema,
|
|
16
|
+
listDchatAccountIds,
|
|
17
|
+
resolveDchatAccount,
|
|
18
|
+
resolveDchatAccountConfig,
|
|
19
|
+
resolveDefaultDchatAccountId,
|
|
20
|
+
} from "./config-schema.js";
|
|
21
|
+
import { NknBus } from "./nkn-bus.js";
|
|
22
|
+
import { dchatOnboardingAdapter } from "./onboarding.js";
|
|
23
|
+
import { getDchatRuntime } from "./runtime.js";
|
|
24
|
+
import { SeenTracker } from "./seen-tracker.js";
|
|
25
|
+
import type { ResolvedDchatAccount } from "./types.js";
|
|
26
|
+
import {
|
|
27
|
+
extractDmAddressFromSessionKey,
|
|
28
|
+
extractGroupIdFromSessionKey,
|
|
29
|
+
extractTopicFromSessionKey,
|
|
30
|
+
genTopicHash,
|
|
31
|
+
nknToInbound,
|
|
32
|
+
parseNknPayload,
|
|
33
|
+
receiptToNkn,
|
|
34
|
+
stripNknSubClientPrefix,
|
|
35
|
+
textToNkn,
|
|
36
|
+
} from "./wire.js";
|
|
37
|
+
|
|
38
|
+
// Per-account NKN bus instances and dedup trackers, keyed by accountId
|
|
39
|
+
const busMap = new Map<string, NknBus>();
|
|
40
|
+
const seenMap = new Map<string, SeenTracker>();
|
|
41
|
+
|
|
42
|
+
const meta = {
|
|
43
|
+
id: "dchat",
|
|
44
|
+
label: "D-Chat / nMobile",
|
|
45
|
+
selectionLabel: "D-Chat (plugin)",
|
|
46
|
+
docsPath: "/channels/dchat",
|
|
47
|
+
docsLabel: "dchat",
|
|
48
|
+
blurb: "decentralized E2E encrypted messaging over the NKN relay network.",
|
|
49
|
+
order: 80,
|
|
50
|
+
quickstartAllowFrom: true,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
function getBusForAccount(accountId: string): NknBus | undefined {
|
|
54
|
+
return busMap.get(accountId);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const dchatPlugin: ChannelPlugin<ResolvedDchatAccount> = {
|
|
58
|
+
id: "dchat",
|
|
59
|
+
meta,
|
|
60
|
+
onboarding: dchatOnboardingAdapter,
|
|
61
|
+
capabilities: {
|
|
62
|
+
chatTypes: ["direct", "group"],
|
|
63
|
+
media: false, // IPFS media support is a stretch goal for v2
|
|
64
|
+
threads: false,
|
|
65
|
+
reactions: false,
|
|
66
|
+
polls: false,
|
|
67
|
+
},
|
|
68
|
+
reload: { configPrefixes: ["channels.dchat"] },
|
|
69
|
+
configSchema: buildChannelConfigSchema(DchatConfigSchema),
|
|
70
|
+
pairing: {
|
|
71
|
+
idLabel: "nknAddress",
|
|
72
|
+
normalizeAllowEntry: (entry) => entry.replace(/^dchat:/i, ""),
|
|
73
|
+
},
|
|
74
|
+
config: {
|
|
75
|
+
listAccountIds: (cfg) => listDchatAccountIds(cfg as CoreConfig),
|
|
76
|
+
resolveAccount: (cfg, accountId) => resolveDchatAccount({ cfg: cfg as CoreConfig, accountId }),
|
|
77
|
+
defaultAccountId: (cfg) => resolveDefaultDchatAccountId(cfg as CoreConfig),
|
|
78
|
+
setAccountEnabled: ({ cfg, accountId, enabled }) =>
|
|
79
|
+
setAccountEnabledInConfigSection({
|
|
80
|
+
cfg: cfg as CoreConfig,
|
|
81
|
+
sectionKey: "dchat",
|
|
82
|
+
accountId,
|
|
83
|
+
enabled,
|
|
84
|
+
allowTopLevel: true,
|
|
85
|
+
}),
|
|
86
|
+
deleteAccount: ({ cfg, accountId }) =>
|
|
87
|
+
deleteAccountFromConfigSection({
|
|
88
|
+
cfg: cfg as CoreConfig,
|
|
89
|
+
sectionKey: "dchat",
|
|
90
|
+
accountId,
|
|
91
|
+
clearBaseFields: [
|
|
92
|
+
"name",
|
|
93
|
+
"seed",
|
|
94
|
+
"keystoreJson",
|
|
95
|
+
"keystorePassword",
|
|
96
|
+
"numSubClients",
|
|
97
|
+
"ipfsGateway",
|
|
98
|
+
],
|
|
99
|
+
}),
|
|
100
|
+
isConfigured: (account) => account.configured,
|
|
101
|
+
describeAccount: (account) => ({
|
|
102
|
+
accountId: account.accountId,
|
|
103
|
+
name: account.name,
|
|
104
|
+
enabled: account.enabled,
|
|
105
|
+
configured: account.configured,
|
|
106
|
+
}),
|
|
107
|
+
resolveAllowFrom: ({ cfg, accountId }) => {
|
|
108
|
+
const dchatConfig = resolveDchatAccountConfig({ cfg: cfg as CoreConfig, accountId });
|
|
109
|
+
return (dchatConfig.dm?.allowFrom ?? []).map((entry: string | number) => String(entry));
|
|
110
|
+
},
|
|
111
|
+
formatAllowFrom: ({ allowFrom }) =>
|
|
112
|
+
allowFrom.map((entry) => String(entry).replace(/^dchat:/i, "")),
|
|
113
|
+
},
|
|
114
|
+
security: {
|
|
115
|
+
resolveDmPolicy: ({ account }) => {
|
|
116
|
+
const accountId = account.accountId;
|
|
117
|
+
const prefix =
|
|
118
|
+
accountId && accountId !== "default"
|
|
119
|
+
? `channels.dchat.accounts.${accountId}.dm`
|
|
120
|
+
: "channels.dchat.dm";
|
|
121
|
+
return {
|
|
122
|
+
policy: account.config.dm?.policy ?? "pairing",
|
|
123
|
+
allowFrom: account.config.dm?.allowFrom ?? [],
|
|
124
|
+
policyPath: `${prefix}.policy`,
|
|
125
|
+
allowFromPath: `${prefix}.allowFrom`,
|
|
126
|
+
approveHint: formatPairingApproveHint("dchat"),
|
|
127
|
+
normalizeEntry: (raw: string) => raw.replace(/^dchat:/i, ""),
|
|
128
|
+
};
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
messaging: {
|
|
132
|
+
normalizeTarget: (raw) => {
|
|
133
|
+
let normalized = raw.trim();
|
|
134
|
+
if (!normalized) return undefined;
|
|
135
|
+
if (normalized.toLowerCase().startsWith("dchat:")) {
|
|
136
|
+
normalized = normalized.slice("dchat:".length).trim();
|
|
137
|
+
}
|
|
138
|
+
return normalized || undefined;
|
|
139
|
+
},
|
|
140
|
+
targetResolver: {
|
|
141
|
+
looksLikeId: (raw) => {
|
|
142
|
+
const trimmed = raw.trim();
|
|
143
|
+
if (!trimmed) return false;
|
|
144
|
+
// NKN addresses are hex public keys (64+ chars)
|
|
145
|
+
if (/^[0-9a-f]{64}/i.test(trimmed)) return true;
|
|
146
|
+
// topic: or group: prefix
|
|
147
|
+
if (/^(topic|group):/i.test(trimmed)) return true;
|
|
148
|
+
return false;
|
|
149
|
+
},
|
|
150
|
+
hint: "<nkn-address|topic:name>",
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
outbound: {
|
|
154
|
+
deliveryMode: "direct",
|
|
155
|
+
chunker: (text, limit) => getDchatRuntime().channel.text.chunkMarkdownText(text, limit),
|
|
156
|
+
chunkerMode: "text",
|
|
157
|
+
textChunkLimit: 4000,
|
|
158
|
+
sendText: async ({ to, text, accountId }) => {
|
|
159
|
+
const resolvedAccountId =
|
|
160
|
+
accountId ??
|
|
161
|
+
resolveDefaultDchatAccountId(getDchatRuntime().config.loadConfig() as CoreConfig);
|
|
162
|
+
const bus = getBusForAccount(resolvedAccountId);
|
|
163
|
+
if (!bus) {
|
|
164
|
+
throw new Error(`D-Chat account "${resolvedAccountId}" not connected`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Parse the target: could be a direct address or topic:name
|
|
168
|
+
const topicName = to.startsWith("topic:") ? to.slice("topic:".length) : undefined;
|
|
169
|
+
const groupId = to.startsWith("group:") ? to.slice("group:".length) : undefined;
|
|
170
|
+
|
|
171
|
+
const msgData = textToNkn(text, { topic: topicName, groupId });
|
|
172
|
+
const payload = JSON.stringify(msgData);
|
|
173
|
+
|
|
174
|
+
if (topicName) {
|
|
175
|
+
// Topic: send to all subscribers
|
|
176
|
+
const topicHash = genTopicHash(topicName);
|
|
177
|
+
const subscribers = await bus.getSubscribers(topicHash);
|
|
178
|
+
const selfAddr = bus.getAddress();
|
|
179
|
+
const dests = subscribers.filter((addr) => addr !== selfAddr);
|
|
180
|
+
if (dests.length > 0) {
|
|
181
|
+
bus.sendToMultiple(dests, payload);
|
|
182
|
+
}
|
|
183
|
+
} else if (groupId) {
|
|
184
|
+
// Private group: not yet supported, send to the group ID as direct
|
|
185
|
+
const dest = to.replace(/^group:/i, "");
|
|
186
|
+
bus.sendNoReply(dest, payload);
|
|
187
|
+
} else {
|
|
188
|
+
// Direct message: extract address from "dchat:addr" or raw address
|
|
189
|
+
const dest = to.replace(/^dchat:/i, "");
|
|
190
|
+
await bus.send(dest, payload);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
channel: "dchat",
|
|
195
|
+
messageId: msgData.id,
|
|
196
|
+
};
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
setup: {
|
|
200
|
+
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
|
|
201
|
+
applyAccountName: ({ cfg, accountId, name }) =>
|
|
202
|
+
applyAccountNameToChannelSection({
|
|
203
|
+
cfg: cfg as CoreConfig,
|
|
204
|
+
channelKey: "dchat",
|
|
205
|
+
accountId,
|
|
206
|
+
name,
|
|
207
|
+
}),
|
|
208
|
+
validateInput: ({ input }) => {
|
|
209
|
+
if (input.useEnv) {
|
|
210
|
+
return "D-Chat does not support --use-env; provide a wallet seed via --access-token";
|
|
211
|
+
}
|
|
212
|
+
// Wallet seed is passed via accessToken field
|
|
213
|
+
const seed = input.accessToken?.trim();
|
|
214
|
+
if (!seed) {
|
|
215
|
+
return "D-Chat requires a wallet seed (--access-token with 64-char hex string)";
|
|
216
|
+
}
|
|
217
|
+
if (!/^[0-9a-f]{64}$/i.test(seed)) {
|
|
218
|
+
return "Wallet seed must be a 64-character hex string";
|
|
219
|
+
}
|
|
220
|
+
return null;
|
|
221
|
+
},
|
|
222
|
+
applyAccountConfig: ({ cfg, input, accountId }) => {
|
|
223
|
+
const resolvedAccountId = accountId ?? DEFAULT_ACCOUNT_ID;
|
|
224
|
+
const namedConfig = applyAccountNameToChannelSection({
|
|
225
|
+
cfg: cfg as CoreConfig,
|
|
226
|
+
channelKey: "dchat",
|
|
227
|
+
accountId: resolvedAccountId,
|
|
228
|
+
name: input.name,
|
|
229
|
+
});
|
|
230
|
+
const seed = input.accessToken?.trim();
|
|
231
|
+
if (resolvedAccountId === DEFAULT_ACCOUNT_ID) {
|
|
232
|
+
return {
|
|
233
|
+
...namedConfig,
|
|
234
|
+
channels: {
|
|
235
|
+
...namedConfig.channels,
|
|
236
|
+
dchat: {
|
|
237
|
+
...(namedConfig as CoreConfig).channels?.dchat,
|
|
238
|
+
enabled: true,
|
|
239
|
+
...(seed ? { seed } : {}),
|
|
240
|
+
},
|
|
241
|
+
},
|
|
242
|
+
} as CoreConfig;
|
|
243
|
+
}
|
|
244
|
+
return {
|
|
245
|
+
...namedConfig,
|
|
246
|
+
channels: {
|
|
247
|
+
...namedConfig.channels,
|
|
248
|
+
dchat: {
|
|
249
|
+
...(namedConfig as CoreConfig).channels?.dchat,
|
|
250
|
+
enabled: true,
|
|
251
|
+
accounts: {
|
|
252
|
+
...(namedConfig as CoreConfig).channels?.dchat?.accounts,
|
|
253
|
+
[resolvedAccountId]: {
|
|
254
|
+
...(namedConfig as CoreConfig).channels?.dchat?.accounts?.[resolvedAccountId],
|
|
255
|
+
enabled: true,
|
|
256
|
+
...(seed ? { seed } : {}),
|
|
257
|
+
},
|
|
258
|
+
},
|
|
259
|
+
},
|
|
260
|
+
},
|
|
261
|
+
} as CoreConfig;
|
|
262
|
+
},
|
|
263
|
+
},
|
|
264
|
+
status: {
|
|
265
|
+
defaultRuntime: {
|
|
266
|
+
accountId: DEFAULT_ACCOUNT_ID,
|
|
267
|
+
running: false,
|
|
268
|
+
lastStartAt: null,
|
|
269
|
+
lastStopAt: null,
|
|
270
|
+
lastError: null,
|
|
271
|
+
},
|
|
272
|
+
buildChannelSummary: ({ snapshot }) => ({
|
|
273
|
+
configured: snapshot.configured ?? false,
|
|
274
|
+
running: snapshot.running ?? false,
|
|
275
|
+
nknAddress: snapshot.baseUrl ?? null,
|
|
276
|
+
lastStartAt: snapshot.lastStartAt ?? null,
|
|
277
|
+
lastStopAt: snapshot.lastStopAt ?? null,
|
|
278
|
+
lastError: snapshot.lastError ?? null,
|
|
279
|
+
}),
|
|
280
|
+
buildAccountSnapshot: ({ account, runtime }) => ({
|
|
281
|
+
accountId: account.accountId,
|
|
282
|
+
name: account.name,
|
|
283
|
+
enabled: account.enabled,
|
|
284
|
+
configured: account.configured,
|
|
285
|
+
running: runtime?.running ?? false,
|
|
286
|
+
lastStartAt: runtime?.lastStartAt ?? null,
|
|
287
|
+
lastStopAt: runtime?.lastStopAt ?? null,
|
|
288
|
+
lastError: runtime?.lastError ?? null,
|
|
289
|
+
lastInboundAt: runtime?.lastInboundAt ?? null,
|
|
290
|
+
lastOutboundAt: runtime?.lastOutboundAt ?? null,
|
|
291
|
+
}),
|
|
292
|
+
collectStatusIssues: (accounts) =>
|
|
293
|
+
accounts.flatMap((account) => {
|
|
294
|
+
const lastError = typeof account.lastError === "string" ? account.lastError.trim() : "";
|
|
295
|
+
if (!lastError) return [];
|
|
296
|
+
return [
|
|
297
|
+
{
|
|
298
|
+
channel: "dchat",
|
|
299
|
+
accountId: account.accountId,
|
|
300
|
+
kind: "runtime",
|
|
301
|
+
message: `Channel error: ${lastError}`,
|
|
302
|
+
},
|
|
303
|
+
];
|
|
304
|
+
}),
|
|
305
|
+
},
|
|
306
|
+
gateway: {
|
|
307
|
+
startAccount: async (ctx) => {
|
|
308
|
+
const account = ctx.account;
|
|
309
|
+
const core = getDchatRuntime();
|
|
310
|
+
const logger = core.logging.getChildLogger({ module: "dchat" });
|
|
311
|
+
|
|
312
|
+
if (!account.seed) {
|
|
313
|
+
logger.warn(`[${account.accountId}] no seed configured, skipping`);
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
ctx.setStatus({ accountId: account.accountId });
|
|
318
|
+
logger.info(`[${account.accountId}] connecting to NKN relay network...`);
|
|
319
|
+
|
|
320
|
+
const bus = new NknBus();
|
|
321
|
+
busMap.set(account.accountId, bus);
|
|
322
|
+
const seenTracker = new SeenTracker();
|
|
323
|
+
seenMap.set(account.accountId, seenTracker);
|
|
324
|
+
|
|
325
|
+
try {
|
|
326
|
+
const address = await bus.connect(
|
|
327
|
+
{ seed: account.seed, numSubClients: account.numSubClients },
|
|
328
|
+
ctx.abortSignal,
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
ctx.setStatus({
|
|
332
|
+
accountId: account.accountId,
|
|
333
|
+
baseUrl: address,
|
|
334
|
+
running: true,
|
|
335
|
+
lastStartAt: Date.now(),
|
|
336
|
+
});
|
|
337
|
+
logger.info(`[${account.accountId}] connected as ${address}`);
|
|
338
|
+
|
|
339
|
+
// Register inbound message handler
|
|
340
|
+
bus.onMessage((rawSrc, rawPayload) => {
|
|
341
|
+
void (async () => {
|
|
342
|
+
try {
|
|
343
|
+
// Strip NKN MultiClient sub-client prefix (__N__.) so addresses match allowlists
|
|
344
|
+
const src = stripNknSubClientPrefix(rawSrc);
|
|
345
|
+
const msg = parseNknPayload(rawPayload);
|
|
346
|
+
if (!msg) {
|
|
347
|
+
logger.warn(`[${account.accountId}] unparseable NKN payload from ${src}`);
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Dedup
|
|
352
|
+
if (seenTracker.checkAndMark(msg.id)) {
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const selfAddress = bus.getAddress();
|
|
357
|
+
if (!selfAddress) return;
|
|
358
|
+
|
|
359
|
+
const inbound = nknToInbound(src, msg, selfAddress, {
|
|
360
|
+
accountId: account.accountId,
|
|
361
|
+
});
|
|
362
|
+
if (!inbound) {
|
|
363
|
+
// Control message (receipt, read, topic:subscribe, etc.) — skip
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Send delivery receipt (fire-and-forget)
|
|
368
|
+
try {
|
|
369
|
+
const receipt = receiptToNkn(msg.id);
|
|
370
|
+
bus.sendNoReply(src, JSON.stringify(receipt));
|
|
371
|
+
} catch {
|
|
372
|
+
// receipt send failure is non-fatal
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Load config for reply dispatch
|
|
376
|
+
const cfg = core.config.loadConfig();
|
|
377
|
+
|
|
378
|
+
const pairing = createScopedPairingAccess({
|
|
379
|
+
core,
|
|
380
|
+
channel: "dchat",
|
|
381
|
+
accountId: account.accountId,
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
// Resolve command authorization for slash commands (/status, /stop, etc.)
|
|
385
|
+
const isGroup = inbound.chatType === "group";
|
|
386
|
+
const dmPolicy = account.config.dm?.policy ?? "pairing";
|
|
387
|
+
const configAllowFrom = (account.config.dm?.allowFrom ?? []).map((v) => String(v));
|
|
388
|
+
|
|
389
|
+
const isSenderAllowed = (senderId: string, allowFrom: string[]) => {
|
|
390
|
+
const lower = senderId.toLowerCase();
|
|
391
|
+
return allowFrom.some(
|
|
392
|
+
(entry) => String(entry).toLowerCase() === lower || entry === "*",
|
|
393
|
+
);
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
const { senderAllowedForCommands, commandAuthorized } =
|
|
397
|
+
await resolveSenderCommandAuthorization({
|
|
398
|
+
cfg,
|
|
399
|
+
rawBody: inbound.body,
|
|
400
|
+
isGroup,
|
|
401
|
+
dmPolicy,
|
|
402
|
+
configuredAllowFrom: configAllowFrom,
|
|
403
|
+
senderId: src,
|
|
404
|
+
isSenderAllowed,
|
|
405
|
+
readAllowFromStore: () => pairing.readAllowFromStore(),
|
|
406
|
+
shouldComputeCommandAuthorized: (body, c) =>
|
|
407
|
+
core.channel.commands.shouldComputeCommandAuthorized(body, c),
|
|
408
|
+
resolveCommandAuthorizedFromAuthorizers: (params) =>
|
|
409
|
+
core.channel.commands.resolveCommandAuthorizedFromAuthorizers(params),
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
// ── DM policy enforcement ──
|
|
413
|
+
if (!isGroup) {
|
|
414
|
+
if (dmPolicy === "disabled") {
|
|
415
|
+
logger.info(`[${account.accountId}] drop DM sender=${src} (dmPolicy=disabled)`);
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
if (dmPolicy !== "open") {
|
|
419
|
+
const storeAllowFrom =
|
|
420
|
+
dmPolicy === "allowlist"
|
|
421
|
+
? []
|
|
422
|
+
: await pairing.readAllowFromStore().catch(() => [] as string[]);
|
|
423
|
+
const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom.map(String)];
|
|
424
|
+
if (!isSenderAllowed(src, effectiveAllowFrom)) {
|
|
425
|
+
if (dmPolicy === "pairing") {
|
|
426
|
+
const { code, created } = await pairing.upsertPairingRequest({
|
|
427
|
+
id: src.toLowerCase(),
|
|
428
|
+
meta: { name: src },
|
|
429
|
+
});
|
|
430
|
+
if (created) {
|
|
431
|
+
try {
|
|
432
|
+
const reply = core.channel.pairing.buildPairingReply({
|
|
433
|
+
channel: "dchat",
|
|
434
|
+
idLine: `Your NKN address: ${src}`,
|
|
435
|
+
code,
|
|
436
|
+
});
|
|
437
|
+
bus.sendNoReply(src, JSON.stringify(textToNkn(reply)));
|
|
438
|
+
} catch {
|
|
439
|
+
// pairing reply send failure is non-fatal
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
logger.info(
|
|
444
|
+
`[${account.accountId}] drop DM sender=${src} (dmPolicy=${dmPolicy})`,
|
|
445
|
+
);
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Drop unauthorized control commands in groups
|
|
452
|
+
if (
|
|
453
|
+
isGroup &&
|
|
454
|
+
core.channel.commands.isControlCommandMessage(inbound.body, cfg) &&
|
|
455
|
+
commandAuthorized !== true
|
|
456
|
+
) {
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// Resolve agent route for multi-agent session key scoping
|
|
461
|
+
const route = core.channel.routing.resolveAgentRoute({
|
|
462
|
+
cfg,
|
|
463
|
+
channel: "dchat",
|
|
464
|
+
accountId: account.accountId,
|
|
465
|
+
peer: {
|
|
466
|
+
kind: isGroup ? "group" : "direct",
|
|
467
|
+
id: isGroup ? (inbound.groupSubject ?? "unknown") : src,
|
|
468
|
+
},
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
|
|
472
|
+
agentId: route.agentId,
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
|
|
476
|
+
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
|
|
477
|
+
storePath,
|
|
478
|
+
sessionKey: route.sessionKey,
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
const body = core.channel.reply.formatAgentEnvelope({
|
|
482
|
+
channel: "D-Chat",
|
|
483
|
+
from:
|
|
484
|
+
inbound.chatType === "direct"
|
|
485
|
+
? inbound.senderName
|
|
486
|
+
: (inbound.groupSubject ?? inbound.senderName),
|
|
487
|
+
timestamp: msg.timestamp,
|
|
488
|
+
previousTimestamp,
|
|
489
|
+
envelope: envelopeOptions,
|
|
490
|
+
body: inbound.body,
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
|
494
|
+
Body: body,
|
|
495
|
+
BodyForAgent: inbound.body,
|
|
496
|
+
RawBody: inbound.body,
|
|
497
|
+
CommandBody: inbound.body,
|
|
498
|
+
From:
|
|
499
|
+
inbound.chatType === "direct"
|
|
500
|
+
? `dchat:${src}`
|
|
501
|
+
: inbound.sessionKey.startsWith("dchat:group:")
|
|
502
|
+
? `dchat:group:${inbound.groupSubject ?? ""}`
|
|
503
|
+
: `dchat:topic:${inbound.groupSubject ?? ""}`,
|
|
504
|
+
To:
|
|
505
|
+
inbound.chatType === "direct"
|
|
506
|
+
? `dchat:${src}`
|
|
507
|
+
: inbound.sessionKey.startsWith("dchat:group:")
|
|
508
|
+
? `group:${inbound.groupSubject ?? ""}`
|
|
509
|
+
: `topic:${inbound.groupSubject ?? ""}`,
|
|
510
|
+
SessionKey: route.sessionKey,
|
|
511
|
+
AccountId: account.accountId,
|
|
512
|
+
ChatType: inbound.chatType === "direct" ? "direct" : "channel",
|
|
513
|
+
ConversationLabel:
|
|
514
|
+
inbound.chatType === "direct" ? inbound.senderName : (inbound.groupSubject ?? ""),
|
|
515
|
+
SenderName: inbound.senderName,
|
|
516
|
+
SenderId: inbound.senderId,
|
|
517
|
+
GroupSubject: inbound.groupSubject,
|
|
518
|
+
Provider: "dchat" as const,
|
|
519
|
+
Surface: "dchat" as const,
|
|
520
|
+
MessageSid: msg.id,
|
|
521
|
+
Timestamp: msg.timestamp,
|
|
522
|
+
CommandAuthorized: commandAuthorized,
|
|
523
|
+
CommandSource: "text" as const,
|
|
524
|
+
OriginatingChannel: "dchat" as const,
|
|
525
|
+
OriginatingTo:
|
|
526
|
+
inbound.chatType === "direct"
|
|
527
|
+
? `dchat:${src}`
|
|
528
|
+
: `topic:${inbound.groupSubject ?? ""}`,
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
// Record session
|
|
532
|
+
core.channel.session.recordInboundSession({
|
|
533
|
+
storePath,
|
|
534
|
+
sessionKey: route.sessionKey,
|
|
535
|
+
ctx: ctxPayload,
|
|
536
|
+
updateLastRoute:
|
|
537
|
+
inbound.chatType === "direct"
|
|
538
|
+
? {
|
|
539
|
+
sessionKey: route.sessionKey,
|
|
540
|
+
channel: "dchat",
|
|
541
|
+
to: `dchat:${src}`,
|
|
542
|
+
accountId: account.accountId,
|
|
543
|
+
}
|
|
544
|
+
: undefined,
|
|
545
|
+
onRecordError: (err) => {
|
|
546
|
+
logger.warn("failed updating session meta", {
|
|
547
|
+
error: String(err),
|
|
548
|
+
storePath,
|
|
549
|
+
sessionKey: inbound.sessionKey,
|
|
550
|
+
});
|
|
551
|
+
},
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
// Dispatch reply via standard pipeline
|
|
555
|
+
const { dispatcher, replyOptions, markDispatchIdle } =
|
|
556
|
+
core.channel.reply.createReplyDispatcherWithTyping({
|
|
557
|
+
deliver: async (payload) => {
|
|
558
|
+
// Deliver reply back to NKN
|
|
559
|
+
const replyText = payload.text ?? "";
|
|
560
|
+
if (!replyText) return;
|
|
561
|
+
|
|
562
|
+
const topic = extractTopicFromSessionKey(inbound.sessionKey);
|
|
563
|
+
const groupIdFromKey = extractGroupIdFromSessionKey(inbound.sessionKey);
|
|
564
|
+
const replyMsg = textToNkn(replyText, { topic, groupId: groupIdFromKey });
|
|
565
|
+
const replyPayload = JSON.stringify(replyMsg);
|
|
566
|
+
|
|
567
|
+
if (topic) {
|
|
568
|
+
const topicHash = genTopicHash(topic);
|
|
569
|
+
const subscribers = await bus.getSubscribers(topicHash);
|
|
570
|
+
const dests = subscribers.filter((a) => a !== selfAddress);
|
|
571
|
+
if (dests.length > 0) {
|
|
572
|
+
bus.sendToMultiple(dests, replyPayload);
|
|
573
|
+
}
|
|
574
|
+
} else if (groupIdFromKey) {
|
|
575
|
+
// Private group: route reply to the group address
|
|
576
|
+
bus.sendNoReply(groupIdFromKey, replyPayload);
|
|
577
|
+
} else {
|
|
578
|
+
bus.sendNoReply(src, replyPayload);
|
|
579
|
+
}
|
|
580
|
+
},
|
|
581
|
+
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, ""),
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
core.channel.reply
|
|
585
|
+
.dispatchReplyFromConfig({
|
|
586
|
+
ctx: ctxPayload,
|
|
587
|
+
cfg,
|
|
588
|
+
dispatcher,
|
|
589
|
+
replyOptions,
|
|
590
|
+
})
|
|
591
|
+
.then(({ queuedFinal }) => {
|
|
592
|
+
if (queuedFinal) {
|
|
593
|
+
markDispatchIdle?.();
|
|
594
|
+
}
|
|
595
|
+
})
|
|
596
|
+
.catch((err) => {
|
|
597
|
+
logger.error("reply dispatch failed", { error: String(err) });
|
|
598
|
+
markDispatchIdle?.();
|
|
599
|
+
});
|
|
600
|
+
} catch (err) {
|
|
601
|
+
logger.error(`[${account.accountId}] inbound handler error`, { error: String(err) });
|
|
602
|
+
}
|
|
603
|
+
})();
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
// Wait for abort signal
|
|
607
|
+
if (ctx.abortSignal) {
|
|
608
|
+
await new Promise<void>((resolve) => {
|
|
609
|
+
ctx.abortSignal.addEventListener(
|
|
610
|
+
"abort",
|
|
611
|
+
() => {
|
|
612
|
+
resolve();
|
|
613
|
+
},
|
|
614
|
+
{ once: true },
|
|
615
|
+
);
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
} catch (err) {
|
|
619
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
620
|
+
ctx.setStatus({
|
|
621
|
+
accountId: account.accountId,
|
|
622
|
+
lastError: errMsg,
|
|
623
|
+
lastStopAt: Date.now(),
|
|
624
|
+
});
|
|
625
|
+
logger.error(`[${account.accountId}] connection failed: ${errMsg}`);
|
|
626
|
+
} finally {
|
|
627
|
+
await bus.disconnect();
|
|
628
|
+
busMap.delete(account.accountId);
|
|
629
|
+
seenMap.delete(account.accountId);
|
|
630
|
+
}
|
|
631
|
+
},
|
|
632
|
+
},
|
|
633
|
+
};
|