@xmoxmo/bncr 0.1.2 → 0.1.4

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,61 +1,76 @@
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;
57
71
  const MAX_RETRY = 10;
58
72
  const PUSH_DRAIN_INTERVAL_MS = 500;
73
+ const PUSH_ACK_TIMEOUT_MS = 30_000;
59
74
  const FILE_FORCE_CHUNK = true; // 统一走 WS 分块,保留 base64 仅作兜底
60
75
  const FILE_INLINE_THRESHOLD = 5 * 1024 * 1024; // fallback 阈值(仅 FILE_FORCE_CHUNK=false 时生效)
61
76
  const FILE_CHUNK_SIZE = 256 * 1024; // 256KB
@@ -155,13 +170,11 @@ function asString(v: unknown, fallback = ''): string {
155
170
  return String(v);
156
171
  }
157
172
 
158
-
159
173
  function backoffMs(retryCount: number): number {
160
174
  // 1s,2s,4s,8s... capped by retry count checks
161
175
  return Math.max(1_000, 1_000 * 2 ** Math.max(0, retryCount - 1));
162
176
  }
163
177
 
164
-
165
178
  function fileExtFromMime(mimeType?: string): string {
166
179
  const mt = asString(mimeType || '').toLowerCase();
167
180
  const map: Record<string, string> = {
@@ -184,7 +197,15 @@ function fileExtFromMime(mimeType?: string): string {
184
197
  function sanitizeFileName(rawName?: string, fallback = 'file.bin'): string {
185
198
  const name = asString(rawName || '').trim();
186
199
  const base = name || fallback;
187
- const cleaned = base.replace(/[\\/:*?"<>|\x00-\x1F]+/g, '_').replace(/\s+/g, ' ').trim();
200
+ const cleaned = Array.from(base, (ch) => {
201
+ const code = ch.charCodeAt(0);
202
+ if (code <= 0x1f) return '_';
203
+ if ('\\/:*?"<>|'.includes(ch)) return '_';
204
+ return ch;
205
+ })
206
+ .join('')
207
+ .replace(/\s+/g, ' ')
208
+ .trim();
188
209
  return cleaned || fallback;
189
210
  }
190
211
 
@@ -196,7 +217,11 @@ function buildTimestampFileName(mimeType?: string): string {
196
217
  return `bncr_${ts}_${Math.random().toString(16).slice(2, 8)}${ext}`;
197
218
  }
198
219
 
199
- function resolveOutboundFileName(params: { mediaUrl?: string; fileName?: string; mimeType?: string }): string {
220
+ function resolveOutboundFileName(params: {
221
+ mediaUrl?: string;
222
+ fileName?: string;
223
+ mimeType?: string;
224
+ }): string {
200
225
  const mediaUrl = asString(params.mediaUrl || '').trim();
201
226
  const mimeType = asString(params.mimeType || '').trim();
202
227
 
@@ -234,12 +259,15 @@ class BncrBridgeRuntime {
234
259
  private lastInboundAtGlobal: number | null = null;
235
260
  private lastActivityAtGlobal: number | null = null;
236
261
  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
- }>();
262
+ private recentConnections = new Map<
263
+ string,
264
+ {
265
+ epoch: number;
266
+ connectedAt: number;
267
+ lastActivityAt: number | null;
268
+ isPrimary: boolean;
269
+ }
270
+ >();
243
271
  private staleCounters = {
244
272
  staleConnect: 0,
245
273
  staleInbound: 0,
@@ -274,14 +302,26 @@ class BncrBridgeRuntime {
274
302
  private outbox = new Map<string, OutboxEntry>(); // messageId -> entry
275
303
  private deadLetter: OutboxEntry[] = [];
276
304
 
277
- private sessionRoutes = new Map<string, { accountId: string; route: BncrRoute; updatedAt: number }>();
278
- private routeAliases = new Map<string, { accountId: string; route: BncrRoute; updatedAt: number }>();
305
+ private sessionRoutes = new Map<
306
+ string,
307
+ { accountId: string; route: BncrRoute; updatedAt: number }
308
+ >();
309
+ private routeAliases = new Map<
310
+ string,
311
+ { accountId: string; route: BncrRoute; updatedAt: number }
312
+ >();
279
313
 
280
314
  private recentInbound = new Map<string, number>();
281
- private lastSessionByAccount = new Map<string, { sessionKey: string; scope: string; updatedAt: number }>();
315
+ private lastSessionByAccount = new Map<
316
+ string,
317
+ { sessionKey: string; scope: string; updatedAt: number }
318
+ >();
282
319
  private lastActivityByAccount = new Map<string, number>();
283
320
  private lastInboundByAccount = new Map<string, number>();
284
321
  private lastOutboundByAccount = new Map<string, number>();
322
+ private canonicalAgentId: string | null = null;
323
+ private canonicalAgentSource: 'startup' | 'runtime' | 'fallback-main' | null = null;
324
+ private canonicalAgentResolvedAt: number | null = null;
285
325
 
286
326
  // 内置健康/回归计数(替代独立脚本)
287
327
  private startedAt = now();
@@ -299,11 +339,14 @@ class BncrBridgeRuntime {
299
339
  // 文件互传状态(V1:尽力而为,重连不续传)
300
340
  private fileSendTransfers = new Map<string, FileSendTransferState>(); // OpenClaw -> Bncr(服务端发起)
301
341
  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
- }>();
342
+ private fileAckWaiters = new Map<
343
+ string,
344
+ {
345
+ resolve: (payload: Record<string, unknown>) => void;
346
+ reject: (err: Error) => void;
347
+ timer: NodeJS.Timeout;
348
+ }
349
+ >();
307
350
 
308
351
  constructor(api: OpenClawPluginApi) {
309
352
  this.api = api;
@@ -318,7 +361,11 @@ class BncrBridgeRuntime {
318
361
  }
319
362
 
320
363
  private classifyRegisterTrace(stack: string) {
321
- if (stack.includes('prepareSecretsRuntimeSnapshot') || stack.includes('resolveRuntimeWebTools') || stack.includes('resolvePluginWebSearchProviders')) {
364
+ if (
365
+ stack.includes('prepareSecretsRuntimeSnapshot') ||
366
+ stack.includes('resolveRuntimeWebTools') ||
367
+ stack.includes('resolvePluginWebSearchProviders')
368
+ ) {
322
369
  return 'runtime/webtools';
323
370
  }
324
371
  if (stack.includes('startGatewayServer') || stack.includes('loadGatewayPlugins')) {
@@ -348,7 +395,9 @@ class BncrBridgeRuntime {
348
395
  return winner;
349
396
  }
350
397
 
351
- private captureDriftSnapshot(summary: ReturnType<BncrBridgeRuntime['buildRegisterTraceSummary']>) {
398
+ private captureDriftSnapshot(
399
+ summary: ReturnType<BncrBridgeRuntime['buildRegisterTraceSummary']>,
400
+ ) {
352
401
  this.lastDriftSnapshot = {
353
402
  capturedAt: now(),
354
403
  registerCount: this.registerCount,
@@ -374,7 +423,7 @@ class BncrBridgeRuntime {
374
423
 
375
424
  for (const trace of this.registerTraceRecent) {
376
425
  buckets[trace.stackBucket] = (buckets[trace.stackBucket] || 0) + 1;
377
- const isWarmup = baseline != null && (trace.ts - baseline) <= REGISTER_WARMUP_WINDOW_MS;
426
+ const isWarmup = baseline != null && trace.ts - baseline <= REGISTER_WARMUP_WINDOW_MS;
378
427
  if (isWarmup) {
379
428
  warmupCount += 1;
380
429
  } else {
@@ -447,14 +496,13 @@ class BncrBridgeRuntime {
447
496
  stackBucket,
448
497
  };
449
498
  this.registerTraceRecent.push(trace);
450
- if (this.registerTraceRecent.length > 12) this.registerTraceRecent.splice(0, this.registerTraceRecent.length - 12);
499
+ if (this.registerTraceRecent.length > 12)
500
+ this.registerTraceRecent.splice(0, this.registerTraceRecent.length - 12);
451
501
 
452
502
  const summary = this.buildRegisterTraceSummary();
453
503
  if (summary.postWarmupRegisterCount > 0) this.captureDriftSnapshot(summary);
454
504
 
455
- this.api.logger.info?.(
456
- `[bncr-register-trace] ${JSON.stringify(trace)}`,
457
- );
505
+ this.api.logger.info?.(`[bncr-register-trace] ${JSON.stringify(trace)}`);
458
506
  }
459
507
 
460
508
  private createLeaseId() {
@@ -488,26 +536,55 @@ class BncrBridgeRuntime {
488
536
  }
489
537
 
490
538
  private observeLease(
491
- kind: 'connect' | 'inbound' | 'activity' | 'ack' | 'file.init' | 'file.chunk' | 'file.complete' | 'file.abort',
539
+ kind:
540
+ | 'connect'
541
+ | 'inbound'
542
+ | 'activity'
543
+ | 'ack'
544
+ | 'file.init'
545
+ | 'file.chunk'
546
+ | 'file.complete'
547
+ | 'file.abort',
492
548
  params: { leaseId?: string; connectionEpoch?: number },
493
549
  ) {
494
550
  const leaseId = typeof params.leaseId === 'string' ? params.leaseId.trim() : '';
495
- const connectionEpoch = typeof params.connectionEpoch === 'number' ? params.connectionEpoch : undefined;
551
+ const connectionEpoch =
552
+ typeof params.connectionEpoch === 'number' ? params.connectionEpoch : undefined;
496
553
  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;
554
+ const staleByLease =
555
+ !!leaseId && this.primaryLeaseId != null && leaseId !== this.primaryLeaseId;
556
+ const staleByEpoch =
557
+ connectionEpoch != null &&
558
+ this.connectionEpoch > 0 &&
559
+ connectionEpoch !== this.connectionEpoch;
499
560
  const stale = staleByLease || staleByEpoch;
500
561
  if (!stale) return { stale: false, reason: 'ok' as const };
501
562
  this.staleCounters.lastStaleAt = now();
502
563
  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;
564
+ case 'connect':
565
+ this.staleCounters.staleConnect += 1;
566
+ break;
567
+ case 'inbound':
568
+ this.staleCounters.staleInbound += 1;
569
+ break;
570
+ case 'activity':
571
+ this.staleCounters.staleActivity += 1;
572
+ break;
573
+ case 'ack':
574
+ this.staleCounters.staleAck += 1;
575
+ break;
576
+ case 'file.init':
577
+ this.staleCounters.staleFileInit += 1;
578
+ break;
579
+ case 'file.chunk':
580
+ this.staleCounters.staleFileChunk += 1;
581
+ break;
582
+ case 'file.complete':
583
+ this.staleCounters.staleFileComplete += 1;
584
+ break;
585
+ case 'file.abort':
586
+ this.staleCounters.staleFileAbort += 1;
587
+ break;
511
588
  }
512
589
  this.api.logger.warn?.(
513
590
  `[bncr] stale ${kind} observed lease=${leaseId || '-'} epoch=${connectionEpoch ?? '-'} currentLease=${this.primaryLeaseId || '-'} currentEpoch=${this.connectionEpoch}`,
@@ -581,10 +658,18 @@ class BncrBridgeRuntime {
581
658
  startService = async (ctx: OpenClawPluginServiceContext, debug?: boolean) => {
582
659
  this.statePath = path.join(ctx.stateDir, 'bncr-bridge-state.json');
583
660
  await this.loadState();
661
+ try {
662
+ const cfg = await this.api.runtime.config.loadConfig();
663
+ this.initializeCanonicalAgentId(cfg);
664
+ } catch {
665
+ // ignore startup canonical agent initialization errors
666
+ }
584
667
  if (typeof debug === 'boolean') BNCR_DEBUG_VERBOSE = debug;
585
668
  const bootDiag = this.buildIntegratedDiagnostics(BNCR_DEFAULT_ACCOUNT_ID);
586
669
  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})`);
670
+ this.api.logger.info(
671
+ `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})`,
672
+ );
588
673
  }
589
674
  };
590
675
 
@@ -630,6 +715,65 @@ class BncrBridgeRuntime {
630
715
  }
631
716
  }
632
717
 
718
+ private tryResolveBindingAgentId(args: {
719
+ cfg: any;
720
+ accountId: string;
721
+ peer?: any;
722
+ channelId?: string;
723
+ }): string | null {
724
+ try {
725
+ const resolved = this.api.runtime.channel.routing.resolveAgentRoute({
726
+ cfg: args.cfg,
727
+ channel: args.channelId || CHANNEL_ID,
728
+ accountId: normalizeAccountId(args.accountId),
729
+ peer: args.peer,
730
+ });
731
+ const agentId = asString(resolved?.agentId || '').trim();
732
+ return agentId || null;
733
+ } catch {
734
+ return null;
735
+ }
736
+ }
737
+
738
+ private initializeCanonicalAgentId(cfg: any) {
739
+ if (this.canonicalAgentId) return;
740
+ const agentId = this.tryResolveBindingAgentId({
741
+ cfg,
742
+ accountId: BNCR_DEFAULT_ACCOUNT_ID,
743
+ channelId: CHANNEL_ID,
744
+ peer: { kind: 'direct', id: 'bootstrap' },
745
+ });
746
+ if (!agentId) return;
747
+ this.canonicalAgentId = agentId;
748
+ this.canonicalAgentSource = 'startup';
749
+ this.canonicalAgentResolvedAt = now();
750
+ }
751
+
752
+ private ensureCanonicalAgentId(args: {
753
+ cfg: any;
754
+ accountId: string;
755
+ peer?: any;
756
+ channelId?: string;
757
+ }): string {
758
+ if (this.canonicalAgentId) return this.canonicalAgentId;
759
+
760
+ const agentId = this.tryResolveBindingAgentId(args);
761
+ if (agentId) {
762
+ this.canonicalAgentId = agentId;
763
+ this.canonicalAgentSource = 'runtime';
764
+ this.canonicalAgentResolvedAt = now();
765
+ return agentId;
766
+ }
767
+
768
+ this.canonicalAgentId = 'main';
769
+ this.canonicalAgentSource = 'fallback-main';
770
+ this.canonicalAgentResolvedAt = now();
771
+ this.api.logger.warn?.(
772
+ '[bncr-canonical-agent] binding agent unresolved; fallback to main for current process lifetime',
773
+ );
774
+ return this.canonicalAgentId;
775
+ }
776
+
633
777
  private countInvalidOutboxSessionKeys(accountId: string): number {
634
778
  const acc = normalizeAccountId(accountId);
635
779
  let count = 0;
@@ -642,7 +786,8 @@ class BncrBridgeRuntime {
642
786
 
643
787
  private countLegacyAccountResidue(accountId: string): number {
644
788
  const acc = normalizeAccountId(accountId);
645
- const mismatched = (raw?: string | null) => asString(raw || '').trim() && normalizeAccountId(raw) !== acc;
789
+ const mismatched = (raw?: string | null) =>
790
+ asString(raw || '').trim() && normalizeAccountId(raw) !== acc;
646
791
 
647
792
  let count = 0;
648
793
 
@@ -688,7 +833,8 @@ class BncrBridgeRuntime {
688
833
  lastActivityAt: this.lastActivityByAccount.get(acc) || null,
689
834
  lastInboundAt: this.lastInboundByAccount.get(acc) || null,
690
835
  lastOutboundAt: this.lastOutboundByAccount.get(acc) || null,
691
- sessionRoutesCount: Array.from(this.sessionRoutes.values()).filter((v) => v.accountId === acc).length,
836
+ sessionRoutesCount: Array.from(this.sessionRoutes.values()).filter((v) => v.accountId === acc)
837
+ .length,
692
838
  invalidOutboxSessionKeys: this.countInvalidOutboxSessionKeys(acc),
693
839
  legacyAccountResidue: this.countLegacyAccountResidue(acc),
694
840
  channelRoot: path.join(process.cwd(), 'plugins', 'bncr'),
@@ -709,11 +855,12 @@ class BncrBridgeRuntime {
709
855
  if (!entry?.messageId) continue;
710
856
  const accountId = normalizeAccountId(entry.accountId);
711
857
  const sessionKey = asString(entry.sessionKey || '').trim();
712
- const normalized = normalizeStoredSessionKey(sessionKey);
858
+ const normalized = normalizeStoredSessionKey(sessionKey, this.canonicalAgentId);
713
859
  if (!normalized) continue;
714
860
 
715
861
  const route = parseRouteLike(entry.route) || normalized.route;
716
- const payload = (entry.payload && typeof entry.payload === 'object') ? { ...entry.payload } : {};
862
+ const payload =
863
+ entry.payload && typeof entry.payload === 'object' ? { ...entry.payload } : {};
717
864
  (payload as any).sessionKey = normalized.sessionKey;
718
865
  (payload as any).platform = route.platform;
719
866
  (payload as any).groupId = route.groupId;
@@ -740,11 +887,12 @@ class BncrBridgeRuntime {
740
887
  if (!entry?.messageId) continue;
741
888
  const accountId = normalizeAccountId(entry.accountId);
742
889
  const sessionKey = asString(entry.sessionKey || '').trim();
743
- const normalized = normalizeStoredSessionKey(sessionKey);
890
+ const normalized = normalizeStoredSessionKey(sessionKey, this.canonicalAgentId);
744
891
  if (!normalized) continue;
745
892
 
746
893
  const route = parseRouteLike(entry.route) || normalized.route;
747
- const payload = (entry.payload && typeof entry.payload === 'object') ? { ...entry.payload } : {};
894
+ const payload =
895
+ entry.payload && typeof entry.payload === 'object' ? { ...entry.payload } : {};
748
896
  (payload as any).sessionKey = normalized.sessionKey;
749
897
  (payload as any).platform = route.platform;
750
898
  (payload as any).groupId = route.groupId;
@@ -767,7 +915,10 @@ class BncrBridgeRuntime {
767
915
  this.sessionRoutes.clear();
768
916
  this.routeAliases.clear();
769
917
  for (const item of data.sessionRoutes || []) {
770
- const normalized = normalizeStoredSessionKey(asString(item?.sessionKey || ''));
918
+ const normalized = normalizeStoredSessionKey(
919
+ asString(item?.sessionKey || ''),
920
+ this.canonicalAgentId,
921
+ );
771
922
  if (!normalized) continue;
772
923
 
773
924
  const route = parseRouteLike(item?.route) || normalized.route;
@@ -787,7 +938,10 @@ class BncrBridgeRuntime {
787
938
  this.lastSessionByAccount.clear();
788
939
  for (const item of data.lastSessionByAccount || []) {
789
940
  const accountId = normalizeAccountId(item?.accountId);
790
- const normalized = normalizeStoredSessionKey(asString(item?.sessionKey || ''));
941
+ const normalized = normalizeStoredSessionKey(
942
+ asString(item?.sessionKey || ''),
943
+ this.canonicalAgentId,
944
+ );
791
945
  const updatedAt = Number(item?.updatedAt || 0);
792
946
  if (!normalized || !Number.isFinite(updatedAt) || updatedAt <= 0) continue;
793
947
 
@@ -823,24 +977,38 @@ class BncrBridgeRuntime {
823
977
  this.lastOutboundByAccount.set(accountId, updatedAt);
824
978
  }
825
979
 
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;
980
+ this.lastDriftSnapshot =
981
+ data.lastDriftSnapshot && typeof data.lastDriftSnapshot === 'object'
982
+ ? {
983
+ capturedAt: Number((data.lastDriftSnapshot as any).capturedAt || 0),
984
+ registerCount: Number.isFinite(Number((data.lastDriftSnapshot as any).registerCount))
985
+ ? Number((data.lastDriftSnapshot as any).registerCount)
986
+ : null,
987
+ apiGeneration: Number.isFinite(Number((data.lastDriftSnapshot as any).apiGeneration))
988
+ ? Number((data.lastDriftSnapshot as any).apiGeneration)
989
+ : null,
990
+ postWarmupRegisterCount: Number.isFinite(
991
+ Number((data.lastDriftSnapshot as any).postWarmupRegisterCount),
992
+ )
993
+ ? Number((data.lastDriftSnapshot as any).postWarmupRegisterCount)
994
+ : null,
995
+ apiInstanceId:
996
+ asString((data.lastDriftSnapshot as any).apiInstanceId || '').trim() || null,
997
+ registryFingerprint:
998
+ asString((data.lastDriftSnapshot as any).registryFingerprint || '').trim() || null,
999
+ dominantBucket:
1000
+ asString((data.lastDriftSnapshot as any).dominantBucket || '').trim() || null,
1001
+ sourceBuckets:
1002
+ (data.lastDriftSnapshot as any).sourceBuckets &&
1003
+ typeof (data.lastDriftSnapshot as any).sourceBuckets === 'object'
1004
+ ? { ...((data.lastDriftSnapshot as any).sourceBuckets as Record<string, number>) }
1005
+ : {},
1006
+ traceWindowSize: Number((data.lastDriftSnapshot as any).traceWindowSize || 0),
1007
+ traceRecent: Array.isArray((data.lastDriftSnapshot as any).traceRecent)
1008
+ ? [...((data.lastDriftSnapshot as any).traceRecent as Array<Record<string, unknown>>)]
1009
+ : [],
1010
+ }
1011
+ : null;
844
1012
 
845
1013
  // 兼容旧状态文件:若尚未持久化 lastSession*/lastActivity*,从 sessionRoutes 回填。
846
1014
  if (this.lastSessionByAccount.size === 0 && this.sessionRoutes.size > 0) {
@@ -884,24 +1052,32 @@ class BncrBridgeRuntime {
884
1052
  outbox: Array.from(this.outbox.values()),
885
1053
  deadLetter: this.deadLetter.slice(-1000),
886
1054
  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
- })),
1055
+ lastSessionByAccount: Array.from(this.lastSessionByAccount.entries()).map(
1056
+ ([accountId, v]) => ({
1057
+ accountId,
1058
+ sessionKey: v.sessionKey,
1059
+ scope: v.scope,
1060
+ updatedAt: v.updatedAt,
1061
+ }),
1062
+ ),
1063
+ lastActivityByAccount: Array.from(this.lastActivityByAccount.entries()).map(
1064
+ ([accountId, updatedAt]) => ({
1065
+ accountId,
1066
+ updatedAt,
1067
+ }),
1068
+ ),
1069
+ lastInboundByAccount: Array.from(this.lastInboundByAccount.entries()).map(
1070
+ ([accountId, updatedAt]) => ({
1071
+ accountId,
1072
+ updatedAt,
1073
+ }),
1074
+ ),
1075
+ lastOutboundByAccount: Array.from(this.lastOutboundByAccount.entries()).map(
1076
+ ([accountId, updatedAt]) => ({
1077
+ accountId,
1078
+ updatedAt,
1079
+ }),
1080
+ ),
905
1081
  lastDriftSnapshot: this.lastDriftSnapshot
906
1082
  ? {
907
1083
  capturedAt: this.lastDriftSnapshot.capturedAt,
@@ -1004,7 +1180,6 @@ class BncrBridgeRuntime {
1004
1180
  })}`,
1005
1181
  );
1006
1182
  }
1007
- this.outbox.delete(entry.messageId);
1008
1183
  this.lastOutboundByAccount.set(entry.accountId, now());
1009
1184
  this.markActivity(entry.accountId);
1010
1185
  this.scheduleSave();
@@ -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({
@@ -1070,88 +1249,6 @@ class BncrBridgeRuntime {
1070
1249
  })}`,
1071
1250
  );
1072
1251
  }
1073
- if (!online) {
1074
- const ctx = this.gatewayContext;
1075
- const directConnIds = Array.from(this.connections.values())
1076
- .filter((c) => normalizeAccountId(c.accountId) === acc && c.connId)
1077
- .map((c) => c.connId as string);
1078
-
1079
- if (BNCR_DEBUG_VERBOSE) {
1080
- this.api.logger.info?.(
1081
- `[bncr-outbox-direct-push] ${JSON.stringify({
1082
- bridge: this.bridgeId,
1083
- accountId: acc,
1084
- outboxSize: this.outbox.size,
1085
- hasGatewayContext: Boolean(ctx),
1086
- connCount: directConnIds.length,
1087
- })}`,
1088
- );
1089
- }
1090
-
1091
- if (!ctx) {
1092
- if (BNCR_DEBUG_VERBOSE) {
1093
- this.api.logger.info?.(
1094
- `[bncr-outbox-direct-push-skip] ${JSON.stringify({
1095
- bridge: this.bridgeId,
1096
- accountId: acc,
1097
- reason: 'no-gateway-context',
1098
- })}`,
1099
- );
1100
- }
1101
- continue;
1102
- }
1103
-
1104
- if (!directConnIds.length) {
1105
- if (BNCR_DEBUG_VERBOSE) {
1106
- this.api.logger.info?.(
1107
- `[bncr-outbox-direct-push-skip] ${JSON.stringify({
1108
- accountId: acc,
1109
- reason: 'no-connection',
1110
- })}`,
1111
- );
1112
- }
1113
- continue;
1114
- }
1115
-
1116
- const directPayloads = this.collectDue(acc, 50);
1117
- if (!directPayloads.length) continue;
1118
-
1119
- try {
1120
- ctx.broadcastToConnIds(BNCR_PUSH_EVENT, {
1121
- forcePush: true,
1122
- items: directPayloads,
1123
- }, new Set(directConnIds));
1124
-
1125
- const pushedIds = directPayloads
1126
- .map((item: any) => asString(item?.messageId || item?.idempotencyKey || '').trim())
1127
- .filter(Boolean);
1128
- for (const id of pushedIds) this.outbox.delete(id);
1129
- if (pushedIds.length) this.scheduleSave();
1130
-
1131
- if (BNCR_DEBUG_VERBOSE) {
1132
- this.api.logger.info?.(
1133
- `[bncr-outbox-direct-push-ok] ${JSON.stringify({
1134
- bridge: this.bridgeId,
1135
- accountId: acc,
1136
- count: directPayloads.length,
1137
- connCount: directConnIds.length,
1138
- dropped: pushedIds.length,
1139
- })}`,
1140
- );
1141
- }
1142
- } catch (error) {
1143
- if (BNCR_DEBUG_VERBOSE) {
1144
- this.api.logger.info?.(
1145
- `[bncr-outbox-direct-push-fail] ${JSON.stringify({
1146
- accountId: acc,
1147
- error: asString((error as any)?.message || error || 'direct-push-error'),
1148
- })}`,
1149
- );
1150
- }
1151
- }
1152
- continue;
1153
- }
1154
-
1155
1252
  this.pushDrainRunningAccounts.add(acc);
1156
1253
  try {
1157
1254
  let localNextDelay: number | null = null;
@@ -1163,7 +1260,6 @@ class BncrBridgeRuntime {
1163
1260
  .sort((a, b) => a.createdAt - b.createdAt);
1164
1261
 
1165
1262
  if (!entries.length) break;
1166
- if (!this.isOnline(acc)) break;
1167
1263
 
1168
1264
  const entry = entries.find((item) => item.nextAttemptAt <= t);
1169
1265
  if (!entry) {
@@ -1172,11 +1268,33 @@ class BncrBridgeRuntime {
1172
1268
  break;
1173
1269
  }
1174
1270
 
1271
+ const onlineNow = this.isOnline(acc);
1175
1272
  const pushed = this.tryPushEntry(entry);
1176
1273
  if (pushed) {
1274
+ if (onlineNow) {
1275
+ await this.waitForOutbound(acc, PUSH_ACK_TIMEOUT_MS);
1276
+ }
1277
+
1278
+ if (!this.outbox.has(entry.messageId)) {
1279
+ await this.sleepMs(PUSH_DRAIN_INTERVAL_MS);
1280
+ continue;
1281
+ }
1282
+
1283
+ entry.retryCount += 1;
1284
+ entry.lastAttemptAt = now();
1285
+ if (entry.retryCount > MAX_RETRY) {
1286
+ this.moveToDeadLetter(entry, entry.lastError || 'push-ack-timeout');
1287
+ continue;
1288
+ }
1289
+ entry.nextAttemptAt = now() + backoffMs(entry.retryCount);
1290
+ entry.lastError = entry.lastError || 'push-ack-timeout';
1291
+ this.outbox.set(entry.messageId, entry);
1177
1292
  this.scheduleSave();
1293
+
1294
+ const wait = Math.max(0, entry.nextAttemptAt - now());
1295
+ localNextDelay = localNextDelay == null ? wait : Math.min(localNextDelay, wait);
1178
1296
  await this.sleepMs(PUSH_DRAIN_INTERVAL_MS);
1179
- continue;
1297
+ break;
1180
1298
  }
1181
1299
 
1182
1300
  const nextAttempt = entry.retryCount + 1;
@@ -1198,7 +1316,8 @@ class BncrBridgeRuntime {
1198
1316
  }
1199
1317
 
1200
1318
  if (localNextDelay != null) {
1201
- globalNextDelay = globalNextDelay == null ? localNextDelay : Math.min(globalNextDelay, localNextDelay);
1319
+ globalNextDelay =
1320
+ globalNextDelay == null ? localNextDelay : Math.min(globalNextDelay, localNextDelay);
1202
1321
  }
1203
1322
  } finally {
1204
1323
  this.pushDrainRunningAccounts.delete(acc);
@@ -1320,7 +1439,11 @@ class BncrBridgeRuntime {
1320
1439
  }
1321
1440
 
1322
1441
  const curConn = this.connections.get(current);
1323
- if (!curConn || t - curConn.lastSeenAt > CONNECT_TTL_MS || nextConn.connectedAt >= curConn.connectedAt) {
1442
+ if (
1443
+ !curConn ||
1444
+ t - curConn.lastSeenAt > CONNECT_TTL_MS ||
1445
+ nextConn.connectedAt >= curConn.connectedAt
1446
+ ) {
1324
1447
  this.activeConnectionByAccount.set(acc, key);
1325
1448
  }
1326
1449
  }
@@ -1372,9 +1495,6 @@ class BncrBridgeRuntime {
1372
1495
  const info = { accountId: acc, route, updatedAt: t };
1373
1496
 
1374
1497
  this.sessionRoutes.set(key, info);
1375
- // 同步维护旧格式与新格式,便于平滑切换
1376
- this.sessionRoutes.set(buildFallbackSessionKey(route), info);
1377
-
1378
1498
  this.routeAliases.set(routeKey(acc, route), info);
1379
1499
  this.lastSessionByAccount.set(acc, {
1380
1500
  sessionKey: key,
@@ -1404,7 +1524,10 @@ class BncrBridgeRuntime {
1404
1524
  // 1) 标准 to 仅认 Bncr:<platform>:<groupId>:<userId> / Bncr:<platform>:<userId>
1405
1525
  // 2) 仍接受 strict sessionKey 作为内部兼容输入
1406
1526
  // 3) 其他旧格式直接失败,并输出标准格式提示日志
1407
- private resolveVerifiedTarget(rawTarget: string, accountId: string): { sessionKey: string; route: BncrRoute; displayScope: string } {
1527
+ private resolveVerifiedTarget(
1528
+ rawTarget: string,
1529
+ accountId: string,
1530
+ ): { sessionKey: string; route: BncrRoute; displayScope: string } {
1408
1531
  const acc = normalizeAccountId(accountId);
1409
1532
  const raw = asString(rawTarget).trim();
1410
1533
  if (!raw) throw new Error('bncr invalid target(empty)');
@@ -1424,17 +1547,23 @@ class BncrBridgeRuntime {
1424
1547
 
1425
1548
  if (!route) {
1426
1549
  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)>`,
1550
+ `[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)>`,
1551
+ );
1552
+ throw new Error(
1553
+ `bncr invalid target(standard: Bncr:<platform>:<groupId>:<userId> | Bncr:<platform>:<userId>): ${raw}`,
1428
1554
  );
1429
- throw new Error(`bncr invalid target(standard: Bncr:<platform>:<groupId>:<userId> | Bncr:<platform>:<userId>): ${raw}`);
1430
1555
  }
1431
1556
 
1432
1557
  const wantedRouteKey = routeKey(acc, route);
1433
1558
  let best: { sessionKey: string; route: BncrRoute; updatedAt: number } | null = null;
1434
1559
 
1435
1560
  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())}`);
1561
+ this.api.logger.info?.(
1562
+ `[bncr-target-incoming-route] raw=${raw} accountId=${acc} route=${JSON.stringify(route)}`,
1563
+ );
1564
+ this.api.logger.info?.(
1565
+ `[bncr-target-incoming-sessionRoutes] raw=${raw} accountId=${acc} sessionRoutes=${JSON.stringify(this.sessionRoutes.entries())}`,
1566
+ );
1438
1567
  }
1439
1568
 
1440
1569
  for (const [key, info] of this.sessionRoutes.entries()) {
@@ -1446,29 +1575,40 @@ class BncrBridgeRuntime {
1446
1575
  const updatedAt = Number(info.updatedAt || 0);
1447
1576
  if (!best || updatedAt >= best.updatedAt) {
1448
1577
  best = {
1449
- sessionKey: parsed.sessionKey,
1578
+ sessionKey: key,
1450
1579
  route: parsed.route,
1451
1580
  updatedAt,
1452
1581
  };
1453
1582
  }
1454
1583
  }
1455
1584
 
1456
- // 直接根据raw生成标准sessionkey
1457
1585
  if (!best) {
1458
1586
  const updatedAt = 0;
1587
+ const canonicalAgentId =
1588
+ this.canonicalAgentId ||
1589
+ this.ensureCanonicalAgentId({
1590
+ cfg: this.api.runtime.config?.get?.() || {},
1591
+ accountId: acc,
1592
+ channelId: CHANNEL_ID,
1593
+ peer: { kind: 'direct', id: route.groupId === '0' ? route.userId : route.groupId },
1594
+ });
1459
1595
  best = {
1460
- sessionKey: `agent:main:bncr:direct:${routeScopeToHex(route)}`,
1596
+ sessionKey: buildCanonicalBncrSessionKey(route, canonicalAgentId),
1461
1597
  route,
1462
1598
  updatedAt,
1463
1599
  };
1464
1600
  }
1465
1601
 
1466
1602
  if (BNCR_DEBUG_VERBOSE) {
1467
- this.api.logger.info?.(`[bncr-target-incoming-best] raw=${raw} accountId=${acc} best=${JSON.stringify(best)}`);
1603
+ this.api.logger.info?.(
1604
+ `[bncr-target-incoming-best] raw=${raw} accountId=${acc} best=${JSON.stringify(best)}`,
1605
+ );
1468
1606
  }
1469
1607
 
1470
1608
  if (!best) {
1471
- this.api.logger.warn?.(`[bncr-target-miss] raw=${raw} accountId=${acc} sessionRoutes=${this.sessionRoutes.size}`);
1609
+ this.api.logger.warn?.(
1610
+ `[bncr-target-miss] raw=${raw} accountId=${acc} sessionRoutes=${this.sessionRoutes.size}`,
1611
+ );
1472
1612
  throw new Error(`bncr target not found in known sessions: ${raw}`);
1473
1613
  }
1474
1614
 
@@ -1496,11 +1636,19 @@ class BncrBridgeRuntime {
1496
1636
  return `${transferId}|${stage}|${idx}`;
1497
1637
  }
1498
1638
 
1499
- private waitForFileAck(params: { transferId: string; stage: string; chunkIndex?: number; timeoutMs?: number }) {
1639
+ private waitForFileAck(params: {
1640
+ transferId: string;
1641
+ stage: string;
1642
+ chunkIndex?: number;
1643
+ timeoutMs?: number;
1644
+ }) {
1500
1645
  const transferId = asString(params.transferId).trim();
1501
1646
  const stage = asString(params.stage).trim();
1502
1647
  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));
1648
+ const timeoutMs = Math.max(
1649
+ 1_000,
1650
+ Math.min(Number(params.timeoutMs || FILE_ACK_TIMEOUT_MS), 120_000),
1651
+ );
1504
1652
 
1505
1653
  return new Promise<Record<string, unknown>>((resolve, reject) => {
1506
1654
  const timer = setTimeout(() => {
@@ -1511,7 +1659,13 @@ class BncrBridgeRuntime {
1511
1659
  });
1512
1660
  }
1513
1661
 
1514
- private resolveFileAck(params: { transferId: string; stage: string; chunkIndex?: number; payload: Record<string, unknown>; ok: boolean }) {
1662
+ private resolveFileAck(params: {
1663
+ transferId: string;
1664
+ stage: string;
1665
+ chunkIndex?: number;
1666
+ payload: Record<string, unknown>;
1667
+ ok: boolean;
1668
+ }) {
1515
1669
  const transferId = asString(params.transferId).trim();
1516
1670
  const stage = asString(params.stage).trim();
1517
1671
  const key = this.fileAckKey(transferId, stage, params.chunkIndex);
@@ -1520,11 +1674,20 @@ class BncrBridgeRuntime {
1520
1674
  this.fileAckWaiters.delete(key);
1521
1675
  clearTimeout(waiter.timer);
1522
1676
  if (params.ok) waiter.resolve(params.payload);
1523
- else waiter.reject(new Error(asString(params.payload?.errorMessage || params.payload?.error || 'file ack failed')));
1677
+ else
1678
+ waiter.reject(
1679
+ new Error(
1680
+ asString(params.payload?.errorMessage || params.payload?.error || 'file ack failed'),
1681
+ ),
1682
+ );
1524
1683
  return true;
1525
1684
  }
1526
1685
 
1527
- private pushFileEventToAccount(accountId: string, event: string, payload: Record<string, unknown>) {
1686
+ private pushFileEventToAccount(
1687
+ accountId: string,
1688
+ event: string,
1689
+ payload: Record<string, unknown>,
1690
+ ) {
1528
1691
  const connIds = this.resolvePushConnIds(accountId);
1529
1692
  if (!connIds.size || !this.gatewayContext) {
1530
1693
  throw new Error(`no active bncr connection for account=${accountId}`);
@@ -1547,7 +1710,9 @@ class BncrBridgeRuntime {
1547
1710
  return dir;
1548
1711
  }
1549
1712
 
1550
- private async materializeRecvTransfer(st: FileRecvTransferState): Promise<{ path: string; fileSha256: string }> {
1713
+ private async materializeRecvTransfer(
1714
+ st: FileRecvTransferState,
1715
+ ): Promise<{ path: string; fileSha256: string }> {
1551
1716
  const dir = this.resolveInboundFilesDir();
1552
1717
  const safeName = asString(st.fileName).trim() || `${st.transferId}.bin`;
1553
1718
  const finalPath = path.join(dir, safeName);
@@ -1589,7 +1754,8 @@ class BncrBridgeRuntime {
1589
1754
  lastActivityAt: this.lastActivityByAccount.get(acc) || null,
1590
1755
  lastInboundAt: this.lastInboundByAccount.get(acc) || null,
1591
1756
  lastOutboundAt: this.lastOutboundByAccount.get(acc) || null,
1592
- sessionRoutesCount: Array.from(this.sessionRoutes.values()).filter((v) => v.accountId === acc).length,
1757
+ sessionRoutesCount: Array.from(this.sessionRoutes.values()).filter((v) => v.accountId === acc)
1758
+ .length,
1593
1759
  invalidOutboxSessionKeys: this.countInvalidOutboxSessionKeys(acc),
1594
1760
  legacyAccountResidue: this.countLegacyAccountResidue(acc),
1595
1761
  channelRoot: path.join(process.cwd(), 'plugins', 'bncr'),
@@ -1613,7 +1779,8 @@ class BncrBridgeRuntime {
1613
1779
  lastActivityAt: this.lastActivityByAccount.get(acc) || null,
1614
1780
  lastInboundAt: this.lastInboundByAccount.get(acc) || null,
1615
1781
  lastOutboundAt: this.lastOutboundByAccount.get(acc) || null,
1616
- sessionRoutesCount: Array.from(this.sessionRoutes.values()).filter((v) => v.accountId === acc).length,
1782
+ sessionRoutesCount: Array.from(this.sessionRoutes.values()).filter((v) => v.accountId === acc)
1783
+ .length,
1617
1784
  invalidOutboxSessionKeys: this.countInvalidOutboxSessionKeys(acc),
1618
1785
  legacyAccountResidue: this.countLegacyAccountResidue(acc),
1619
1786
  running: true,
@@ -1638,7 +1805,8 @@ class BncrBridgeRuntime {
1638
1805
  lastActivityAt: this.lastActivityByAccount.get(acc) || null,
1639
1806
  lastInboundAt: this.lastInboundByAccount.get(acc) || null,
1640
1807
  lastOutboundAt: this.lastOutboundByAccount.get(acc) || null,
1641
- sessionRoutesCount: Array.from(this.sessionRoutes.values()).filter((v) => v.accountId === acc).length,
1808
+ sessionRoutesCount: Array.from(this.sessionRoutes.values()).filter((v) => v.accountId === acc)
1809
+ .length,
1642
1810
  invalidOutboxSessionKeys: this.countInvalidOutboxSessionKeys(acc),
1643
1811
  legacyAccountResidue: this.countLegacyAccountResidue(acc),
1644
1812
  channelRoot: path.join(process.cwd(), 'plugins', 'bncr'),
@@ -1763,7 +1931,10 @@ class BncrBridgeRuntime {
1763
1931
  timeoutMs?: number;
1764
1932
  }): Promise<void> {
1765
1933
  const { transferId, chunkIndex } = params;
1766
- const timeoutMs = Math.max(1_000, Math.min(Number(params.timeoutMs || FILE_TRANSFER_ACK_TTL_MS), 60_000));
1934
+ const timeoutMs = Math.max(
1935
+ 1_000,
1936
+ Math.min(Number(params.timeoutMs || FILE_TRANSFER_ACK_TTL_MS), 60_000),
1937
+ );
1767
1938
  const started = now();
1768
1939
 
1769
1940
  return new Promise<void>((resolve, reject) => {
@@ -1832,7 +2003,13 @@ class BncrBridgeRuntime {
1832
2003
  route: BncrRoute;
1833
2004
  mediaUrl: string;
1834
2005
  mediaLocalRoots?: readonly string[];
1835
- }): Promise<{ mode: 'base64' | 'chunk'; mimeType?: string; fileName?: string; mediaBase64?: string; path?: string }> {
2006
+ }): Promise<{
2007
+ mode: 'base64' | 'chunk';
2008
+ mimeType?: string;
2009
+ fileName?: string;
2010
+ mediaBase64?: string;
2011
+ path?: string;
2012
+ }> {
1836
2013
  const loaded = await this.api.runtime.media.loadWebMedia(params.mediaUrl, {
1837
2014
  localRoots: params.mediaLocalRoots,
1838
2015
  maxBytes: 50 * 1024 * 1024,
@@ -1884,21 +2061,25 @@ class BncrBridgeRuntime {
1884
2061
  };
1885
2062
  this.fileSendTransfers.set(transferId, st);
1886
2063
 
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);
2064
+ ctx.broadcastToConnIds(
2065
+ 'bncr.file.init',
2066
+ {
2067
+ transferId,
2068
+ direction: 'oc2bncr',
2069
+ sessionKey: params.sessionKey,
2070
+ platform: params.route.platform,
2071
+ groupId: params.route.groupId,
2072
+ userId: params.route.userId,
2073
+ fileName,
2074
+ mimeType,
2075
+ fileSize: size,
2076
+ chunkSize,
2077
+ totalChunks,
2078
+ fileSha256,
2079
+ ts: now(),
2080
+ },
2081
+ connIds,
2082
+ );
1902
2083
 
1903
2084
  // 逐块发送并等待 ACK
1904
2085
  for (let idx = 0; idx < totalChunks; idx++) {
@@ -1910,18 +2091,26 @@ class BncrBridgeRuntime {
1910
2091
  let ok = false;
1911
2092
  let lastErr: unknown = null;
1912
2093
  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);
2094
+ ctx.broadcastToConnIds(
2095
+ 'bncr.file.chunk',
2096
+ {
2097
+ transferId,
2098
+ chunkIndex: idx,
2099
+ offset: start,
2100
+ size: slice.byteLength,
2101
+ chunkSha256,
2102
+ base64: slice.toString('base64'),
2103
+ ts: now(),
2104
+ },
2105
+ connIds,
2106
+ );
1922
2107
 
1923
2108
  try {
1924
- await this.waitChunkAck({ transferId, chunkIndex: idx, timeoutMs: FILE_TRANSFER_ACK_TTL_MS });
2109
+ await this.waitChunkAck({
2110
+ transferId,
2111
+ chunkIndex: idx,
2112
+ timeoutMs: FILE_TRANSFER_ACK_TTL_MS,
2113
+ });
1925
2114
  ok = true;
1926
2115
  break;
1927
2116
  } catch (err) {
@@ -1934,19 +2123,27 @@ class BncrBridgeRuntime {
1934
2123
  st.status = 'aborted';
1935
2124
  st.error = String((lastErr as any)?.message || lastErr || `chunk-${idx}-failed`);
1936
2125
  this.fileSendTransfers.set(transferId, st);
1937
- ctx.broadcastToConnIds('bncr.file.abort', {
1938
- transferId,
1939
- reason: st.error,
1940
- ts: now(),
1941
- }, connIds);
2126
+ ctx.broadcastToConnIds(
2127
+ 'bncr.file.abort',
2128
+ {
2129
+ transferId,
2130
+ reason: st.error,
2131
+ ts: now(),
2132
+ },
2133
+ connIds,
2134
+ );
1942
2135
  throw new Error(st.error);
1943
2136
  }
1944
2137
  }
1945
2138
 
1946
- ctx.broadcastToConnIds('bncr.file.complete', {
1947
- transferId,
1948
- ts: now(),
1949
- }, connIds);
2139
+ ctx.broadcastToConnIds(
2140
+ 'bncr.file.complete',
2141
+ {
2142
+ transferId,
2143
+ ts: now(),
2144
+ },
2145
+ connIds,
2146
+ );
1950
2147
 
1951
2148
  const done = await this.waitCompleteAck({ transferId, timeoutMs: 60_000 });
1952
2149
 
@@ -1962,7 +2159,14 @@ class BncrBridgeRuntime {
1962
2159
  accountId: string;
1963
2160
  sessionKey: string;
1964
2161
  route: BncrRoute;
1965
- payload: { text?: string; mediaUrl?: string; mediaUrls?: string[]; asVoice?: boolean; audioAsVoice?: boolean; kind?: 'block' | 'final' };
2162
+ payload: {
2163
+ text?: string;
2164
+ mediaUrl?: string;
2165
+ mediaUrls?: string[];
2166
+ asVoice?: boolean;
2167
+ audioAsVoice?: boolean;
2168
+ kind?: 'tool' | 'block' | 'final';
2169
+ };
1966
2170
  mediaLocalRoots?: readonly string[];
1967
2171
  }) {
1968
2172
  const { accountId, sessionKey, route, payload, mediaLocalRoots } = params;
@@ -2144,6 +2348,7 @@ class BncrBridgeRuntime {
2144
2348
  if (ok) {
2145
2349
  this.outbox.delete(messageId);
2146
2350
  this.scheduleSave();
2351
+ this.wakeAccountWaiters(accountId);
2147
2352
  respond(true, { ok: true });
2148
2353
  return;
2149
2354
  }
@@ -2270,11 +2475,12 @@ class BncrBridgeRuntime {
2270
2475
  return;
2271
2476
  }
2272
2477
 
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;
2478
+ const route =
2479
+ parseRouteLike({
2480
+ platform: asString(params?.platform || normalized.route.platform),
2481
+ groupId: asString(params?.groupId || normalized.route.groupId),
2482
+ userId: asString(params?.userId || normalized.route.userId),
2483
+ }) || normalized.route;
2278
2484
 
2279
2485
  this.fileRecvTransfers.set(transferId, {
2280
2486
  transferId,
@@ -2354,7 +2560,12 @@ class BncrBridgeRuntime {
2354
2560
  }
2355
2561
  };
2356
2562
 
2357
- handleFileComplete = async ({ params, respond, client, context }: GatewayRequestHandlerOptions) => {
2563
+ handleFileComplete = async ({
2564
+ params,
2565
+ respond,
2566
+ client,
2567
+ context,
2568
+ }: GatewayRequestHandlerOptions) => {
2358
2569
  const accountId = normalizeAccountId(asString(params?.accountId || ''));
2359
2570
  const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
2360
2571
  const clientId = asString((params as any)?.clientId || '').trim() || undefined;
@@ -2377,10 +2588,14 @@ class BncrBridgeRuntime {
2377
2588
 
2378
2589
  try {
2379
2590
  if (st.receivedChunks.size < st.totalChunks) {
2380
- throw new Error(`chunk not complete received=${st.receivedChunks.size} total=${st.totalChunks}`);
2591
+ throw new Error(
2592
+ `chunk not complete received=${st.receivedChunks.size} total=${st.totalChunks}`,
2593
+ );
2381
2594
  }
2382
2595
 
2383
- const ordered = Array.from(st.bufferByChunk.entries()).sort((a, b) => a[0] - b[0]).map((x) => x[1]);
2596
+ const ordered = Array.from(st.bufferByChunk.entries())
2597
+ .sort((a, b) => a[0] - b[0])
2598
+ .map((x) => x[1]);
2384
2599
  const merged = Buffer.concat(ordered);
2385
2600
  if (st.fileSize > 0 && merged.length !== st.fileSize) {
2386
2601
  throw new Error(`file size mismatch expected=${st.fileSize} got=${merged.length}`);
@@ -2515,7 +2730,24 @@ class BncrBridgeRuntime {
2515
2730
 
2516
2731
  handleInbound = async ({ params, respond, client, context }: GatewayRequestHandlerOptions) => {
2517
2732
  const parsed = parseBncrInboundParams(params);
2518
- const { accountId, platform, groupId, userId, sessionKeyfromroute, route, text, msgType, mediaBase64, mediaPathFromTransfer, mimeType, fileName, msgId, dedupKey, peer, extracted } = parsed;
2733
+ const {
2734
+ accountId,
2735
+ platform,
2736
+ groupId,
2737
+ userId,
2738
+ sessionKeyfromroute,
2739
+ route,
2740
+ text,
2741
+ msgType,
2742
+ mediaBase64,
2743
+ mediaPathFromTransfer,
2744
+ mimeType,
2745
+ fileName,
2746
+ msgId,
2747
+ dedupKey,
2748
+ peer,
2749
+ extracted,
2750
+ } = parsed;
2519
2751
  const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
2520
2752
  const clientId = asString((params as any)?.clientId || '').trim() || undefined;
2521
2753
  this.rememberGatewayContext(context);
@@ -2555,13 +2787,21 @@ class BncrBridgeRuntime {
2555
2787
  return;
2556
2788
  }
2557
2789
 
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;
2790
+ const canonicalAgentId = this.ensureCanonicalAgentId({
2791
+ cfg,
2792
+ accountId,
2793
+ peer,
2794
+ channelId: CHANNEL_ID,
2795
+ });
2796
+ const resolvedRoute = this.api.runtime.channel.routing.resolveAgentRoute({
2797
+ cfg,
2798
+ channel: CHANNEL_ID,
2799
+ accountId,
2800
+ peer,
2801
+ });
2802
+ const baseSessionKey =
2803
+ normalizeInboundSessionKey(sessionKeyfromroute, route, canonicalAgentId) ||
2804
+ resolvedRoute.sessionKey;
2565
2805
  const taskSessionKey = withTaskSessionKey(baseSessionKey, extracted.taskKey);
2566
2806
  const sessionKey = taskSessionKey || baseSessionKey;
2567
2807
 
@@ -2578,7 +2818,9 @@ class BncrBridgeRuntime {
2578
2818
  channelId: CHANNEL_ID,
2579
2819
  cfg,
2580
2820
  parsed,
2581
- rememberSessionRoute: (sessionKey, accountId, route) => this.rememberSessionRoute(sessionKey, accountId, route),
2821
+ canonicalAgentId,
2822
+ rememberSessionRoute: (sessionKey, accountId, route) =>
2823
+ this.rememberSessionRoute(sessionKey, accountId, route),
2582
2824
  enqueueFromReply: (args) => this.enqueueFromReply(args),
2583
2825
  setInboundActivity: (accountId, at) => {
2584
2826
  this.lastInboundByAccount.set(accountId, at);
@@ -2664,7 +2906,8 @@ class BncrBridgeRuntime {
2664
2906
  text: asString(ctx.text || ''),
2665
2907
  mediaLocalRoots: ctx.mediaLocalRoots,
2666
2908
  resolveVerifiedTarget: (to, accountId) => this.resolveVerifiedTarget(to, accountId),
2667
- rememberSessionRoute: (sessionKey, accountId, route) => this.rememberSessionRoute(sessionKey, accountId, route),
2909
+ rememberSessionRoute: (sessionKey, accountId, route) =>
2910
+ this.rememberSessionRoute(sessionKey, accountId, route),
2668
2911
  enqueueFromReply: (args) => this.enqueueFromReply(args),
2669
2912
  createMessageId: () => randomUUID(),
2670
2913
  });
@@ -2708,7 +2951,8 @@ class BncrBridgeRuntime {
2708
2951
  audioAsVoice,
2709
2952
  mediaLocalRoots: ctx.mediaLocalRoots,
2710
2953
  resolveVerifiedTarget: (to, accountId) => this.resolveVerifiedTarget(to, accountId),
2711
- rememberSessionRoute: (sessionKey, accountId, route) => this.rememberSessionRoute(sessionKey, accountId, route),
2954
+ rememberSessionRoute: (sessionKey, accountId, route) =>
2955
+ this.rememberSessionRoute(sessionKey, accountId, route),
2712
2956
  enqueueFromReply: (args) => this.enqueueFromReply(args),
2713
2957
  createMessageId: () => randomUUID(),
2714
2958
  });
@@ -2723,10 +2967,13 @@ export function createBncrChannelPlugin(bridge: BncrBridgeRuntime) {
2723
2967
  const messageActions: ChannelMessageActionAdapter = {
2724
2968
  describeMessageTool: ({ cfg }) => {
2725
2969
  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);
2970
+ const hasExplicitConfiguredAccount =
2971
+ Boolean(channelCfg && typeof channelCfg === 'object') &&
2972
+ resolveBncrChannelPolicy(channelCfg).enabled !== false &&
2973
+ Boolean(channelCfg.accounts && typeof channelCfg.accounts === 'object') &&
2974
+ Object.keys(channelCfg.accounts).some(
2975
+ (accountId) => resolveAccount(cfg, accountId).enabled !== false,
2976
+ );
2730
2977
 
2731
2978
  const hasConnectedRuntime = listAccountIds(cfg).some((accountId) => {
2732
2979
  const resolved = resolveAccount(cfg, accountId);
@@ -2746,7 +2993,8 @@ export function createBncrChannelPlugin(bridge: BncrBridgeRuntime) {
2746
2993
  supportsAction: ({ action }) => action === 'send',
2747
2994
  extractToolSend: ({ args }) => extractToolSend(args, 'sendMessage'),
2748
2995
  handleAction: async ({ action, params, accountId, mediaLocalRoots }) => {
2749
- if (action !== 'send') throw new Error(`Action ${action} is not supported for provider ${CHANNEL_ID}.`);
2996
+ if (action !== 'send')
2997
+ throw new Error(`Action ${action} is not supported for provider ${CHANNEL_ID}.`);
2750
2998
  const to = readStringParam(params, 'to', { required: true });
2751
2999
  const message = readStringParam(params, 'message', { allowEmpty: true }) ?? '';
2752
3000
  const caption = readStringParam(params, 'caption', { allowEmpty: true }) ?? '';
@@ -2758,36 +3006,40 @@ export function createBncrChannelPlugin(bridge: BncrBridgeRuntime) {
2758
3006
  readStringParam(params, 'mediaUrl', { trim: false });
2759
3007
  const asVoice = readBooleanParam(params, 'asVoice') ?? false;
2760
3008
  const audioAsVoice = readBooleanParam(params, 'audioAsVoice') ?? false;
2761
- const resolvedAccountId = normalizeAccountId(readStringParam(params, 'accountId') ?? accountId);
3009
+ const resolvedAccountId = normalizeAccountId(
3010
+ readStringParam(params, 'accountId') ?? accountId,
3011
+ );
2762
3012
 
2763
3013
  if (!content.trim() && !mediaUrl) throw new Error('send requires text or media');
2764
3014
 
2765
3015
  const result = mediaUrl
2766
3016
  ? 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
- })
3017
+ channelId: CHANNEL_ID,
3018
+ accountId: resolvedAccountId,
3019
+ to,
3020
+ text: content,
3021
+ mediaUrl,
3022
+ asVoice,
3023
+ audioAsVoice,
3024
+ mediaLocalRoots,
3025
+ resolveVerifiedTarget: (to, accountId) => bridge.resolveVerifiedTarget(to, accountId),
3026
+ rememberSessionRoute: (sessionKey, accountId, route) =>
3027
+ bridge.rememberSessionRoute(sessionKey, accountId, route),
3028
+ enqueueFromReply: (args) => bridge.enqueueFromReply(args as any),
3029
+ createMessageId: () => randomUUID(),
3030
+ })
2780
3031
  : 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
- });
3032
+ channelId: CHANNEL_ID,
3033
+ accountId: resolvedAccountId,
3034
+ to,
3035
+ text: content,
3036
+ mediaLocalRoots,
3037
+ resolveVerifiedTarget: (to, accountId) => bridge.resolveVerifiedTarget(to, accountId),
3038
+ rememberSessionRoute: (sessionKey, accountId, route) =>
3039
+ bridge.rememberSessionRoute(sessionKey, accountId, route),
3040
+ enqueueFromReply: (args) => bridge.enqueueFromReply(args as any),
3041
+ createMessageId: () => randomUUID(),
3042
+ });
2791
3043
 
2792
3044
  return jsonResult({ ok: true, ...result });
2793
3045
  },
@@ -2820,7 +3072,7 @@ export function createBncrChannelPlugin(bridge: BncrBridgeRuntime) {
2820
3072
  looksLikeId: (raw: string, normalized?: string) => {
2821
3073
  return Boolean(asString(normalized || raw).trim());
2822
3074
  },
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>',
3075
+ 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
3076
  },
2825
3077
  },
2826
3078
  configSchema: BncrConfigSchema,
@@ -2873,30 +3125,35 @@ export function createBncrChannelPlugin(bridge: BncrBridgeRuntime) {
2873
3125
  },
2874
3126
  outbound: {
2875
3127
  deliveryMode: 'gateway' as const,
2876
- textChunkLimit: 4000,
2877
3128
  sendText: bridge.channelSendText,
2878
3129
  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
- }),
3130
+ replyAction: async (ctx: any) =>
3131
+ sendBncrReplyAction({
3132
+ accountId: normalizeAccountId(ctx?.accountId),
3133
+ to: asString(ctx?.to || '').trim(),
3134
+ text: asString(ctx?.text || ''),
3135
+ replyToMessageId:
3136
+ asString(ctx?.replyToId || ctx?.replyToMessageId || '').trim() || undefined,
3137
+ sendText: async ({ accountId, to, text }) =>
3138
+ bridge.channelSendText({ accountId, to, text }),
3139
+ }),
3140
+ deleteAction: async (ctx: any) =>
3141
+ deleteBncrMessageAction({
3142
+ accountId: normalizeAccountId(ctx?.accountId),
3143
+ targetMessageId: asString(ctx?.messageId || ctx?.targetMessageId || '').trim(),
3144
+ }),
3145
+ reactAction: async (ctx: any) =>
3146
+ reactBncrMessageAction({
3147
+ accountId: normalizeAccountId(ctx?.accountId),
3148
+ targetMessageId: asString(ctx?.messageId || ctx?.targetMessageId || '').trim(),
3149
+ emoji: asString(ctx?.emoji || '').trim(),
3150
+ }),
3151
+ editAction: async (ctx: any) =>
3152
+ editBncrMessageAction({
3153
+ accountId: normalizeAccountId(ctx?.accountId),
3154
+ targetMessageId: asString(ctx?.messageId || ctx?.targetMessageId || '').trim(),
3155
+ text: asString(ctx?.text || ''),
3156
+ }),
2900
3157
  },
2901
3158
  status: {
2902
3159
  defaultRuntime: createDefaultChannelRuntimeState(BNCR_DEFAULT_ACCOUNT_ID, {
@@ -2923,9 +3180,7 @@ export function createBncrChannelPlugin(bridge: BncrBridgeRuntime) {
2923
3180
  const lastOutboundAgo = rt?.lastOutboundAgo ?? meta.lastOutboundAgo ?? '-';
2924
3181
  const diagnostics = rt?.diagnostics ?? meta.diagnostics ?? null;
2925
3182
  // 右侧状态字段统一:离线时也显示 Status(避免出现 configured 文案)
2926
- const normalizedMode = rt?.mode === 'linked'
2927
- ? 'linked'
2928
- : 'Status';
3183
+ const normalizedMode = rt?.mode === 'linked' ? 'linked' : 'Status';
2929
3184
 
2930
3185
  const displayName = resolveDefaultDisplayName(account?.name, account?.accountId);
2931
3186