@xmoxmo/bncr 0.1.2 → 0.1.3

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 CHANGED
@@ -1,56 +1,70 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
1
2
  import fs from 'node:fs';
2
3
  import path from 'node:path';
3
- import { createHash, randomUUID } from 'node:crypto';
4
+ import { readBooleanParam } from 'openclaw/plugin-sdk/boolean-param';
4
5
  import type {
6
+ GatewayRequestHandlerOptions,
5
7
  OpenClawPluginApi,
6
8
  OpenClawPluginServiceContext,
7
- GatewayRequestHandlerOptions,
8
9
  } from 'openclaw/plugin-sdk/core';
9
10
  import {
10
- setAccountEnabledInConfigSection,
11
11
  applyAccountNameToChannelSection,
12
+ setAccountEnabledInConfigSection,
12
13
  } from 'openclaw/plugin-sdk/core';
13
- import type { ChatType, ChannelMessageActionAdapter } from 'openclaw/plugin-sdk/mattermost';
14
- import { createDefaultChannelRuntimeState } from 'openclaw/plugin-sdk/status-helpers';
15
- import { writeJsonFileAtomically, readJsonFileWithFallback } from 'openclaw/plugin-sdk/json-store';
14
+ import { readJsonFileWithFallback, writeJsonFileAtomically } from 'openclaw/plugin-sdk/json-store';
15
+ import type { ChannelMessageActionAdapter, ChatType } from 'openclaw/plugin-sdk/mattermost';
16
16
  import { readStringParam } from 'openclaw/plugin-sdk/param-readers';
17
- import { readBooleanParam } from 'openclaw/plugin-sdk/boolean-param';
18
- import { extractToolSend } from 'openclaw/plugin-sdk/tool-send';
17
+ import { createDefaultChannelRuntimeState } from 'openclaw/plugin-sdk/status-helpers';
19
18
  import { jsonResult } from 'openclaw/plugin-sdk/telegram-core';
20
- import { CHANNEL_ID, BNCR_DEFAULT_ACCOUNT_ID, normalizeAccountId, resolveDefaultDisplayName, resolveAccount, listAccountIds } from './core/accounts.ts';
21
- import type { BncrRoute, BncrConnection, OutboxEntry } from './core/types.ts';
19
+ import { extractToolSend } from 'openclaw/plugin-sdk/tool-send';
22
20
  import {
23
- parseRouteFromScope,
24
- parseRouteFromDisplayScope,
21
+ BNCR_DEFAULT_ACCOUNT_ID,
22
+ CHANNEL_ID,
23
+ listAccountIds,
24
+ normalizeAccountId,
25
+ resolveAccount,
26
+ resolveDefaultDisplayName,
27
+ } from './core/accounts.ts';
28
+ import { BncrConfigSchema } from './core/config-schema.ts';
29
+ import { buildBncrPermissionSummary } from './core/permissions.ts';
30
+ import { resolveBncrChannelPolicy } from './core/policy.ts';
31
+ import { probeBncrAccount } from './core/probe.ts';
32
+ import {
33
+ buildAccountRuntimeSnapshot,
34
+ buildIntegratedDiagnostics as buildIntegratedDiagnosticsFromRuntime,
35
+ buildStatusHeadlineFromRuntime,
36
+ buildStatusMetaFromRuntime,
37
+ } from './core/status.ts';
38
+ import {
39
+ buildCanonicalBncrSessionKey,
25
40
  formatDisplayScope,
26
41
  isLowerHex,
27
- routeScopeToHex,
42
+ normalizeInboundSessionKey,
43
+ normalizeStoredSessionKey,
44
+ parseRouteFromDisplayScope,
28
45
  parseRouteFromHexScope,
46
+ parseRouteFromScope,
29
47
  parseRouteLike,
30
- parseLegacySessionKeyToStrict,
31
- normalizeStoredSessionKey,
32
48
  parseStrictBncrSessionKey,
33
- normalizeInboundSessionKey,
34
- withTaskSessionKey,
35
- buildFallbackSessionKey,
36
49
  routeKey,
50
+ routeScopeToHex,
51
+ withTaskSessionKey,
37
52
  } from './core/targets.ts';
38
- import { parseBncrInboundParams } from './messaging/inbound/parse.ts';
53
+ import type { BncrConnection, BncrRoute, OutboxEntry } from './core/types.ts';
39
54
  import { dispatchBncrInbound } from './messaging/inbound/dispatch.ts';
40
55
  import { checkBncrMessageGate } from './messaging/inbound/gate.ts';
41
- import { sendBncrText, sendBncrMedia } from './messaging/outbound/send.ts';
42
- import { buildBncrMediaOutboundFrame, resolveBncrOutboundMessageType } from './messaging/outbound/media.ts';
43
- import { sendBncrReplyAction, deleteBncrMessageAction, reactBncrMessageAction, editBncrMessageAction } from './messaging/outbound/actions.ts';
56
+ import { parseBncrInboundParams } from './messaging/inbound/parse.ts';
44
57
  import {
45
- buildIntegratedDiagnostics as buildIntegratedDiagnosticsFromRuntime,
46
- buildStatusHeadlineFromRuntime,
47
- buildStatusMetaFromRuntime,
48
- buildAccountRuntimeSnapshot,
49
- } from './core/status.ts';
50
- import { probeBncrAccount } from './core/probe.ts';
51
- import { BncrConfigSchema } from './core/config-schema.ts';
52
- import { resolveBncrChannelPolicy } from './core/policy.ts';
53
- import { buildBncrPermissionSummary } from './core/permissions.ts';
58
+ deleteBncrMessageAction,
59
+ editBncrMessageAction,
60
+ reactBncrMessageAction,
61
+ sendBncrReplyAction,
62
+ } from './messaging/outbound/actions.ts';
63
+ import {
64
+ buildBncrMediaOutboundFrame,
65
+ resolveBncrOutboundMessageType,
66
+ } from './messaging/outbound/media.ts';
67
+ import { sendBncrMedia, sendBncrText } from './messaging/outbound/send.ts';
54
68
  const BRIDGE_VERSION = 2;
55
69
  const BNCR_PUSH_EVENT = 'bncr.push';
56
70
  const CONNECT_TTL_MS = 120_000;
@@ -155,13 +169,11 @@ function asString(v: unknown, fallback = ''): string {
155
169
  return String(v);
156
170
  }
157
171
 
158
-
159
172
  function backoffMs(retryCount: number): number {
160
173
  // 1s,2s,4s,8s... capped by retry count checks
161
174
  return Math.max(1_000, 1_000 * 2 ** Math.max(0, retryCount - 1));
162
175
  }
163
176
 
