@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/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
+ };