164
-
165
177
  function fileExtFromMime(mimeType?: string): string {
166
178
  const mt = asString(mimeType || '').toLowerCase();
167
179
  const map: Record<string, string> = {
@@ -184,7 +196,15 @@ function fileExtFromMime(mimeType?: string): string {
184
196
  function sanitizeFileName(rawName?: string, fallback = 'file.bin'): string {
185
197
  const name = asString(rawName || '').trim();
186
198
  const base = name || fallback;
187
- const cleaned = base.replace(/[\\/:*?"<>|\x00-\x1F]+/g, '_').replace(/\s+/g, ' ').trim();
199
+ const cleaned = Array.from(base, (ch) => {
200
+ const code = ch.charCodeAt(0);
201
+ if (code <= 0x1f) return '_';
202
+ if ('\\/:*?"<>|'.includes(ch)) return '_';
203
+ return ch;
204
+ })
205
+ .join('')
206
+ .replace(/\s+/g, ' ')
207
+ .trim();
188
208
  return cleaned || fallback;
189
209
  }
190
210
 
@@ -196,7 +216,11 @@ function buildTimestampFileName(mimeType?: string): string {
196
216
  return `bncr_${ts}_${Math.random().toString(16).slice(2, 8)}${ext}`;
197
217
  }
198
218
 
199
- function resolveOutboundFileName(params: { mediaUrl?: string; fileName?: string; mimeType?: string }): string {
219
+ function resolveOutboundFileName(params: {
220
+ mediaUrl?: string;
221
+ fileName?: string;
222
+ mimeType?: string;
223
+ }): string {
200
224
  const mediaUrl = asString(params.mediaUrl || '').trim();
201
225
  const mimeType = asString(params.mimeType || '').trim();
202
226
 
@@ -234,12 +258,15 @@ class BncrBridgeRuntime {
234
258
  private lastInboundAtGlobal: number | null = null;
235
259
  private lastActivityAtGlobal: number | null = null;
236
260
  private lastAckAtGlobal: number | null = null;
237
- private recentConnections = new Map<string, {
238
- epoch: number;
239
- connectedAt: number;
240
- lastActivityAt: number | null;
241
- isPrimary: boolean;
242
- }>();
261
+ private recentConnections = new Map<
262
+ string,
263
+ {
264
+ epoch: number;
265
+ connectedAt: number;
266
+ lastActivityAt: number | null;
267
+ isPrimary: boolean;
268
+ }
269
+ >();
243
270
  private staleCounters = {
244
271
  staleConnect: 0,
245
272
  staleInbound: 0,
@@ -274,14 +301,26 @@ class BncrBridgeRuntime {
274
301
  private outbox = new Map<string, OutboxEntry>(); // messageId -> entry
275
302
  private deadLetter: OutboxEntry[] = [];
276
303
 
277
- private sessionRoutes = new Map<string, { accountId: string; route: BncrRoute; updatedAt: number }>();
278
- private routeAliases = new Map<string, { accountId: string; route: BncrRoute; updatedAt: number }>();
304
+ private sessionRoutes = new Map<
305
+ string,
306
+ { accountId: string; route: BncrRoute; updatedAt: number }
307
+ >();
308
+ private routeAliases = new Map<
309
+ string,
310
+ { accountId: string; route: BncrRoute; updatedAt: number }
311
+ >();
279
312
 
280
313
  private recentInbound = new Map<string, number>();
281
- private lastSessionByAccount = new Map<string, { sessionKey: string; scope: string; updatedAt: number }>();
314
+ private lastSessionByAccount = new Map<
315
+ string,
316
+ { sessionKey: string; scope: string; updatedAt: number }
317
+ >();
282
318
  private lastActivityByAccount = new Map<string, number>();
283
319
  private lastInboundByAccount = new Map<string, number>();
284
320
  private lastOutboundByAccount = new Map<string, number>();
321
+ private canonicalAgentId: string | null = null;
322
+ private canonicalAgentSource: 'startup' | 'runtime' | 'fallback-main' | null = null;
323
+ private canonicalAgentResolvedAt: number | null = null;
285
324
 
286
325
  // 内置健康/回归计数(替代独立脚本)
287
326
  private startedAt = now();
@@ -299,11 +338,14 @@ class BncrBridgeRuntime {
299
338
  // 文件互传状态(V1:尽力而为,重连不续传)
300
339
  private fileSendTransfers = new Map<string, FileSendTransferState>(); // OpenClaw -> Bncr(服务端发起)
301
340
  private fileRecvTransfers = new Map<string, FileRecvTransferState>(); // Bncr -> OpenClaw(客户端发起)
302
- private fileAckWaiters = new Map<string, {
303
- resolve: (payload: Record<string, unknown>) => void;
304
- reject: (err: Error) => void;
305
- timer: NodeJS.Timeout;
306
- }>();
341
+ private fileAckWaiters = new Map<
342
+ string,
343
+ {
344
+ resolve: (payload: Record<string, unknown>) => void;
345
+ reject: (err: Error) => void;
346
+ timer: NodeJS.Timeout;
347
+ }
348
+ >();
307
349
 
308
350
  constructor(api: OpenClawPluginApi) {
309
351
  this.api = api;
@@ -318,7 +360,11 @@ class BncrBridgeRuntime {
318
360
  }
319
361
 
320
362
  private classifyRegisterTrace(stack: string) {
321
- if (stack.includes('prepareSecretsRuntimeSnapshot') || stack.includes('resolveRuntimeWebTools') || stack.includes('resolvePluginWebSearchProviders')) {
363
+ if (
364
+ stack.includes('prepareSecretsRuntimeSnapshot') ||
365
+ stack.includes('resolveRuntimeWebTools') ||
366
+ stack.includes('resolvePluginWebSearchProviders')
367
+ ) {
322
368
  return 'runtime/webtools';
323
369
  }
324
370
  if (stack.includes('startGatewayServer') || stack.includes('loadGatewayPlugins')) {
@@ -348,7 +394,9 @@ class BncrBridgeRuntime {
348
394
  return winner;
349
395
  }
350
396
 
351
- private captureDriftSnapshot(summary: ReturnType<BncrBridgeRuntime['buildRegisterTraceSummary']>) {
397
+ private captureDriftSnapshot(
398
+ summary: ReturnType<BncrBridgeRuntime['buildRegisterTraceSummary']>,
399
+ ) {
352
400
  this.lastDriftSnapshot = {
353
401
  capturedAt: now(),
354
402
  registerCount: this.registerCount,
@@ -374,7 +422,7 @@ class BncrBridgeRuntime {
374
422
 
375
423
  for (const trace of this.registerTraceRecent) {
376
424
  buckets[trace.stackBucket] = (buckets[trace.stackBucket] || 0) + 1;
377
- const isWarmup = baseline != null && (trace.ts - baseline) <= REGISTER_WARMUP_WINDOW_MS;
425
+ const isWarmup = baseline != null && trace.ts - baseline <= REGISTER_WARMUP_WINDOW_MS;
378
426
  if (isWarmup) {
379
427
  warmupCount += 1;
380
428
  } else {
@@ -447,14 +495,13 @@ class BncrBridgeRuntime {
447
495
  stackBucket,
448
496
  };
449
497
  this.registerTraceRecent.push(trace);
450
- if (this.registerTraceRecent.length > 12) this.registerTraceRecent.splice(0, this.registerTraceRecent.length - 12);
498
+ if (this.registerTraceRecent.length > 12)
499
+ this.registerTraceRecent.splice(0, this.registerTraceRecent.length - 12);
451
500
 
452
501
  const summary = this.buildRegisterTraceSummary();
453
502
  if (summary.postWarmupRegisterCount > 0) this.captureDriftSnapshot(summary);
454
503
 
455
- this.api.logger.info?.(
456
- `[bncr-register-trace] ${JSON.stringify(trace)}`,
457
- );
504
+ this.api.logger.info?.(`[bncr-register-trace] ${JSON.stringify(trace)}`);
458
505
  }
459
506
 
460
507
  private createLeaseId() {
@@ -488,26 +535,55 @@ class BncrBridgeRuntime {
488
535
  }
489
536
 
490
537
  private observeLease(
491
- kind: 'connect' | 'inbound' | 'activity' | 'ack' | 'file.init' | 'file.chunk' | 'file.complete' | 'file.abort',
538
+ kind:
539
+ | 'connect'
540
+ | 'inbound'
541
+ | 'activity'
542
+ | 'ack'
543
+ | 'file.init'
544
+ | 'file.chunk'
545
+ | 'file.complete'
546
+ | 'file.abort',
492
547
  params: { leaseId?: string; connectionEpoch?: number },
493
548
  ) {
494
549
  const leaseId = typeof params.leaseId === 'string' ? params.leaseId.trim() : '';
495
- const connectionEpoch = typeof params.connectionEpoch === 'number' ? params.connectionEpoch : undefined;
550
+ const connectionEpoch =
551
+ typeof params.connectionEpoch === 'number' ? params.connectionEpoch : undefined;
496
552
  if (!leaseId && connectionEpoch == null) return { stale: false, reason: 'missing' as const };
497
- const staleByLease = !!leaseId && this.primaryLeaseId != null && leaseId !== this.primaryLeaseId;
498
- const staleByEpoch = connectionEpoch != null && this.connectionEpoch > 0 && connectionEpoch !== this.connectionEpoch;
553
+ const staleByLease =
554
+ !!leaseId && this.primaryLeaseId != null && leaseId !== this.primaryLeaseId;
555
+ const staleByEpoch =
556
+ connectionEpoch != null &&
557
+ this.connectionEpoch > 0 &&
558
+ connectionEpoch !== this.connectionEpoch;
499
559
  const stale = staleByLease || staleByEpoch;
500
560
  if (!stale) return { stale: false, reason: 'ok' as const };
501
561
  this.staleCounters.lastStaleAt = now();
502
562
  switch (kind) {
503
- case 'connect': this.staleCounters.staleConnect += 1; break;
504
- case 'inbound': this.staleCounters.staleInbound += 1; break;
505
- case 'activity': this.staleCounters.staleActivity += 1; break;
506
- case 'ack': this.staleCounters.staleAck += 1; break;
507
- case 'file.init': this.staleCounters.staleFileInit += 1; break;
508
- case 'file.chunk': this.staleCounters.staleFileChunk += 1; break;
509
- case 'file.complete': this.staleCounters.staleFileComplete += 1; break;
510
- case 'file.abort': this.staleCounters.staleFileAbort += 1; break;
563
+ case 'connect':
564
+ this.staleCounters.staleConnect += 1;
565
+ break;
566
+ case 'inbound':
567
+ this.staleCounters.staleInbound += 1;
568
+ break;
569
+ case 'activity':
570
+ this.staleCounters.staleActivity += 1;
571
+ break;
572
+ case 'ack':
573
+ this.staleCounters.staleAck += 1;
574
+ break;
575
+ case 'file.init':
576
+ this.staleCounters.staleFileInit += 1;
577
+ break;
578
+ case 'file.chunk':
579
+ this.staleCounters.staleFileChunk += 1;
580
+ break;
581
+ case 'file.complete':
582
+ this.staleCounters.staleFileComplete += 1;
583
+ break;
584
+ case 'file.abort':
585
+ this.staleCounters.staleFileAbort += 1;
586
+ break;
511
587
  }
512
588
  this.api.logger.warn?.(
513
589
  `[bncr] stale ${kind} observed lease=${leaseId || '-'} epoch=${connectionEpoch ?? '-'} currentLease=${this.primaryLeaseId || '-'} currentEpoch=${this.connectionEpoch}`,
@@ -581,10 +657,18 @@ class BncrBridgeRuntime {
581
657
  startService = async (ctx: OpenClawPluginServiceContext, debug?: boolean) => {
582
658
  this.statePath = path.join(ctx.stateDir, 'bncr-bridge-state.json');
583
659
  await this.loadState();
660
+ try {
661
+ const cfg = await this.api.runtime.config.loadConfig();
662
+ this.initializeCanonicalAgentId(cfg);
663
+ } catch {
664
+ // ignore startup canonical agent initialization errors
665
+ }
584
666
  if (typeof debug === 'boolean') BNCR_DEBUG_VERBOSE = debug;
585
667
  const bootDiag = this.buildIntegratedDiagnostics(BNCR_DEFAULT_ACCOUNT_ID);
586
668
  if (BNCR_DEBUG_VERBOSE) {
587
- this.api.logger.info(`bncr-channel service started (bridge=${this.bridgeId} diag.ok=${bootDiag.regression.ok} routes=${bootDiag.regression.totalKnownRoutes} pending=${bootDiag.health.pending} dead=${bootDiag.health.deadLetter} debug=${BNCR_DEBUG_VERBOSE})`);
669
+ this.api.logger.info(
670
+ `bncr-channel service started (bridge=${this.bridgeId} diag.ok=${bootDiag.regression.ok} routes=${bootDiag.regression.totalKnownRoutes} pending=${bootDiag.health.pending} dead=${bootDiag.health.deadLetter} debug=${BNCR_DEBUG_VERBOSE})`,
671
+ );
588
672
  }
589
673
  };
590
674
 
@@ -630,6 +714,65 @@ class BncrBridgeRuntime {
630
714
  }
631
715
  }
632
716
 
717
+ private tryResolveBindingAgentId(args: {
718
+ cfg: any;
719
+ accountId: string;
720
+ peer?: any;
721
+ channelId?: string;
722
+ }): string | null {
723
+ try {
724
+ const resolved = this.api.runtime.channel.routing.resolveAgentRoute({
725
+ cfg: args.cfg,
726
+ channel: args.channelId || CHANNEL_ID,
727
+ accountId: normalizeAccountId(args.accountId),
728
+ peer: args.peer,
729
+ });
730
+ const agentId = asString(resolved?.agentId || '').trim();
731
+ return agentId || null;
732
+ } catch {
733
+ return null;
734
+ }
735
+ }
736
+
737
+ private initializeCanonicalAgentId(cfg: any) {
738
+ if (this.canonicalAgentId) return;
739
+ const agentId = this.tryResolveBindingAgentId({
740
+ cfg,
741
+ accountId: BNCR_DEFAULT_ACCOUNT_ID,
742
+ channelId: CHANNEL_ID,
743
+ peer: { kind: 'direct', id: 'bootstrap' },
744
+ });
745
+ if (!agentId) return;
746
+ this.canonicalAgentId = agentId;
747
+ this.canonicalAgentSource = 'startup';
748
+ this.canonicalAgentResolvedAt = now();
749
+ }
750
+
751
+ private ensureCanonicalAgentId(args: {
752
+ cfg: any;
753
+ accountId: string;
754
+ peer?: any;
755
+ channelId?: string;
756
+ }): string {
757
+ if (this.canonicalAgentId) return this.canonicalAgentId;
758
+
759
+ const agentId = this.tryResolveBindingAgentId(args);
760
+ if (agentId) {
761
+ this.canonicalAgentId = agentId;
762
+ this.canonicalAgentSource = 'runtime';
763
+ this.canonicalAgentResolvedAt = now();
764
+ return agentId;
765
+ }
766
+
767
+ this.canonicalAgentId = 'main';
768
+ this.canonicalAgentSource = 'fallback-main';
769
+ this.canonicalAgentResolvedAt = now();
770
+ this.api.logger.warn?.(
771
+ '[bncr-canonical-agent] binding agent unresolved; fallback to main for current process lifetime',
772
+ );
773
+ return this.canonicalAgentId;
774
+ }
775
+
633
776
  private countInvalidOutboxSessionKeys(accountId: string): number {
634
777
  const acc = normalizeAccountId(accountId);
635
778
  let count = 0;
@@ -642,7 +785,8 @@ class BncrBridgeRuntime {
642
785
 
643
786
  private countLegacyAccountResidue(accountId: string): number {
644
787
  const acc = normalizeAccountId(accountId);
645
- const mismatched = (raw?: string | null) => asString(raw || '').trim() && normalizeAccountId(raw) !== acc;
788
+ const mismatched = (raw?: string | null) =>
789
+ asString(raw || '').trim() && normalizeAccountId(raw) !== acc;
646
790
 
647
791
  let count = 0;
648
792
 
@@ -688,7 +832,8 @@ class BncrBridgeRuntime {
688
832
  lastActivityAt: this.lastActivityByAccount.get(acc) || null,
689
833
  lastInboundAt: this.lastInboundByAccount.get(acc) || null,
690
834
  lastOutboundAt: this.lastOutboundByAccount.get(acc) || null,
691
- sessionRoutesCount: Array.from(this.sessionRoutes.values()).filter((v) => v.accountId === acc).length,
835
+ sessionRoutesCount: Array.from(this.sessionRoutes.values()).filter((v) => v.accountId === acc)
836
+ .length,
692
837
  invalidOutboxSessionKeys: this.countInvalidOutboxSessionKeys(acc),
693
838
  legacyAccountResidue: this.countLegacyAccountResidue(acc),
694
839
  channelRoot: path.join(process.cwd(), 'plugins', 'bncr'),
@@ -709,11 +854,12 @@ class BncrBridgeRuntime {
709
854
  if (!entry?.messageId) continue;
710
855
  const accountId = normalizeAccountId(entry.accountId);
711
856
  const sessionKey = asString(entry.sessionKey || '').trim();
712
- const normalized = normalizeStoredSessionKey(sessionKey);
857
+ const normalized = normalizeStoredSessionKey(sessionKey, this.canonicalAgentId);
713
858
  if (!normalized) continue;
714
859
 
715
860
  const route = parseRouteLike(entry.route) || normalized.route;
716
- const payload = (entry.payload && typeof entry.payload === 'object') ? { ...entry.payload } : {};
861
+ const payload =
862
+ entry.payload && typeof entry.payload === 'object' ? { ...entry.payload } : {};
717
863
  (payload as any).sessionKey = normalized.sessionKey;
718
864
  (payload as any).platform = route.platform;
719
865
  (payload as any).groupId = route.groupId;
@@ -740,11 +886,12 @@ class BncrBridgeRuntime {
740
886
  if (!entry?.messageId) continue;
741
887
  const accountId = normalizeAccountId(entry.accountId);
742
888
  const sessionKey = asString(entry.sessionKey || '').trim();
743
- const normalized = normalizeStoredSessionKey(sessionKey);
889
+ const normalized = normalizeStoredSessionKey(sessionKey, this.canonicalAgentId);
744
890
  if (!normalized) continue;
745
891
 
746
892
  const route = parseRouteLike(entry.route) || normalized.route;
747
- const payload = (entry.payload && typeof entry.payload === 'object') ? { ...entry.payload } : {};
893
+ const payload =
894
+ entry.payload && typeof entry.payload === 'object' ? { ...entry.payload } : {};
748
895
  (payload as any).sessionKey = normalized.sessionKey;
749
896
  (payload as any).platform = route.platform;
750
897
  (payload as any).groupId = route.groupId;
@@ -767,7 +914,10 @@ class BncrBridgeRuntime {
767
914
  this.sessionRoutes.clear();
768
915
  this.routeAliases.clear();
769
916
  for (const item of data.sessionRoutes || []) {
770
- const normalized = normalizeStoredSessionKey(asString(item?.sessionKey || ''));
917
+ const normalized = normalizeStoredSessionKey(
918
+ asString(item?.sessionKey || ''),
919
+ this.canonicalAgentId,
920
+ );
771
921
  if (!normalized) continue;
772
922
 
773
923
  const route = parseRouteLike(item?.route) || normalized.route;
@@ -787,7 +937,10 @@ class BncrBridgeRuntime {
787
937
  this.lastSessionByAccount.clear();
788
938
  for (const item of data.lastSessionByAccount || []) {
789
939
  const accountId = normalizeAccountId(item?.accountId);
790
- const normalized = normalizeStoredSessionKey(asString(item?.sessionKey || ''));
940
+ const normalized = normalizeStoredSessionKey(
941
+ asString(item?.sessionKey || ''),
942
+ this.canonicalAgentId,
943
+ );
791
944
  const updatedAt = Number(item?.updatedAt || 0);
792
945
  if (!normalized || !Number.isFinite(updatedAt) || updatedAt <= 0) continue;
793
946
 
@@ -823,24 +976,38 @@ class BncrBridgeRuntime {
823
976
  this.lastOutboundByAccount.set(accountId, updatedAt);
824
977
  }
825
978
 
826
- this.lastDriftSnapshot = data.lastDriftSnapshot && typeof data.lastDriftSnapshot === 'object'
827
- ? {
828
- capturedAt: Number((data.lastDriftSnapshot as any).capturedAt || 0),
829
- registerCount: Number.isFinite(Number((data.lastDriftSnapshot as any).registerCount)) ? Number((data.lastDriftSnapshot as any).registerCount) : null,
830
- apiGeneration: Number.isFinite(Number((data.lastDriftSnapshot as any).apiGeneration)) ? Number((data.lastDriftSnapshot as any).apiGeneration) : null,
831
- postWarmupRegisterCount: Number.isFinite(Number((data.lastDriftSnapshot as any).postWarmupRegisterCount)) ? Number((data.lastDriftSnapshot as any).postWarmupRegisterCount) : null,
832
- apiInstanceId: asString((data.lastDriftSnapshot as any).apiInstanceId || '').trim() || null,
833
- registryFingerprint: asString((data.lastDriftSnapshot as any).registryFingerprint || '').trim() || null,
834
- dominantBucket: asString((data.lastDriftSnapshot as any).dominantBucket || '').trim() || null,
835
- sourceBuckets: ((data.lastDriftSnapshot as any).sourceBuckets && typeof (data.lastDriftSnapshot as any).sourceBuckets === 'object')
836
- ? { ...((data.lastDriftSnapshot as any).sourceBuckets as Record<string, number>) }
837
- : {},
838
- traceWindowSize: Number((data.lastDriftSnapshot as any).traceWindowSize || 0),
839
- traceRecent: Array.isArray((data.lastDriftSnapshot as any).traceRecent)
840
- ? [ ...((data.lastDriftSnapshot as any).traceRecent as Array<Record<string, unknown>>) ]
841
- : [],
842
- }
843
- : null;
979
+ this.lastDriftSnapshot =
980
+ data.lastDriftSnapshot && typeof data.lastDriftSnapshot === 'object'
981
+ ? {
982
+ capturedAt: Number((data.lastDriftSnapshot as any).capturedAt || 0),
983
+ registerCount: Number.isFinite(Number((data.lastDriftSnapshot as any).registerCount))
984
+ ? Number((data.lastDriftSnapshot as any).registerCount)
985
+ : null,
986
+ apiGeneration: Number.isFinite(Number((data.lastDriftSnapshot as any).apiGeneration))
987
+ ? Number((data.lastDriftSnapshot as any).apiGeneration)
988
+ : null,
989
+ postWarmupRegisterCount: Number.isFinite(
990
+ Number((data.lastDriftSnapshot as any).postWarmupRegisterCount),
991
+ )
992
+ ? Number((data.lastDriftSnapshot as any).postWarmupRegisterCount)
993
+ : null,
994
+ apiInstanceId:
995
+ asString((data.lastDriftSnapshot as any).apiInstanceId || '').trim() || null,
996
+ registryFingerprint:
997
+ asString((data.lastDriftSnapshot as any).registryFingerprint || '').trim() || null,
998
+ dominantBucket:
999
+ asString((data.lastDriftSnapshot as any).dominantBucket || '').trim() || null,
1000
+ sourceBuckets:
1001
+ (data.lastDriftSnapshot as any).sourceBuckets &&
1002
+ typeof (data.lastDriftSnapshot as any).sourceBuckets === 'object'
1003
+ ? { ...((data.lastDriftSnapshot as any).sourceBuckets as Record<string, number>) }
1004
+ : {},
1005
+ traceWindowSize: Number((data.lastDriftSnapshot as any).traceWindowSize || 0),
1006
+ traceRecent: Array.isArray((data.lastDriftSnapshot as any).traceRecent)
1007
+ ? [...((data.lastDriftSnapshot as any).traceRecent as Array<Record<string, unknown>>)]
1008
+ : [],
1009
+ }
1010
+ : null;
844
1011
 
845
1012
  // 兼容旧状态文件:若尚未持久化 lastSession*/lastActivity*,从 sessionRoutes 回填。
846
1013
  if (this.lastSessionByAccount.size === 0 && this.sessionRoutes.size > 0) {
@@ -884,24 +1051,32 @@ class BncrBridgeRuntime {
884
1051
  outbox: Array.from(this.outbox.values()),
885
1052
  deadLetter: this.deadLetter.slice(-1000),
886
1053
  sessionRoutes,
887
- lastSessionByAccount: Array.from(this.lastSessionByAccount.entries()).map(([accountId, v]) => ({
888
- accountId,
889
- sessionKey: v.sessionKey,
890
- scope: v.scope,
891
- updatedAt: v.updatedAt,
892
- })),
893
- lastActivityByAccount: Array.from(this.lastActivityByAccount.entries()).map(([accountId, updatedAt]) => ({
894
- accountId,
895
- updatedAt,
896
- })),
897
- lastInboundByAccount: Array.from(this.lastInboundByAccount.entries()).map(([accountId, updatedAt]) => ({
898
- accountId,
899
- updatedAt,
900
- })),
901
- lastOutboundByAccount: Array.from(this.lastOutboundByAccount.entries()).map(([accountId, updatedAt]) => ({
902
- accountId,
903
- updatedAt,
904
- })),
1054
+ lastSessionByAccount: Array.from(this.lastSessionByAccount.entries()).map(
1055
+ ([accountId, v]) => ({
1056
+ accountId,
1057
+ sessionKey: v.sessionKey,
1058
+ scope: v.scope,
1059
+ updatedAt: v.updatedAt,
1060
+ }),
1061
+ ),
1062
+ lastActivityByAccount: Array.from(this.lastActivityByAccount.entries()).map(
1063
+ ([accountId, updatedAt]) => ({
1064
+ accountId,
1065
+ updatedAt,
1066
+ }),
1067
+ ),
1068
+ lastInboundByAccount: Array.from(this.lastInboundByAccount.entries()).map(
1069
+ ([accountId, updatedAt]) => ({
1070
+ accountId,
1071
+ updatedAt,
1072
+ }),
1073
+ ),
1074
+ lastOutboundByAccount: Array.from(this.lastOutboundByAccount.entries()).map(
1075
+ ([accountId, updatedAt]) => ({
1076
+ accountId,
1077
+ updatedAt,
1078
+ }),
1079
+ ),
905
1080
  lastDriftSnapshot: this.lastDriftSnapshot
906
1081
  ? {
907
1082
  capturedAt: this.lastDriftSnapshot.capturedAt,
@@ -1038,7 +1213,11 @@ class BncrBridgeRuntime {
1038
1213
  const filterAcc = accountId ? normalizeAccountId(accountId) : null;
1039
1214
  const targetAccounts = filterAcc
1040
1215
  ? [filterAcc]
1041
- : Array.from(new Set(Array.from(this.outbox.values()).map((entry) => normalizeAccountId(entry.accountId))));
1216
+ : Array.from(
1217
+ new Set(
1218
+ Array.from(this.outbox.values()).map((entry) => normalizeAccountId(entry.accountId)),
1219
+ ),
1220
+ );
1042
1221
  if (BNCR_DEBUG_VERBOSE) {
1043
1222
  this.api.logger.info?.(
1044
1223
  `[bncr-outbox-flush] ${JSON.stringify({
@@ -1117,10 +1296,14 @@ class BncrBridgeRuntime {
1117
1296
  if (!directPayloads.length) continue;
1118
1297
 
1119
1298
  try {
1120
- ctx.broadcastToConnIds(BNCR_PUSH_EVENT, {
1121
- forcePush: true,
1122
- items: directPayloads,
1123
- }, new Set(directConnIds));
1299
+ ctx.broadcastToConnIds(
1300
+ BNCR_PUSH_EVENT,
1301
+ {
1302
+ forcePush: true,
1303
+ items: directPayloads,
1304
+ },
1305
+ new Set(directConnIds),
1306
+ );
1124
1307
 
1125
1308
  const pushedIds = directPayloads
1126
1309
  .map((item: any) => asString(item?.messageId || item?.idempotencyKey || '').trim())
@@ -1198,7 +1381,8 @@ class BncrBridgeRuntime {
1198
1381
  }
1199
1382
 
1200
1383
  if (localNextDelay != null) {
1201
- globalNextDelay = globalNextDelay == null ? localNextDelay : Math.min(globalNextDelay, localNextDelay);
1384
+ globalNextDelay =
1385
+ globalNextDelay == null ? localNextDelay : Math.min(globalNextDelay, localNextDelay);
1202
1386
  }
1203
1387
  } finally {
1204
1388
  this.pushDrainRunningAccounts.delete(acc);
@@ -1320,7 +1504,11 @@ class BncrBridgeRuntime {
1320
1504
  }
1321
1505
 
1322
1506
  const curConn = this.connections.get(current);
1323
- if (!curConn || t - curConn.lastSeenAt > CONNECT_TTL_MS || nextConn.connectedAt >= curConn.connectedAt) {
1507
+ if (
1508
+ !curConn ||
1509
+ t - curConn.lastSeenAt > CONNECT_TTL_MS ||
1510
+ nextConn.connectedAt >= curConn.connectedAt
1511
+ ) {
1324
1512
  this.activeConnectionByAccount.set(acc, key);
1325
1513
  }
1326
1514
  }
@@ -1372,9 +1560,6 @@ class BncrBridgeRuntime {
1372
1560
  const info = { accountId: acc, route, updatedAt: t };
1373
1561
 
1374
1562
  this.sessionRoutes.set(key, info);
1375
- // 同步维护旧格式与新格式,便于平滑切换
1376
- this.sessionRoutes.set(buildFallbackSessionKey(route), info);
1377
-
1378
1563
  this.routeAliases.set(routeKey(acc, route), info);
1379
1564
  this.lastSessionByAccount.set(acc, {
1380
1565
  sessionKey: key,
@@ -1404,7 +1589,10 @@ class BncrBridgeRuntime {
1404
1589
  // 1) 标准 to 仅认 Bncr:<platform>:<groupId>:<userId> / Bncr:<platform>:<userId>
1405
1590
  // 2) 仍接受 strict sessionKey 作为内部兼容输入
1406
1591
  // 3) 其他旧格式直接失败,并输出标准格式提示日志
1407
- private resolveVerifiedTarget(rawTarget: string, accountId: string): { sessionKey: string; route: BncrRoute; displayScope: string } {
1592
+ private resolveVerifiedTarget(
1593
+ rawTarget: string,
1594
+ accountId: string,
1595
+ ): { sessionKey: string; route: BncrRoute; displayScope: string } {
1408
1596
  const acc = normalizeAccountId(accountId);
1409
1597
  const raw = asString(rawTarget).trim();
1410
1598
  if (!raw) throw new Error('bncr invalid target(empty)');
@@ -1424,17 +1612,23 @@ class BncrBridgeRuntime {
1424
1612
 
1425
1613
  if (!route) {
1426
1614
  this.api.logger.warn?.(
1427
- `[bncr-target-invalid] raw=${raw} accountId=${acc} reason=unparseable-or-unknown standardTo=Bncr:<platform>:<groupId>:<userId>|Bncr:<platform>:<userId> standardSessionKey=agent:main:bncr:direct:<hex(scope)>`,
1615
+ `[bncr-target-invalid] raw=${raw} accountId=${acc} reason=unparseable-or-unknown standardTo=Bncr:<platform>:<groupId>:<userId>|Bncr:<platform>:<userId> standardSessionKey=agent:<agentId>:bncr:direct:<hex(scope)>`,
1616
+ );
1617
+ throw new Error(
1618
+ `bncr invalid target(standard: Bncr:<platform>:<groupId>:<userId> | Bncr:<platform>:<userId>): ${raw}`,
1428
1619
  );
1429
- throw new Error(`bncr invalid target(standard: Bncr:<platform>:<groupId>:<userId> | Bncr:<platform>:<userId>): ${raw}`);
1430
1620
  }
1431
1621
 
1432
1622
  const wantedRouteKey = routeKey(acc, route);
1433
1623
  let best: { sessionKey: string; route: BncrRoute; updatedAt: number } | null = null;
1434
1624
 
1435
1625
  if (BNCR_DEBUG_VERBOSE) {
1436
- this.api.logger.info?.(`[bncr-target-incoming-route] raw=${raw} accountId=${acc} route=${JSON.stringify(route)}`);
1437
- this.api.logger.info?.(`[bncr-target-incoming-sessionRoutes] raw=${raw} accountId=${acc} sessionRoutes=${JSON.stringify(this.sessionRoutes.entries())}`);
1626
+ this.api.logger.info?.(
1627
+ `[bncr-target-incoming-route] raw=${raw} accountId=${acc} route=${JSON.stringify(route)}`,
1628
+ );
1629
+ this.api.logger.info?.(
1630
+ `[bncr-target-incoming-sessionRoutes] raw=${raw} accountId=${acc} sessionRoutes=${JSON.stringify(this.sessionRoutes.entries())}`,
1631
+ );
1438
1632
  }
1439
1633
 
1440
1634
  for (const [key, info] of this.sessionRoutes.entries()) {
@@ -1446,29 +1640,40 @@ class BncrBridgeRuntime {
1446
1640
  const updatedAt = Number(info.updatedAt || 0);
1447
1641
  if (!best || updatedAt >= best.updatedAt) {
1448
1642
  best = {
1449
- sessionKey: parsed.sessionKey,
1643
+ sessionKey: key,
1450
1644
  route: parsed.route,
1451
1645
  updatedAt,
1452
1646
  };
1453
1647
  }
1454
1648
  }
1455
1649
 
1456
- // 直接根据raw生成标准sessionkey
1457
1650
  if (!best) {
1458
1651
  const updatedAt = 0;
1652
+ const canonicalAgentId =
1653
+ this.canonicalAgentId ||
1654
+ this.ensureCanonicalAgentId({
1655
+ cfg: this.api.runtime.config?.get?.() || {},
1656
+ accountId: acc,
1657
+ channelId: CHANNEL_ID,
1658
+ peer: { kind: 'direct', id: route.groupId === '0' ? route.userId : route.groupId },
1659
+ });
1459
1660
  best = {
1460
- sessionKey: `agent:main:bncr:direct:${routeScopeToHex(route)}`,
1661
+ sessionKey: buildCanonicalBncrSessionKey(route, canonicalAgentId),
1461
1662
  route,
1462
1663
  updatedAt,
1463
1664
  };
1464
1665
  }
1465
1666
 
1466
1667
  if (BNCR_DEBUG_VERBOSE) {
1467
- this.api.logger.info?.(`[bncr-target-incoming-best] raw=${raw} accountId=${acc} best=${JSON.stringify(best)}`);
1668
+ this.api.logger.info?.(
1669
+ `[bncr-target-incoming-best] raw=${raw} accountId=${acc} best=${JSON.stringify(best)}`,
1670
+ );
1468
1671
  }
1469
1672
 
1470
1673
  if (!best) {
1471
- this.api.logger.warn?.(`[bncr-target-miss] raw=${raw} accountId=${acc} sessionRoutes=${this.sessionRoutes.size}`);
1674
+ this.api.logger.warn?.(
1675
+ `[bncr-target-miss] raw=${raw} accountId=${acc} sessionRoutes=${this.sessionRoutes.size}`,
1676
+ );
1472
1677
  throw new Error(`bncr target not found in known sessions: ${raw}`);
1473
1678
  }
1474
1679
 
@@ -1496,11 +1701,19 @@ class BncrBridgeRuntime {
1496
1701
  return `${transferId}|${stage}|${idx}`;
1497
1702
  }
1498
1703
 
1499
- private waitForFileAck(params: { transferId: string; stage: string; chunkIndex?: number; timeoutMs?: number }) {
1704
+ private waitForFileAck(params: {
1705
+ transferId: string;
1706
+ stage: string;
1707
+ chunkIndex?: number;
1708
+ timeoutMs?: number;
1709
+ }) {
1500
1710
  const transferId = asString(params.transferId).trim();
1501
1711
  const stage = asString(params.stage).trim();
1502
1712
  const key = this.fileAckKey(transferId, stage, params.chunkIndex);
1503
- const timeoutMs = Math.max(1_000, Math.min(Number(params.timeoutMs || FILE_ACK_TIMEOUT_MS), 120_000));
1713
+ const timeoutMs = Math.max(
1714
+ 1_000,
1715
+ Math.min(Number(params.timeoutMs || FILE_ACK_TIMEOUT_MS), 120_000),
1716
+ );
1504
1717
 
1505
1718
  return new Promise<Record<string, unknown>>((resolve, reject) => {
1506
1719
  const timer = setTimeout(() => {
@@ -1511,7 +1724,13 @@ class BncrBridgeRuntime {
1511
1724
  });
1512
1725
  }
1513
1726
 
1514
- private resolveFileAck(params: { transferId: string; stage: string; chunkIndex?: number; payload: Record<string, unknown>; ok: boolean }) {
1727
+ private resolveFileAck(params: {
1728
+ transferId: string;
1729
+ stage: string;
1730
+ chunkIndex?: number;
1731
+ payload: Record<string, unknown>;
1732
+ ok: boolean;
1733
+ }) {
1515
1734
  const transferId = asString(params.transferId).trim();
1516
1735
  const stage = asString(params.stage).trim();
1517
1736
  const key = this.fileAckKey(transferId, stage, params.chunkIndex);
@@ -1520,11 +1739,20 @@ class BncrBridgeRuntime {
1520
1739
  this.fileAckWaiters.delete(key);
1521
1740
  clearTimeout(waiter.timer);
1522
1741
  if (params.ok) waiter.resolve(params.payload);
1523
- else waiter.reject(new Error(asString(params.payload?.errorMessage || params.payload?.error || 'file ack failed')));
1742
+ else
1743
+ waiter.reject(
1744
+ new Error(
1745
+ asString(params.payload?.errorMessage || params.payload?.error || 'file ack failed'),
1746
+ ),
1747
+ );
1524
1748
  return true;
1525
1749
  }
1526
1750
 
1527
- private pushFileEventToAccount(accountId: string, event: string, payload: Record<string, unknown>) {
1751
+ private pushFileEventToAccount(
1752
+ accountId: string,
1753
+ event: string,
1754
+ payload: Record<string, unknown>,
1755
+ ) {
1528
1756
  const connIds = this.resolvePushConnIds(accountId);
1529
1757
  if (!connIds.size || !this.gatewayContext) {
1530
1758
  throw new Error(`no active bncr connection for account=${accountId}`);
@@ -1547,7 +1775,9 @@ class BncrBridgeRuntime {
1547
1775
  return dir;
1548
1776
  }
1549
1777
 
1550
- private async materializeRecvTransfer(st: FileRecvTransferState): Promise<{ path: string; fileSha256: string }> {
1778
+ private async materializeRecvTransfer(
1779
+ st: FileRecvTransferState,
1780
+ ): Promise<{ path: string; fileSha256: string }> {
1551
1781
  const dir = this.resolveInboundFilesDir();
1552
1782
  const safeName = asString(st.fileName).trim() || `${st.transferId}.bin`;
1553
1783
  const finalPath = path.join(dir, safeName);
@@ -1589,7 +1819,8 @@ class BncrBridgeRuntime {
1589
1819
  lastActivityAt: this.lastActivityByAccount.get(acc) || null,
1590
1820
  lastInboundAt: this.lastInboundByAccount.get(acc) || null,
1591
1821
  lastOutboundAt: this.lastOutboundByAccount.get(acc) || null,
1592
- sessionRoutesCount: Array.from(this.sessionRoutes.values()).filter((v) => v.accountId === acc).length,
1822
+ sessionRoutesCount: Array.from(this.sessionRoutes.values()).filter((v) => v.accountId === acc)
1823
+ .length,
1593
1824
  invalidOutboxSessionKeys: this.countInvalidOutboxSessionKeys(acc),
1594
1825
  legacyAccountResidue: this.countLegacyAccountResidue(acc),
1595
1826
  channelRoot: path.join(process.cwd(), 'plugins', 'bncr'),
@@ -1613,7 +1844,8 @@ class BncrBridgeRuntime {
1613
1844
  lastActivityAt: this.lastActivityByAccount.get(acc) || null,
1614
1845
  lastInboundAt: this.lastInboundByAccount.get(acc) || null,
1615
1846
  lastOutboundAt: this.lastOutboundByAccount.get(acc) || null,
1616
- sessionRoutesCount: Array.from(this.sessionRoutes.values()).filter((v) => v.accountId === acc).length,
1847
+ sessionRoutesCount: Array.from(this.sessionRoutes.values()).filter((v) => v.accountId === acc)
1848
+ .length,
1617
1849
  invalidOutboxSessionKeys: this.countInvalidOutboxSessionKeys(acc),
1618
1850
  legacyAccountResidue: this.countLegacyAccountResidue(acc),
1619
1851
  running: true,
@@ -1638,7 +1870,8 @@ class BncrBridgeRuntime {
1638
1870
  lastActivityAt: this.lastActivityByAccount.get(acc) || null,
1639
1871
  lastInboundAt: this.lastInboundByAccount.get(acc) || null,
1640
1872
  lastOutboundAt: this.lastOutboundByAccount.get(acc) || null,
1641
- sessionRoutesCount: Array.from(this.sessionRoutes.values()).filter((v) => v.accountId === acc).length,
1873
+ sessionRoutesCount: Array.from(this.sessionRoutes.values()).filter((v) => v.accountId === acc)
1874
+ .length,
1642
1875
  invalidOutboxSessionKeys: this.countInvalidOutboxSessionKeys(acc),
1643
1876
  legacyAccountResidue: this.countLegacyAccountResidue(acc),
1644
1877
  channelRoot: path.join(process.cwd(), 'plugins', 'bncr'),
@@ -1763,7 +1996,10 @@ class BncrBridgeRuntime {
1763
1996
  timeoutMs?: number;
1764
1997
  }): Promise<void> {
1765
1998
  const { transferId, chunkIndex } = params;
1766
- const timeoutMs = Math.max(1_000, Math.min(Number(params.timeoutMs || FILE_TRANSFER_ACK_TTL_MS), 60_000));
1999
+ const timeoutMs = Math.max(
2000
+ 1_000,
2001
+ Math.min(Number(params.timeoutMs || FILE_TRANSFER_ACK_TTL_MS), 60_000),
2002
+ );
1767
2003
  const started = now();
1768
2004
 
1769
2005
  return new Promise<void>((resolve, reject) => {
@@ -1832,7 +2068,13 @@ class BncrBridgeRuntime {
1832
2068
  route: BncrRoute;
1833
2069
  mediaUrl: string;
1834
2070
  mediaLocalRoots?: readonly string[];
1835
- }): Promise<{ mode: 'base64' | 'chunk'; mimeType?: string; fileName?: string; mediaBase64?: string; path?: string }> {
2071
+ }): Promise<{
2072
+ mode: 'base64' | 'chunk';
2073
+ mimeType?: string;
2074
+ fileName?: string;
2075
+ mediaBase64?: string;
2076
+ path?: string;
2077
+ }> {
1836
2078
  const loaded = await this.api.runtime.media.loadWebMedia(params.mediaUrl, {
1837
2079
  localRoots: params.mediaLocalRoots,
1838
2080
  maxBytes: 50 * 1024 * 1024,
@@ -1884,21 +2126,25 @@ class BncrBridgeRuntime {
1884
2126
  };
1885
2127
  this.fileSendTransfers.set(transferId, st);
1886
2128
 
1887
- ctx.broadcastToConnIds('bncr.file.init', {
1888
- transferId,
1889
- direction: 'oc2bncr',
1890
- sessionKey: params.sessionKey,
1891
- platform: params.route.platform,
1892
- groupId: params.route.groupId,
1893
- userId: params.route.userId,
1894
- fileName,
1895
- mimeType,
1896
- fileSize: size,
1897
- chunkSize,
1898
- totalChunks,
1899
- fileSha256,
1900
- ts: now(),
1901
- }, connIds);
2129
+ ctx.broadcastToConnIds(
2130
+ 'bncr.file.init',
2131
+ {
2132
+ transferId,
2133
+ direction: 'oc2bncr',
2134
+ sessionKey: params.sessionKey,
2135
+ platform: params.route.platform,
2136
+ groupId: params.route.groupId,
2137
+ userId: params.route.userId,
2138
+ fileName,
2139
+ mimeType,
2140
+ fileSize: size,
2141
+ chunkSize,
2142
+ totalChunks,
2143
+ fileSha256,
2144
+ ts: now(),
2145
+ },
2146
+ connIds,
2147
+ );
1902
2148
 
1903
2149
  // 逐块发送并等待 ACK
1904
2150
  for (let idx = 0; idx < totalChunks; idx++) {
@@ -1910,18 +2156,26 @@ class BncrBridgeRuntime {
1910
2156
  let ok = false;
1911
2157
  let lastErr: unknown = null;
1912
2158
  for (let attempt = 1; attempt <= 3; attempt++) {
1913
- ctx.broadcastToConnIds('bncr.file.chunk', {
1914
- transferId,
1915
- chunkIndex: idx,
1916
- offset: start,
1917
- size: slice.byteLength,
1918
- chunkSha256,
1919
- base64: slice.toString('base64'),
1920
- ts: now(),
1921
- }, connIds);
2159
+ ctx.broadcastToConnIds(
2160
+ 'bncr.file.chunk',
2161
+ {
2162
+ transferId,
2163
+ chunkIndex: idx,
2164
+ offset: start,
2165
+ size: slice.byteLength,
2166
+ chunkSha256,
2167
+ base64: slice.toString('base64'),
2168
+ ts: now(),
2169
+ },
2170
+ connIds,
2171
+ );
1922
2172
 
1923
2173
  try {
1924
- await this.waitChunkAck({ transferId, chunkIndex: idx, timeoutMs: FILE_TRANSFER_ACK_TTL_MS });
2174
+ await this.waitChunkAck({
2175
+ transferId,
2176
+ chunkIndex: idx,
2177
+ timeoutMs: FILE_TRANSFER_ACK_TTL_MS,
2178
+ });
1925
2179
  ok = true;
1926
2180
  break;
1927
2181
  } catch (err) {
@@ -1934,19 +2188,27 @@ class BncrBridgeRuntime {
1934
2188
  st.status = 'aborted';
1935
2189
  st.error = String((lastErr as any)?.message || lastErr || `chunk-${idx}-failed`);
1936
2190
  this.fileSendTransfers.set(transferId, st);
1937
- ctx.broadcastToConnIds('bncr.file.abort', {
1938
- transferId,
1939
- reason: st.error,
1940
- ts: now(),
1941
- }, connIds);
2191
+ ctx.broadcastToConnIds(
2192
+ 'bncr.file.abort',
2193
+ {
2194
+ transferId,
2195
+ reason: st.error,
2196
+ ts: now(),
2197
+ },
2198
+ connIds,
2199
+ );
1942
2200
  throw new Error(st.error);
1943
2201
  }
1944
2202
  }
1945
2203
 
1946
- ctx.broadcastToConnIds('bncr.file.complete', {
1947
- transferId,
1948
- ts: now(),
1949
- }, connIds);
2204
+ ctx.broadcastToConnIds(
2205
+ 'bncr.file.complete',
2206
+ {
2207
+ transferId,
2208
+ ts: now(),
2209
+ },
2210
+ connIds,
2211
+ );
1950
2212
 
1951
2213
  const done = await this.waitCompleteAck({ transferId, timeoutMs: 60_000 });
1952
2214
 
@@ -1962,7 +2224,14 @@ class BncrBridgeRuntime {
1962
2224
  accountId: string;
1963
2225
  sessionKey: string;
1964
2226
  route: BncrRoute;
1965
- payload: { text?: string; mediaUrl?: string; mediaUrls?: string[]; asVoice?: boolean; audioAsVoice?: boolean; kind?: 'block' | 'final' };
2227
+ payload: {
2228
+ text?: string;
2229
+ mediaUrl?: string;
2230
+ mediaUrls?: string[];
2231
+ asVoice?: boolean;
2232
+ audioAsVoice?: boolean;
2233
+ kind?: 'block' | 'final';
2234
+ };
1966
2235
  mediaLocalRoots?: readonly string[];
1967
2236
  }) {
1968
2237
  const { accountId, sessionKey, route, payload, mediaLocalRoots } = params;
@@ -2270,11 +2539,12 @@ class BncrBridgeRuntime {
2270
2539
  return;
2271
2540
  }
2272
2541
 
2273
- const route = parseRouteLike({
2274
- platform: asString(params?.platform || normalized.route.platform),
2275
- groupId: asString(params?.groupId || normalized.route.groupId),
2276
- userId: asString(params?.userId || normalized.route.userId),
2277
- }) || normalized.route;
2542
+ const route =
2543
+ parseRouteLike({
2544
+ platform: asString(params?.platform || normalized.route.platform),
2545
+ groupId: asString(params?.groupId || normalized.route.groupId),
2546
+ userId: asString(params?.userId || normalized.route.userId),
2547
+ }) || normalized.route;
2278
2548
 
2279
2549
  this.fileRecvTransfers.set(transferId, {
2280
2550
  transferId,
@@ -2354,7 +2624,12 @@ class BncrBridgeRuntime {
2354
2624
  }
2355
2625
  };
2356
2626
 
2357
- handleFileComplete = async ({ params, respond, client, context }: GatewayRequestHandlerOptions) => {
2627
+ handleFileComplete = async ({
2628
+ params,
2629
+ respond,
2630
+ client,
2631
+ context,
2632
+ }: GatewayRequestHandlerOptions) => {
2358
2633
  const accountId = normalizeAccountId(asString(params?.accountId || ''));
2359
2634
  const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
2360
2635
  const clientId = asString((params as any)?.clientId || '').trim() || undefined;
@@ -2377,10 +2652,14 @@ class BncrBridgeRuntime {
2377
2652
 
2378
2653
  try {
2379
2654
  if (st.receivedChunks.size < st.totalChunks) {
2380
- throw new Error(`chunk not complete received=${st.receivedChunks.size} total=${st.totalChunks}`);
2655
+ throw new Error(
2656
+ `chunk not complete received=${st.receivedChunks.size} total=${st.totalChunks}`,
2657
+ );
2381
2658
  }
2382
2659
 
2383
- const ordered = Array.from(st.bufferByChunk.entries()).sort((a, b) => a[0] - b[0]).map((x) => x[1]);
2660
+ const ordered = Array.from(st.bufferByChunk.entries())
2661
+ .sort((a, b) => a[0] - b[0])
2662
+ .map((x) => x[1]);
2384
2663
  const merged = Buffer.concat(ordered);
2385
2664
  if (st.fileSize > 0 && merged.length !== st.fileSize) {
2386
2665
  throw new Error(`file size mismatch expected=${st.fileSize} got=${merged.length}`);
@@ -2515,7 +2794,24 @@ class BncrBridgeRuntime {
2515
2794
 
2516
2795
  handleInbound = async ({ params, respond, client, context }: GatewayRequestHandlerOptions) => {
2517
2796
  const parsed = parseBncrInboundParams(params);
2518
- const { accountId, platform, groupId, userId, sessionKeyfromroute, route, text, msgType, mediaBase64, mediaPathFromTransfer, mimeType, fileName, msgId, dedupKey, peer, extracted } = parsed;
2797
+ const {
2798
+ accountId,
2799
+ platform,
2800
+ groupId,
2801
+ userId,
2802
+ sessionKeyfromroute,
2803
+ route,
2804
+ text,
2805
+ msgType,
2806
+ mediaBase64,
2807
+ mediaPathFromTransfer,
2808
+ mimeType,
2809
+ fileName,
2810
+ msgId,
2811
+ dedupKey,
2812
+ peer,
2813
+ extracted,
2814
+ } = parsed;
2519
2815
  const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
2520
2816
  const clientId = asString((params as any)?.clientId || '').trim() || undefined;
2521
2817
  this.rememberGatewayContext(context);
@@ -2555,13 +2851,21 @@ class BncrBridgeRuntime {
2555
2851
  return;
2556
2852
  }
2557
2853
 
2558
- const baseSessionKey = normalizeInboundSessionKey(sessionKeyfromroute, route)
2559
- || this.api.runtime.channel.routing.resolveAgentRoute({
2560
- cfg,
2561
- channel: CHANNEL_ID,
2562
- accountId,
2563
- peer,
2564
- }).sessionKey;
2854
+ const canonicalAgentId = this.ensureCanonicalAgentId({
2855
+ cfg,
2856
+ accountId,
2857
+ peer,
2858
+ channelId: CHANNEL_ID,
2859
+ });
2860
+ const resolvedRoute = this.api.runtime.channel.routing.resolveAgentRoute({
2861
+ cfg,
2862
+ channel: CHANNEL_ID,
2863
+ accountId,
2864
+ peer,
2865
+ });
2866
+ const baseSessionKey =
2867
+ normalizeInboundSessionKey(sessionKeyfromroute, route, canonicalAgentId) ||
2868
+ resolvedRoute.sessionKey;
2565
2869
  const taskSessionKey = withTaskSessionKey(baseSessionKey, extracted.taskKey);
2566
2870
  const sessionKey = taskSessionKey || baseSessionKey;
2567
2871
 
@@ -2578,7 +2882,9 @@ class BncrBridgeRuntime {
2578
2882
  channelId: CHANNEL_ID,
2579
2883
  cfg,
2580
2884
  parsed,
2581
- rememberSessionRoute: (sessionKey, accountId, route) => this.rememberSessionRoute(sessionKey, accountId, route),
2885
+ canonicalAgentId,
2886
+ rememberSessionRoute: (sessionKey, accountId, route) =>
2887
+ this.rememberSessionRoute(sessionKey, accountId, route),
2582
2888
  enqueueFromReply: (args) => this.enqueueFromReply(args),
2583
2889
  setInboundActivity: (accountId, at) => {
2584
2890
  this.lastInboundByAccount.set(accountId, at);
@@ -2664,7 +2970,8 @@ class BncrBridgeRuntime {
2664
2970
  text: asString(ctx.text || ''),
2665
2971
  mediaLocalRoots: ctx.mediaLocalRoots,
2666
2972
  resolveVerifiedTarget: (to, accountId) => this.resolveVerifiedTarget(to, accountId),
2667
- rememberSessionRoute: (sessionKey, accountId, route) => this.rememberSessionRoute(sessionKey, accountId, route),
2973
+ rememberSessionRoute: (sessionKey, accountId, route) =>
2974
+ this.rememberSessionRoute(sessionKey, accountId, route),
2668
2975
  enqueueFromReply: (args) => this.enqueueFromReply(args),
2669
2976
  createMessageId: () => randomUUID(),
2670
2977
  });
@@ -2708,7 +3015,8 @@ class BncrBridgeRuntime {
2708
3015
  audioAsVoice,
2709
3016
  mediaLocalRoots: ctx.mediaLocalRoots,
2710
3017
  resolveVerifiedTarget: (to, accountId) => this.resolveVerifiedTarget(to, accountId),
2711
- rememberSessionRoute: (sessionKey, accountId, route) => this.rememberSessionRoute(sessionKey, accountId, route),
3018
+ rememberSessionRoute: (sessionKey, accountId, route) =>
3019
+ this.rememberSessionRoute(sessionKey, accountId, route),
2712
3020
  enqueueFromReply: (args) => this.enqueueFromReply(args),
2713
3021
  createMessageId: () => randomUUID(),
2714
3022
  });
@@ -2723,10 +3031,13 @@ export function createBncrChannelPlugin(bridge: BncrBridgeRuntime) {
2723
3031
  const messageActions: ChannelMessageActionAdapter = {
2724
3032
  describeMessageTool: ({ cfg }) => {
2725
3033
  const channelCfg = cfg?.channels?.[CHANNEL_ID];
2726
- const hasExplicitConfiguredAccount = Boolean(channelCfg && typeof channelCfg === 'object')
2727
- && resolveBncrChannelPolicy(channelCfg).enabled !== false
2728
- && Boolean(channelCfg.accounts && typeof channelCfg.accounts === 'object')
2729
- && Object.keys(channelCfg.accounts).some((accountId) => resolveAccount(cfg, accountId).enabled !== false);
3034
+ const hasExplicitConfiguredAccount =
3035
+ Boolean(channelCfg && typeof channelCfg === 'object') &&
3036
+ resolveBncrChannelPolicy(channelCfg).enabled !== false &&
3037
+ Boolean(channelCfg.accounts && typeof channelCfg.accounts === 'object') &&
3038
+ Object.keys(channelCfg.accounts).some(
3039
+ (accountId) => resolveAccount(cfg, accountId).enabled !== false,
3040
+ );
2730
3041
 
2731
3042
  const hasConnectedRuntime = listAccountIds(cfg).some((accountId) => {
2732
3043
  const resolved = resolveAccount(cfg, accountId);
@@ -2746,7 +3057,8 @@ export function createBncrChannelPlugin(bridge: BncrBridgeRuntime) {
2746
3057
  supportsAction: ({ action }) => action === 'send',
2747
3058
  extractToolSend: ({ args }) => extractToolSend(args, 'sendMessage'),
2748
3059
  handleAction: async ({ action, params, accountId, mediaLocalRoots }) => {
2749
- if (action !== 'send') throw new Error(`Action ${action} is not supported for provider ${CHANNEL_ID}.`);
3060
+ if (action !== 'send')
3061
+ throw new Error(`Action ${action} is not supported for provider ${CHANNEL_ID}.`);
2750
3062
  const to = readStringParam(params, 'to', { required: true });
2751
3063
  const message = readStringParam(params, 'message', { allowEmpty: true }) ?? '';
2752
3064
  const caption = readStringParam(params, 'caption', { allowEmpty: true }) ?? '';
@@ -2758,36 +3070,40 @@ export function createBncrChannelPlugin(bridge: BncrBridgeRuntime) {
2758
3070
  readStringParam(params, 'mediaUrl', { trim: false });
2759
3071
  const asVoice = readBooleanParam(params, 'asVoice') ?? false;
2760
3072
  const audioAsVoice = readBooleanParam(params, 'audioAsVoice') ?? false;
2761
- const resolvedAccountId = normalizeAccountId(readStringParam(params, 'accountId') ?? accountId);
3073
+ const resolvedAccountId = normalizeAccountId(
3074
+ readStringParam(params, 'accountId') ?? accountId,
3075
+ );
2762
3076
 
2763
3077
  if (!content.trim() && !mediaUrl) throw new Error('send requires text or media');
2764
3078
 
2765
3079
  const result = mediaUrl
2766
3080
  ? await sendBncrMedia({
2767
- channelId: CHANNEL_ID,
2768
- accountId: resolvedAccountId,
2769
- to,
2770
- text: content,
2771
- mediaUrl,
2772
- asVoice,
2773
- audioAsVoice,
2774
- mediaLocalRoots,
2775
- resolveVerifiedTarget: (to, accountId) => bridge.resolveVerifiedTarget(to, accountId),
2776
- rememberSessionRoute: (sessionKey, accountId, route) => bridge.rememberSessionRoute(sessionKey, accountId, route),
2777
- enqueueFromReply: (args) => bridge.enqueueFromReply(args as any),
2778
- createMessageId: () => randomUUID(),
2779
- })
3081
+ channelId: CHANNEL_ID,
3082
+ accountId: resolvedAccountId,
3083
+ to,
3084
+ text: content,
3085
+ mediaUrl,
3086
+ asVoice,
3087
+ audioAsVoice,
3088
+ mediaLocalRoots,
3089
+ resolveVerifiedTarget: (to, accountId) => bridge.resolveVerifiedTarget(to, accountId),
3090
+ rememberSessionRoute: (sessionKey, accountId, route) =>
3091
+ bridge.rememberSessionRoute(sessionKey, accountId, route),
3092
+ enqueueFromReply: (args) => bridge.enqueueFromReply(args as any),
3093
+ createMessageId: () => randomUUID(),
3094
+ })
2780
3095
  : await sendBncrText({
2781
- channelId: CHANNEL_ID,
2782
- accountId: resolvedAccountId,
2783
- to,
2784
- text: content,
2785
- mediaLocalRoots,
2786
- resolveVerifiedTarget: (to, accountId) => bridge.resolveVerifiedTarget(to, accountId),
2787
- rememberSessionRoute: (sessionKey, accountId, route) => bridge.rememberSessionRoute(sessionKey, accountId, route),
2788
- enqueueFromReply: (args) => bridge.enqueueFromReply(args as any),
2789
- createMessageId: () => randomUUID(),
2790
- });
3096
+ channelId: CHANNEL_ID,
3097
+ accountId: resolvedAccountId,
3098
+ to,
3099
+ text: content,
3100
+ mediaLocalRoots,
3101
+ resolveVerifiedTarget: (to, accountId) => bridge.resolveVerifiedTarget(to, accountId),
3102
+ rememberSessionRoute: (sessionKey, accountId, route) =>
3103
+ bridge.rememberSessionRoute(sessionKey, accountId, route),
3104
+ enqueueFromReply: (args) => bridge.enqueueFromReply(args as any),
3105
+ createMessageId: () => randomUUID(),
3106
+ });
2791
3107
 
2792
3108
  return jsonResult({ ok: true, ...result });
2793
3109
  },
@@ -2820,7 +3136,7 @@ export function createBncrChannelPlugin(bridge: BncrBridgeRuntime) {
2820
3136
  looksLikeId: (raw: string, normalized?: string) => {
2821
3137
  return Boolean(asString(normalized || raw).trim());
2822
3138
  },
2823
- hint: 'Standard to=Bncr:<platform>:<group>:<user> or Bncr:<platform>:<user>; sessionKey keeps existing strict/legacy compatibility, canonical sessionKey=agent:main:bncr:direct:<hex>',
3139
+ hint: 'Standard to=Bncr:<platform>:<group>:<user> or Bncr:<platform>:<user>; sessionKey keeps existing strict/legacy compatibility, canonical sessionKey=agent:<agentId>:bncr:direct:<hex>',
2824
3140
  },
2825
3141
  },
2826
3142
  configSchema: BncrConfigSchema,
@@ -2876,27 +3192,33 @@ export function createBncrChannelPlugin(bridge: BncrBridgeRuntime) {
2876
3192
  textChunkLimit: 4000,
2877
3193
  sendText: bridge.channelSendText,
2878
3194
  sendMedia: bridge.channelSendMedia,
2879
- replyAction: async (ctx: any) => sendBncrReplyAction({
2880
- accountId: normalizeAccountId(ctx?.accountId),
2881
- to: asString(ctx?.to || '').trim(),
2882
- text: asString(ctx?.text || ''),
2883
- replyToMessageId: asString(ctx?.replyToId || ctx?.replyToMessageId || '').trim() || undefined,
2884
- sendText: async ({ accountId, to, text }) => bridge.channelSendText({ accountId, to, text }),
2885
- }),
2886
- deleteAction: async (ctx: any) => deleteBncrMessageAction({
2887
- accountId: normalizeAccountId(ctx?.accountId),
2888
- targetMessageId: asString(ctx?.messageId || ctx?.targetMessageId || '').trim(),
2889
- }),
2890
- reactAction: async (ctx: any) => reactBncrMessageAction({
2891
- accountId: normalizeAccountId(ctx?.accountId),
2892
- targetMessageId: asString(ctx?.messageId || ctx?.targetMessageId || '').trim(),
2893
- emoji: asString(ctx?.emoji || '').trim(),
2894
- }),
2895
- editAction: async (ctx: any) => editBncrMessageAction({
2896
- accountId: normalizeAccountId(ctx?.accountId),
2897
- targetMessageId: asString(ctx?.messageId || ctx?.targetMessageId || '').trim(),
2898
- text: asString(ctx?.text || ''),
2899
- }),
3195
+ replyAction: async (ctx: any) =>
3196
+ sendBncrReplyAction({
3197
+ accountId: normalizeAccountId(ctx?.accountId),
3198
+ to: asString(ctx?.to || '').trim(),
3199
+ text: asString(ctx?.text || ''),
3200
+ replyToMessageId:
3201
+ asString(ctx?.replyToId || ctx?.replyToMessageId || '').trim() || undefined,
3202
+ sendText: async ({ accountId, to, text }) =>
3203
+ bridge.channelSendText({ accountId, to, text }),
3204
+ }),
3205
+ deleteAction: async (ctx: any) =>
3206
+ deleteBncrMessageAction({
3207
+ accountId: normalizeAccountId(ctx?.accountId),
3208
+ targetMessageId: asString(ctx?.messageId || ctx?.targetMessageId || '').trim(),
3209
+ }),
3210
+ reactAction: async (ctx: any) =>
3211
+ reactBncrMessageAction({
3212
+ accountId: normalizeAccountId(ctx?.accountId),
3213
+ targetMessageId: asString(ctx?.messageId || ctx?.targetMessageId || '').trim(),
3214
+ emoji: asString(ctx?.emoji || '').trim(),
3215
+ }),
3216
+ editAction: async (ctx: any) =>
3217
+ editBncrMessageAction({
3218
+ accountId: normalizeAccountId(ctx?.accountId),
3219
+ targetMessageId: asString(ctx?.messageId || ctx?.targetMessageId || '').trim(),
3220
+ text: asString(ctx?.text || ''),
3221
+ }),
2900
3222
  },
2901
3223
  status: {
2902
3224
  defaultRuntime: createDefaultChannelRuntimeState(BNCR_DEFAULT_ACCOUNT_ID, {
@@ -2923,9 +3245,7 @@ export function createBncrChannelPlugin(bridge: BncrBridgeRuntime) {
2923
3245
  const lastOutboundAgo = rt?.lastOutboundAgo ?? meta.lastOutboundAgo ?? '-';
2924
3246
  const diagnostics = rt?.diagnostics ?? meta.diagnostics ?? null;
2925
3247
  // 右侧状态字段统一:离线时也显示 Status(避免出现 configured 文案)
2926
- const normalizedMode = rt?.mode === 'linked'
2927
- ? 'linked'
2928
- : 'Status';
3248
+ const normalizedMode = rt?.mode === 'linked' ? 'linked' : 'Status';
2929
3249
 
2930
3250
  const displayName = resolveDefaultDisplayName(account?.name, account?.accountId);
2931
3251