@xmoxmo/bncr 0.1.1 → 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;
@@ -63,6 +77,7 @@ const FILE_CHUNK_RETRY = 3;
63
77
  const FILE_ACK_TIMEOUT_MS = 30_000;
64
78
  const FILE_TRANSFER_ACK_TTL_MS = 30_000;
65
79
  const FILE_TRANSFER_KEEP_MS = 6 * 60 * 60 * 1000;
80
+ const REGISTER_WARMUP_WINDOW_MS = 30_000;
66
81
  let BNCR_DEBUG_VERBOSE = false; // 全局调试日志开关(默认关闭)
67
82
 
68
83
  type FileSendTransferState = {
@@ -130,6 +145,18 @@ type PersistedState = {
130
145
  accountId: string;
131
146
  updatedAt: number;
132
147
  }>;
148
+ lastDriftSnapshot?: {
149
+ capturedAt: number;
150
+ registerCount: number | null;
151
+ apiGeneration: number | null;
152
+ postWarmupRegisterCount: number | null;
153
+ apiInstanceId: string | null;
154
+ registryFingerprint: string | null;
155
+ dominantBucket: string | null;
156
+ sourceBuckets: Record<string, number>;
157
+ traceWindowSize: number;
158
+ traceRecent: Array<Record<string, unknown>>;
159
+ } | null;
133
160
  };
134
161
 
135
162
  function now() {
@@ -142,13 +169,11 @@ function asString(v: unknown, fallback = ''): string {
142
169
  return String(v);
143
170
  }
144
171
 
145
-
146
172
  function backoffMs(retryCount: number): number {
147
173
  // 1s,2s,4s,8s... capped by retry count checks
148
174
  return Math.max(1_000, 1_000 * 2 ** Math.max(0, retryCount - 1));
149
175
  }
150
176
 
151
-
152
177
  function fileExtFromMime(mimeType?: string): string {
153
178
  const mt = asString(mimeType || '').toLowerCase();
154
179
  const map: Record<string, string> = {
@@ -171,7 +196,15 @@ function fileExtFromMime(mimeType?: string): string {
171
196
  function sanitizeFileName(rawName?: string, fallback = 'file.bin'): string {
172
197
  const name = asString(rawName || '').trim();
173
198
  const base = name || fallback;
174
- 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();
175
208
  return cleaned || fallback;
176
209
  }
177
210
 
@@ -183,7 +216,11 @@ function buildTimestampFileName(mimeType?: string): string {
183
216
  return `bncr_${ts}_${Math.random().toString(16).slice(2, 8)}${ext}`;
184
217
  }
185
218
 
186
- 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 {
187
224
  const mediaUrl = asString(params.mediaUrl || '').trim();
188
225
  const mimeType = asString(params.mimeType || '').trim();
189
226
 
@@ -205,20 +242,85 @@ class BncrBridgeRuntime {
205
242
  private api: OpenClawPluginApi;
206
243
  private statePath: string | null = null;
207
244
  private bridgeId = `${process.pid}-${Math.random().toString(16).slice(2, 8)}`;
245
+ private gatewayPid = process.pid;
246
+ private registerCount = 0;
247
+ private apiGeneration = 0;
248
+ private firstRegisterAt: number | null = null;
249
+ private lastRegisterAt: number | null = null;
250
+ private lastApiRebindAt: number | null = null;
251
+ private pluginSource: string | null = null;
252
+ private pluginVersion: string | null = null;
253
+ private connectionEpoch = 0;
254
+ private primaryLeaseId: string | null = null;
255
+ private acceptedConnections = 0;
256
+ private lastConnectAt: number | null = null;
257
+ private lastDisconnectAt: number | null = null;
258
+ private lastInboundAtGlobal: number | null = null;
259
+ private lastActivityAtGlobal: number | null = null;
260
+ private lastAckAtGlobal: number | null = null;
261
+ private recentConnections = new Map<
262
+ string,
263
+ {
264
+ epoch: number;
265
+ connectedAt: number;
266
+ lastActivityAt: number | null;
267
+ isPrimary: boolean;
268
+ }
269
+ >();
270
+ private staleCounters = {
271
+ staleConnect: 0,
272
+ staleInbound: 0,
273
+ staleActivity: 0,
274
+ staleAck: 0,
275
+ staleFileInit: 0,
276
+ staleFileChunk: 0,
277
+ staleFileComplete: 0,
278
+ staleFileAbort: 0,
279
+ lastStaleAt: null as number | null,
280
+ };
281
+ private lastApiInstanceId: string | null = null;
282
+ private lastRegistryFingerprint: string | null = null;
283
+ private lastDriftSnapshot: PersistedState['lastDriftSnapshot'] = null;
284
+ private registerTraceRecent: Array<{
285
+ ts: number;
286
+ bridgeId: string;
287
+ gatewayPid: number;
288
+ registerCount: number;
289
+ apiGeneration: number;
290
+ apiRebound: boolean;
291
+ apiInstanceId: string | null;
292
+ registryFingerprint: string | null;
293
+ source: string | null;
294
+ pluginVersion: string | null;
295
+ stack: string;
296
+ stackBucket: string;
297
+ }> = [];
208
298
 
209
299
  private connections = new Map<string, BncrConnection>(); // connectionKey -> connection
210
300
  private activeConnectionByAccount = new Map<string, string>(); // accountId -> connectionKey
211
301
  private outbox = new Map<string, OutboxEntry>(); // messageId -> entry
212
302
  private deadLetter: OutboxEntry[] = [];
213
303
 
214
- private sessionRoutes = new Map<string, { accountId: string; route: BncrRoute; updatedAt: number }>();
215
- 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
+ >();
216
312
 
217
313
  private recentInbound = new Map<string, number>();
218
- 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
+ >();
219
318
  private lastActivityByAccount = new Map<string, number>();
220
319
  private lastInboundByAccount = new Map<string, number>();
221
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;
222
324
 
223
325
  // 内置健康/回归计数(替代独立脚本)
224
326
  private startedAt = now();
@@ -236,16 +338,313 @@ class BncrBridgeRuntime {
236
338
  // 文件互传状态(V1:尽力而为,重连不续传)
237
339
  private fileSendTransfers = new Map<string, FileSendTransferState>(); // OpenClaw -> Bncr(服务端发起)
238
340
  private fileRecvTransfers = new Map<string, FileRecvTransferState>(); // Bncr -> OpenClaw(客户端发起)
239
- private fileAckWaiters = new Map<string, {
240
- resolve: (payload: Record<string, unknown>) => void;
241
- reject: (err: Error) => void;
242
- timer: NodeJS.Timeout;
243
- }>();
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
+ >();
244
349
 
245
350
  constructor(api: OpenClawPluginApi) {
246
351
  this.api = api;
247
352
  }
248
353
 
354
+ bindApi(api: OpenClawPluginApi) {
355
+ this.api = api;
356
+ }
357
+
358
+ getBridgeId() {
359
+ return this.bridgeId;
360
+ }
361
+
362
+ private classifyRegisterTrace(stack: string) {
363
+ if (
364
+ stack.includes('prepareSecretsRuntimeSnapshot') ||
365
+ stack.includes('resolveRuntimeWebTools') ||
366
+ stack.includes('resolvePluginWebSearchProviders')
367
+ ) {
368
+ return 'runtime/webtools';
369
+ }
370
+ if (stack.includes('startGatewayServer') || stack.includes('loadGatewayPlugins')) {
371
+ return 'gateway/startup';
372
+ }
373
+ if (stack.includes('resolvePluginImplicitProviders')) {
374
+ return 'provider/discovery/implicit';
375
+ }
376
+ if (stack.includes('resolvePluginDiscoveryProviders')) {
377
+ return 'provider/discovery/discovery';
378
+ }
379
+ if (stack.includes('resolvePluginProviders')) {
380
+ return 'provider/discovery/providers';
381
+ }
382
+ return 'other';
383
+ }
384
+
385
+ private dominantRegisterBucket(sourceBuckets: Record<string, number>) {
386
+ let winner: string | null = null;
387
+ let winnerCount = -1;
388
+ for (const [bucket, count] of Object.entries(sourceBuckets)) {
389
+ if (count > winnerCount) {
390
+ winner = bucket;
391
+ winnerCount = count;
392
+ }
393
+ }
394
+ return winner;
395
+ }
396
+
397
+ private captureDriftSnapshot(
398
+ summary: ReturnType<BncrBridgeRuntime['buildRegisterTraceSummary']>,
399
+ ) {
400
+ this.lastDriftSnapshot = {
401
+ capturedAt: now(),
402
+ registerCount: this.registerCount,
403
+ apiGeneration: this.apiGeneration,
404
+ postWarmupRegisterCount: summary.postWarmupRegisterCount,
405
+ apiInstanceId: this.lastApiInstanceId,
406
+ registryFingerprint: this.lastRegistryFingerprint,
407
+ dominantBucket: summary.dominantBucket,
408
+ sourceBuckets: { ...summary.sourceBuckets },
409
+ traceWindowSize: this.registerTraceRecent.length,
410
+ traceRecent: this.registerTraceRecent.map((trace) => ({ ...trace })),
411
+ };
412
+ this.scheduleSave();
413
+ }
414
+
415
+ private buildRegisterTraceSummary() {
416
+ const buckets: Record<string, number> = {};
417
+ let warmupCount = 0;
418
+ let postWarmupCount = 0;
419
+ let unexpectedRegisterAfterWarmup = false;
420
+ let lastUnexpectedRegisterAt: number | null = null;
421
+ const baseline = this.firstRegisterAt;
422
+
423
+ for (const trace of this.registerTraceRecent) {
424
+ buckets[trace.stackBucket] = (buckets[trace.stackBucket] || 0) + 1;
425
+ const isWarmup = baseline != null && trace.ts - baseline <= REGISTER_WARMUP_WINDOW_MS;
426
+ if (isWarmup) {
427
+ warmupCount += 1;
428
+ } else {
429
+ postWarmupCount += 1;
430
+ unexpectedRegisterAfterWarmup = true;
431
+ lastUnexpectedRegisterAt = trace.ts;
432
+ }
433
+ }
434
+
435
+ const dominantBucket = this.dominantRegisterBucket(buckets);
436
+ const likelyRuntimeRegistryDrift = postWarmupCount > 0;
437
+ const likelyStartupFanoutOnly = warmupCount > 0 && postWarmupCount === 0;
438
+
439
+ return {
440
+ startupWindowMs: REGISTER_WARMUP_WINDOW_MS,
441
+ traceWindowSize: this.registerTraceRecent.length,
442
+ sourceBuckets: buckets,
443
+ dominantBucket,
444
+ warmupRegisterCount: warmupCount,
445
+ postWarmupRegisterCount: postWarmupCount,
446
+ unexpectedRegisterAfterWarmup,
447
+ lastUnexpectedRegisterAt,
448
+ likelyRuntimeRegistryDrift,
449
+ likelyStartupFanoutOnly,
450
+ };
451
+ }
452
+
453
+ noteRegister(meta: {
454
+ source?: string;
455
+ pluginVersion?: string;
456
+ apiRebound?: boolean;
457
+ apiInstanceId?: string;
458
+ registryFingerprint?: string;
459
+ }) {
460
+ const ts = now();
461
+ this.registerCount += 1;
462
+ if (this.firstRegisterAt == null) this.firstRegisterAt = ts;
463
+ this.lastRegisterAt = ts;
464
+ if (meta.apiRebound) {
465
+ this.apiGeneration += 1;
466
+ this.lastApiRebindAt = ts;
467
+ } else if (this.registerCount === 1 && this.apiGeneration === 0) {
468
+ this.apiGeneration = 1;
469
+ }
470
+ if (meta.source) this.pluginSource = meta.source;
471
+ if (meta.pluginVersion) this.pluginVersion = meta.pluginVersion;
472
+ if (meta.apiInstanceId) this.lastApiInstanceId = meta.apiInstanceId;
473
+ if (meta.registryFingerprint) this.lastRegistryFingerprint = meta.registryFingerprint;
474
+
475
+ const stack = String(new Error().stack || '')
476
+ .split('\n')
477
+ .slice(2, 7)
478
+ .map((line) => line.trim())
479
+ .filter(Boolean)
480
+ .join(' <- ');
481
+ const stackBucket = this.classifyRegisterTrace(stack);
482
+
483
+ const trace = {
484
+ ts,
485
+ bridgeId: this.bridgeId,
486
+ gatewayPid: this.gatewayPid,
487
+ registerCount: this.registerCount,
488
+ apiGeneration: this.apiGeneration,
489
+ apiRebound: meta.apiRebound === true,
490
+ apiInstanceId: this.lastApiInstanceId,
491
+ registryFingerprint: this.lastRegistryFingerprint,
492
+ source: this.pluginSource,
493
+ pluginVersion: this.pluginVersion,
494
+ stack,
495
+ stackBucket,
496
+ };
497
+ this.registerTraceRecent.push(trace);
498
+ if (this.registerTraceRecent.length > 12)
499
+ this.registerTraceRecent.splice(0, this.registerTraceRecent.length - 12);
500
+
501
+ const summary = this.buildRegisterTraceSummary();
502
+ if (summary.postWarmupRegisterCount > 0) this.captureDriftSnapshot(summary);
503
+
504
+ this.api.logger.info?.(`[bncr-register-trace] ${JSON.stringify(trace)}`);
505
+ }
506
+
507
+ private createLeaseId() {
508
+ return typeof crypto?.randomUUID === 'function'
509
+ ? `lease_${crypto.randomUUID()}`
510
+ : `lease_${Math.random().toString(16).slice(2)}${Date.now().toString(16)}`;
511
+ }
512
+
513
+ private acceptConnection() {
514
+ const ts = now();
515
+ const leaseId = this.createLeaseId();
516
+ const connectionEpoch = ++this.connectionEpoch;
517
+ this.primaryLeaseId = leaseId;
518
+ this.acceptedConnections += 1;
519
+ this.lastConnectAt = ts;
520
+ this.recentConnections.set(leaseId, {
521
+ epoch: connectionEpoch,
522
+ connectedAt: ts,
523
+ lastActivityAt: null,
524
+ isPrimary: true,
525
+ });
526
+ for (const [id, entry] of this.recentConnections.entries()) {
527
+ if (id !== leaseId) entry.isPrimary = false;
528
+ }
529
+ while (this.recentConnections.size > 8) {
530
+ const oldest = this.recentConnections.keys().next().value;
531
+ if (!oldest) break;
532
+ this.recentConnections.delete(oldest);
533
+ }
534
+ return { leaseId, connectionEpoch, acceptedAt: ts };
535
+ }
536
+
537
+ private observeLease(
538
+ kind:
539
+ | 'connect'
540
+ | 'inbound'
541
+ | 'activity'
542
+ | 'ack'
543
+ | 'file.init'
544
+ | 'file.chunk'
545
+ | 'file.complete'
546
+ | 'file.abort',
547
+ params: { leaseId?: string; connectionEpoch?: number },
548
+ ) {
549
+ const leaseId = typeof params.leaseId === 'string' ? params.leaseId.trim() : '';
550
+ const connectionEpoch =
551
+ typeof params.connectionEpoch === 'number' ? params.connectionEpoch : undefined;
552
+ if (!leaseId && connectionEpoch == null) return { stale: false, reason: 'missing' as const };
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;
559
+ const stale = staleByLease || staleByEpoch;
560
+ if (!stale) return { stale: false, reason: 'ok' as const };
561
+ this.staleCounters.lastStaleAt = now();
562
+ switch (kind) {
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;
587
+ }
588
+ this.api.logger.warn?.(
589
+ `[bncr] stale ${kind} observed lease=${leaseId || '-'} epoch=${connectionEpoch ?? '-'} currentLease=${this.primaryLeaseId || '-'} currentEpoch=${this.connectionEpoch}`,
590
+ );
591
+ return { stale: true, reason: 'mismatch' as const };
592
+ }
593
+
594
+ private buildExtendedDiagnostics(accountId: string) {
595
+ const diagnostics = this.buildIntegratedDiagnostics(accountId) as Record<string, any>;
596
+ return {
597
+ ...diagnostics,
598
+ register: {
599
+ bridgeId: this.bridgeId,
600
+ gatewayPid: this.gatewayPid,
601
+ pluginVersion: this.pluginVersion,
602
+ source: this.pluginSource,
603
+ apiInstanceId: this.lastApiInstanceId,
604
+ registryFingerprint: this.lastRegistryFingerprint,
605
+ registerCount: this.registerCount,
606
+ firstRegisterAt: this.firstRegisterAt,
607
+ lastRegisterAt: this.lastRegisterAt,
608
+ lastApiRebindAt: this.lastApiRebindAt,
609
+ apiGeneration: this.apiGeneration,
610
+ traceRecent: this.registerTraceRecent.slice(),
611
+ traceSummary: this.buildRegisterTraceSummary(),
612
+ lastDriftSnapshot: this.lastDriftSnapshot,
613
+ },
614
+ connection: {
615
+ active: this.activeConnectionCount(accountId),
616
+ primaryLeaseId: this.primaryLeaseId,
617
+ primaryEpoch: this.connectionEpoch || null,
618
+ acceptedConnections: this.acceptedConnections,
619
+ lastConnectAt: this.lastConnectAt,
620
+ lastDisconnectAt: this.lastDisconnectAt,
621
+ lastActivityAt: this.lastActivityAtGlobal,
622
+ lastInboundAt: this.lastInboundAtGlobal,
623
+ lastAckAt: this.lastAckAtGlobal,
624
+ recent: Array.from(this.recentConnections.entries()).map(([leaseId, entry]) => ({
625
+ leaseId,
626
+ epoch: entry.epoch,
627
+ connectedAt: entry.connectedAt,
628
+ lastActivityAt: entry.lastActivityAt,
629
+ isPrimary: entry.isPrimary,
630
+ })),
631
+ },
632
+ protocol: {
633
+ bridgeVersion: BRIDGE_VERSION,
634
+ protocolVersion: 2,
635
+ minClientProtocol: 1,
636
+ features: {
637
+ leaseId: true,
638
+ connectionEpoch: true,
639
+ staleObserveOnly: true,
640
+ staleRejectAck: false,
641
+ staleRejectFile: false,
642
+ },
643
+ },
644
+ stale: { ...this.staleCounters },
645
+ };
646
+ }
647
+
249
648
  isDebugEnabled(): boolean {
250
649
  try {
251
650
  const cfg = (this.api.runtime.config?.get?.() as any) || {};
@@ -258,10 +657,18 @@ class BncrBridgeRuntime {
258
657
  startService = async (ctx: OpenClawPluginServiceContext, debug?: boolean) => {
259
658
  this.statePath = path.join(ctx.stateDir, 'bncr-bridge-state.json');
260
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
+ }
261
666
  if (typeof debug === 'boolean') BNCR_DEBUG_VERBOSE = debug;
262
667
  const bootDiag = this.buildIntegratedDiagnostics(BNCR_DEFAULT_ACCOUNT_ID);
263
668
  if (BNCR_DEBUG_VERBOSE) {
264
- 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
+ );
265
672
  }
266
673
  };
267
674
 
@@ -307,6 +714,65 @@ class BncrBridgeRuntime {
307
714
  }
308
715
  }
309
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
+
310
776
  private countInvalidOutboxSessionKeys(accountId: string): number {
311
777
  const acc = normalizeAccountId(accountId);
312
778
  let count = 0;
@@ -319,7 +785,8 @@ class BncrBridgeRuntime {
319
785
 
320
786
  private countLegacyAccountResidue(accountId: string): number {
321
787
  const acc = normalizeAccountId(accountId);
322
- const mismatched = (raw?: string | null) => asString(raw || '').trim() && normalizeAccountId(raw) !== acc;
788
+ const mismatched = (raw?: string | null) =>
789
+ asString(raw || '').trim() && normalizeAccountId(raw) !== acc;
323
790
 
324
791
  let count = 0;
325
792
 
@@ -365,7 +832,8 @@ class BncrBridgeRuntime {
365
832
  lastActivityAt: this.lastActivityByAccount.get(acc) || null,
366
833
  lastInboundAt: this.lastInboundByAccount.get(acc) || null,
367
834
  lastOutboundAt: this.lastOutboundByAccount.get(acc) || null,
368
- 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,
369
837
  invalidOutboxSessionKeys: this.countInvalidOutboxSessionKeys(acc),
370
838
  legacyAccountResidue: this.countLegacyAccountResidue(acc),
371
839
  channelRoot: path.join(process.cwd(), 'plugins', 'bncr'),
@@ -386,11 +854,12 @@ class BncrBridgeRuntime {
386
854
  if (!entry?.messageId) continue;
387
855
  const accountId = normalizeAccountId(entry.accountId);
388
856
  const sessionKey = asString(entry.sessionKey || '').trim();
389
- const normalized = normalizeStoredSessionKey(sessionKey);
857
+ const normalized = normalizeStoredSessionKey(sessionKey, this.canonicalAgentId);
390
858
  if (!normalized) continue;
391
859
 
392
860
  const route = parseRouteLike(entry.route) || normalized.route;
393
- const payload = (entry.payload && typeof entry.payload === 'object') ? { ...entry.payload } : {};
861
+ const payload =
862
+ entry.payload && typeof entry.payload === 'object' ? { ...entry.payload } : {};
394
863
  (payload as any).sessionKey = normalized.sessionKey;
395
864
  (payload as any).platform = route.platform;
396
865
  (payload as any).groupId = route.groupId;
@@ -417,11 +886,12 @@ class BncrBridgeRuntime {
417
886
  if (!entry?.messageId) continue;
418
887
  const accountId = normalizeAccountId(entry.accountId);
419
888
  const sessionKey = asString(entry.sessionKey || '').trim();
420
- const normalized = normalizeStoredSessionKey(sessionKey);
889
+ const normalized = normalizeStoredSessionKey(sessionKey, this.canonicalAgentId);
421
890
  if (!normalized) continue;
422
891
 
423
892
  const route = parseRouteLike(entry.route) || normalized.route;
424
- const payload = (entry.payload && typeof entry.payload === 'object') ? { ...entry.payload } : {};
893
+ const payload =
894
+ entry.payload && typeof entry.payload === 'object' ? { ...entry.payload } : {};
425
895
  (payload as any).sessionKey = normalized.sessionKey;
426
896
  (payload as any).platform = route.platform;
427
897
  (payload as any).groupId = route.groupId;
@@ -444,7 +914,10 @@ class BncrBridgeRuntime {
444
914
  this.sessionRoutes.clear();
445
915
  this.routeAliases.clear();
446
916
  for (const item of data.sessionRoutes || []) {
447
- const normalized = normalizeStoredSessionKey(asString(item?.sessionKey || ''));
917
+ const normalized = normalizeStoredSessionKey(
918
+ asString(item?.sessionKey || ''),
919
+ this.canonicalAgentId,
920
+ );
448
921
  if (!normalized) continue;
449
922
 
450
923
  const route = parseRouteLike(item?.route) || normalized.route;
@@ -464,7 +937,10 @@ class BncrBridgeRuntime {
464
937
  this.lastSessionByAccount.clear();
465
938
  for (const item of data.lastSessionByAccount || []) {
466
939
  const accountId = normalizeAccountId(item?.accountId);
467
- const normalized = normalizeStoredSessionKey(asString(item?.sessionKey || ''));
940
+ const normalized = normalizeStoredSessionKey(
941
+ asString(item?.sessionKey || ''),
942
+ this.canonicalAgentId,
943
+ );
468
944
  const updatedAt = Number(item?.updatedAt || 0);
469
945
  if (!normalized || !Number.isFinite(updatedAt) || updatedAt <= 0) continue;
470
946
 
@@ -500,6 +976,39 @@ class BncrBridgeRuntime {
500
976
  this.lastOutboundByAccount.set(accountId, updatedAt);
501
977
  }
502
978
 
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;
1011
+
503
1012
  // 兼容旧状态文件:若尚未持久化 lastSession*/lastActivity*,从 sessionRoutes 回填。
504
1013
  if (this.lastSessionByAccount.size === 0 && this.sessionRoutes.size > 0) {
505
1014
  for (const [sessionKey, info] of this.sessionRoutes.entries()) {
@@ -542,24 +1051,46 @@ class BncrBridgeRuntime {
542
1051
  outbox: Array.from(this.outbox.values()),
543
1052
  deadLetter: this.deadLetter.slice(-1000),
544
1053
  sessionRoutes,
545
- lastSessionByAccount: Array.from(this.lastSessionByAccount.entries()).map(([accountId, v]) => ({
546
- accountId,
547
- sessionKey: v.sessionKey,
548
- scope: v.scope,
549
- updatedAt: v.updatedAt,
550
- })),
551
- lastActivityByAccount: Array.from(this.lastActivityByAccount.entries()).map(([accountId, updatedAt]) => ({
552
- accountId,
553
- updatedAt,
554
- })),
555
- lastInboundByAccount: Array.from(this.lastInboundByAccount.entries()).map(([accountId, updatedAt]) => ({
556
- accountId,
557
- updatedAt,
558
- })),
559
- lastOutboundByAccount: Array.from(this.lastOutboundByAccount.entries()).map(([accountId, updatedAt]) => ({
560
- accountId,
561
- updatedAt,
562
- })),
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
+ ),
1080
+ lastDriftSnapshot: this.lastDriftSnapshot
1081
+ ? {
1082
+ capturedAt: this.lastDriftSnapshot.capturedAt,
1083
+ registerCount: this.lastDriftSnapshot.registerCount,
1084
+ apiGeneration: this.lastDriftSnapshot.apiGeneration,
1085
+ postWarmupRegisterCount: this.lastDriftSnapshot.postWarmupRegisterCount,
1086
+ apiInstanceId: this.lastDriftSnapshot.apiInstanceId,
1087
+ registryFingerprint: this.lastDriftSnapshot.registryFingerprint,
1088
+ dominantBucket: this.lastDriftSnapshot.dominantBucket,
1089
+ sourceBuckets: { ...this.lastDriftSnapshot.sourceBuckets },
1090
+ traceWindowSize: this.lastDriftSnapshot.traceWindowSize,
1091
+ traceRecent: this.lastDriftSnapshot.traceRecent.map((trace) => ({ ...trace })),
1092
+ }
1093
+ : null,
563
1094
  };
564
1095
 
565
1096
  await writeJsonFileAtomically(this.statePath, data);
@@ -682,7 +1213,11 @@ class BncrBridgeRuntime {
682
1213
  const filterAcc = accountId ? normalizeAccountId(accountId) : null;
683
1214
  const targetAccounts = filterAcc
684
1215
  ? [filterAcc]
685
- : 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
+ );
686
1221
  if (BNCR_DEBUG_VERBOSE) {
687
1222
  this.api.logger.info?.(
688
1223
  `[bncr-outbox-flush] ${JSON.stringify({
@@ -761,10 +1296,14 @@ class BncrBridgeRuntime {
761
1296
  if (!directPayloads.length) continue;
762
1297
 
763
1298
  try {
764
- ctx.broadcastToConnIds(BNCR_PUSH_EVENT, {
765
- forcePush: true,
766
- items: directPayloads,
767
- }, new Set(directConnIds));
1299
+ ctx.broadcastToConnIds(
1300
+ BNCR_PUSH_EVENT,
1301
+ {
1302
+ forcePush: true,
1303
+ items: directPayloads,
1304
+ },
1305
+ new Set(directConnIds),
1306
+ );
768
1307
 
769
1308
  const pushedIds = directPayloads
770
1309
  .map((item: any) => asString(item?.messageId || item?.idempotencyKey || '').trim())
@@ -842,7 +1381,8 @@ class BncrBridgeRuntime {
842
1381
  }
843
1382
 
844
1383
  if (localNextDelay != null) {
845
- globalNextDelay = globalNextDelay == null ? localNextDelay : Math.min(globalNextDelay, localNextDelay);
1384
+ globalNextDelay =
1385
+ globalNextDelay == null ? localNextDelay : Math.min(globalNextDelay, localNextDelay);
846
1386
  }
847
1387
  } finally {
848
1388
  this.pushDrainRunningAccounts.delete(acc);
@@ -964,7 +1504,11 @@ class BncrBridgeRuntime {
964
1504
  }
965
1505
 
966
1506
  const curConn = this.connections.get(current);
967
- 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
+ ) {
968
1512
  this.activeConnectionByAccount.set(acc, key);
969
1513
  }
970
1514
  }
@@ -1016,9 +1560,6 @@ class BncrBridgeRuntime {
1016
1560
  const info = { accountId: acc, route, updatedAt: t };
1017
1561
 
1018
1562
  this.sessionRoutes.set(key, info);
1019
- // 同步维护旧格式与新格式,便于平滑切换
1020
- this.sessionRoutes.set(buildFallbackSessionKey(route), info);
1021
-
1022
1563
  this.routeAliases.set(routeKey(acc, route), info);
1023
1564
  this.lastSessionByAccount.set(acc, {
1024
1565
  sessionKey: key,
@@ -1048,7 +1589,10 @@ class BncrBridgeRuntime {
1048
1589
  // 1) 标准 to 仅认 Bncr:<platform>:<groupId>:<userId> / Bncr:<platform>:<userId>
1049
1590
  // 2) 仍接受 strict sessionKey 作为内部兼容输入
1050
1591
  // 3) 其他旧格式直接失败,并输出标准格式提示日志
1051
- 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 } {
1052
1596
  const acc = normalizeAccountId(accountId);
1053
1597
  const raw = asString(rawTarget).trim();
1054
1598
  if (!raw) throw new Error('bncr invalid target(empty)');
@@ -1068,17 +1612,23 @@ class BncrBridgeRuntime {
1068
1612
 
1069
1613
  if (!route) {
1070
1614
  this.api.logger.warn?.(
1071
- `[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}`,
1072
1619
  );
1073
- throw new Error(`bncr invalid target(standard: Bncr:<platform>:<groupId>:<userId> | Bncr:<platform>:<userId>): ${raw}`);
1074
1620
  }
1075
1621
 
1076
1622
  const wantedRouteKey = routeKey(acc, route);
1077
1623
  let best: { sessionKey: string; route: BncrRoute; updatedAt: number } | null = null;
1078
1624
 
1079
1625
  if (BNCR_DEBUG_VERBOSE) {
1080
- this.api.logger.info?.(`[bncr-target-incoming-route] raw=${raw} accountId=${acc} route=${JSON.stringify(route)}`);
1081
- 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
+ );
1082
1632
  }
1083
1633
 
1084
1634
  for (const [key, info] of this.sessionRoutes.entries()) {
@@ -1090,29 +1640,40 @@ class BncrBridgeRuntime {
1090
1640
  const updatedAt = Number(info.updatedAt || 0);
1091
1641
  if (!best || updatedAt >= best.updatedAt) {
1092
1642
  best = {
1093
- sessionKey: parsed.sessionKey,
1643
+ sessionKey: key,
1094
1644
  route: parsed.route,
1095
1645
  updatedAt,
1096
1646
  };
1097
1647
  }
1098
1648
  }
1099
1649
 
1100
- // 直接根据raw生成标准sessionkey
1101
1650
  if (!best) {
1102
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
+ });
1103
1660
  best = {
1104
- sessionKey: `agent:main:bncr:direct:${routeScopeToHex(route)}`,
1661
+ sessionKey: buildCanonicalBncrSessionKey(route, canonicalAgentId),
1105
1662
  route,
1106
1663
  updatedAt,
1107
1664
  };
1108
1665
  }
1109
1666
 
1110
1667
  if (BNCR_DEBUG_VERBOSE) {
1111
- 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
+ );
1112
1671
  }
1113
1672
 
1114
1673
  if (!best) {
1115
- 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
+ );
1116
1677
  throw new Error(`bncr target not found in known sessions: ${raw}`);
1117
1678
  }
1118
1679
 
@@ -1140,11 +1701,19 @@ class BncrBridgeRuntime {
1140
1701
  return `${transferId}|${stage}|${idx}`;
1141
1702
  }
1142
1703
 
1143
- 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
+ }) {
1144
1710
  const transferId = asString(params.transferId).trim();
1145
1711
  const stage = asString(params.stage).trim();
1146
1712
  const key = this.fileAckKey(transferId, stage, params.chunkIndex);
1147
- 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
+ );
1148
1717
 
1149
1718
  return new Promise<Record<string, unknown>>((resolve, reject) => {
1150
1719
  const timer = setTimeout(() => {
@@ -1155,7 +1724,13 @@ class BncrBridgeRuntime {
1155
1724
  });
1156
1725
  }
1157
1726
 
1158
- 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
+ }) {
1159
1734
  const transferId = asString(params.transferId).trim();
1160
1735
  const stage = asString(params.stage).trim();
1161
1736
  const key = this.fileAckKey(transferId, stage, params.chunkIndex);
@@ -1164,11 +1739,20 @@ class BncrBridgeRuntime {
1164
1739
  this.fileAckWaiters.delete(key);
1165
1740
  clearTimeout(waiter.timer);
1166
1741
  if (params.ok) waiter.resolve(params.payload);
1167
- 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
+ );
1168
1748
  return true;
1169
1749
  }
1170
1750
 
1171
- 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
+ ) {
1172
1756
  const connIds = this.resolvePushConnIds(accountId);
1173
1757
  if (!connIds.size || !this.gatewayContext) {
1174
1758
  throw new Error(`no active bncr connection for account=${accountId}`);
@@ -1191,7 +1775,9 @@ class BncrBridgeRuntime {
1191
1775
  return dir;
1192
1776
  }
1193
1777
 
1194
- private async materializeRecvTransfer(st: FileRecvTransferState): Promise<{ path: string; fileSha256: string }> {
1778
+ private async materializeRecvTransfer(
1779
+ st: FileRecvTransferState,
1780
+ ): Promise<{ path: string; fileSha256: string }> {
1195
1781
  const dir = this.resolveInboundFilesDir();
1196
1782
  const safeName = asString(st.fileName).trim() || `${st.transferId}.bin`;
1197
1783
  const finalPath = path.join(dir, safeName);
@@ -1233,7 +1819,8 @@ class BncrBridgeRuntime {
1233
1819
  lastActivityAt: this.lastActivityByAccount.get(acc) || null,
1234
1820
  lastInboundAt: this.lastInboundByAccount.get(acc) || null,
1235
1821
  lastOutboundAt: this.lastOutboundByAccount.get(acc) || null,
1236
- 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,
1237
1824
  invalidOutboxSessionKeys: this.countInvalidOutboxSessionKeys(acc),
1238
1825
  legacyAccountResidue: this.countLegacyAccountResidue(acc),
1239
1826
  channelRoot: path.join(process.cwd(), 'plugins', 'bncr'),
@@ -1257,7 +1844,8 @@ class BncrBridgeRuntime {
1257
1844
  lastActivityAt: this.lastActivityByAccount.get(acc) || null,
1258
1845
  lastInboundAt: this.lastInboundByAccount.get(acc) || null,
1259
1846
  lastOutboundAt: this.lastOutboundByAccount.get(acc) || null,
1260
- 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,
1261
1849
  invalidOutboxSessionKeys: this.countInvalidOutboxSessionKeys(acc),
1262
1850
  legacyAccountResidue: this.countLegacyAccountResidue(acc),
1263
1851
  running: true,
@@ -1282,7 +1870,8 @@ class BncrBridgeRuntime {
1282
1870
  lastActivityAt: this.lastActivityByAccount.get(acc) || null,
1283
1871
  lastInboundAt: this.lastInboundByAccount.get(acc) || null,
1284
1872
  lastOutboundAt: this.lastOutboundByAccount.get(acc) || null,
1285
- 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,
1286
1875
  invalidOutboxSessionKeys: this.countInvalidOutboxSessionKeys(acc),
1287
1876
  legacyAccountResidue: this.countLegacyAccountResidue(acc),
1288
1877
  channelRoot: path.join(process.cwd(), 'plugins', 'bncr'),
@@ -1407,7 +1996,10 @@ class BncrBridgeRuntime {
1407
1996
  timeoutMs?: number;
1408
1997
  }): Promise<void> {
1409
1998
  const { transferId, chunkIndex } = params;
1410
- 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
+ );
1411
2003
  const started = now();
1412
2004
 
1413
2005
  return new Promise<void>((resolve, reject) => {
@@ -1476,7 +2068,13 @@ class BncrBridgeRuntime {
1476
2068
  route: BncrRoute;
1477
2069
  mediaUrl: string;
1478
2070
  mediaLocalRoots?: readonly string[];
1479
- }): 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
+ }> {
1480
2078
  const loaded = await this.api.runtime.media.loadWebMedia(params.mediaUrl, {
1481
2079
  localRoots: params.mediaLocalRoots,
1482
2080
  maxBytes: 50 * 1024 * 1024,
@@ -1528,21 +2126,25 @@ class BncrBridgeRuntime {
1528
2126
  };
1529
2127
  this.fileSendTransfers.set(transferId, st);
1530
2128
 
1531
- ctx.broadcastToConnIds('bncr.file.init', {
1532
- transferId,
1533
- direction: 'oc2bncr',
1534
- sessionKey: params.sessionKey,
1535
- platform: params.route.platform,
1536
- groupId: params.route.groupId,
1537
- userId: params.route.userId,
1538
- fileName,
1539
- mimeType,
1540
- fileSize: size,
1541
- chunkSize,
1542
- totalChunks,
1543
- fileSha256,
1544
- ts: now(),
1545
- }, 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
+ );
1546
2148
 
1547
2149
  // 逐块发送并等待 ACK
1548
2150
  for (let idx = 0; idx < totalChunks; idx++) {
@@ -1554,18 +2156,26 @@ class BncrBridgeRuntime {
1554
2156
  let ok = false;
1555
2157
  let lastErr: unknown = null;
1556
2158
  for (let attempt = 1; attempt <= 3; attempt++) {
1557
- ctx.broadcastToConnIds('bncr.file.chunk', {
1558
- transferId,
1559
- chunkIndex: idx,
1560
- offset: start,
1561
- size: slice.byteLength,
1562
- chunkSha256,
1563
- base64: slice.toString('base64'),
1564
- ts: now(),
1565
- }, 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
+ );
1566
2172
 
1567
2173
  try {
1568
- 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
+ });
1569
2179
  ok = true;
1570
2180
  break;
1571
2181
  } catch (err) {
@@ -1578,19 +2188,27 @@ class BncrBridgeRuntime {
1578
2188
  st.status = 'aborted';
1579
2189
  st.error = String((lastErr as any)?.message || lastErr || `chunk-${idx}-failed`);
1580
2190
  this.fileSendTransfers.set(transferId, st);
1581
- ctx.broadcastToConnIds('bncr.file.abort', {
1582
- transferId,
1583
- reason: st.error,
1584
- ts: now(),
1585
- }, connIds);
2191
+ ctx.broadcastToConnIds(
2192
+ 'bncr.file.abort',
2193
+ {
2194
+ transferId,
2195
+ reason: st.error,
2196
+ ts: now(),
2197
+ },
2198
+ connIds,
2199
+ );
1586
2200
  throw new Error(st.error);
1587
2201
  }
1588
2202
  }
1589
2203
 
1590
- ctx.broadcastToConnIds('bncr.file.complete', {
1591
- transferId,
1592
- ts: now(),
1593
- }, connIds);
2204
+ ctx.broadcastToConnIds(
2205
+ 'bncr.file.complete',
2206
+ {
2207
+ transferId,
2208
+ ts: now(),
2209
+ },
2210
+ connIds,
2211
+ );
1594
2212
 
1595
2213
  const done = await this.waitCompleteAck({ transferId, timeoutMs: 60_000 });
1596
2214
 
@@ -1606,7 +2224,14 @@ class BncrBridgeRuntime {
1606
2224
  accountId: string;
1607
2225
  sessionKey: string;
1608
2226
  route: BncrRoute;
1609
- 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
+ };
1610
2235
  mediaLocalRoots?: readonly string[];
1611
2236
  }) {
1612
2237
  const { accountId, sessionKey, route, payload, mediaLocalRoots } = params;
@@ -1718,6 +2343,7 @@ class BncrBridgeRuntime {
1718
2343
  this.markSeen(accountId, connId, clientId);
1719
2344
  this.markActivity(accountId);
1720
2345
  this.incrementCounter(this.connectEventsByAccount, accountId);
2346
+ const lease = this.acceptConnection();
1721
2347
 
1722
2348
  respond(true, {
1723
2349
  channel: CHANNEL_ID,
@@ -1729,7 +2355,13 @@ class BncrBridgeRuntime {
1729
2355
  activeConnections: this.activeConnectionCount(accountId),
1730
2356
  pending: Array.from(this.outbox.values()).filter((v) => v.accountId === accountId).length,
1731
2357
  deadLetter: this.deadLetter.filter((v) => v.accountId === accountId).length,
1732
- diagnostics: this.buildIntegratedDiagnostics(accountId),
2358
+ diagnostics: this.buildExtendedDiagnostics(accountId),
2359
+ leaseId: lease.leaseId,
2360
+ connectionEpoch: lease.connectionEpoch,
2361
+ protocolVersion: 2,
2362
+ acceptedAt: lease.acceptedAt,
2363
+ serverPid: this.gatewayPid,
2364
+ bridgeId: this.bridgeId,
1733
2365
  now: now(),
1734
2366
  });
1735
2367
 
@@ -1743,6 +2375,8 @@ class BncrBridgeRuntime {
1743
2375
  const clientId = asString((params as any)?.clientId || '').trim() || undefined;
1744
2376
  this.rememberGatewayContext(context);
1745
2377
  this.markSeen(accountId, connId, clientId);
2378
+ this.observeLease('ack', params ?? {});
2379
+ this.lastAckAtGlobal = now();
1746
2380
  this.incrementCounter(this.ackEventsByAccount, accountId);
1747
2381
 
1748
2382
  const messageId = asString(params?.messageId || '').trim();
@@ -1802,6 +2436,8 @@ class BncrBridgeRuntime {
1802
2436
  const accountId = normalizeAccountId(asString(params?.accountId || ''));
1803
2437
  const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
1804
2438
  const clientId = asString((params as any)?.clientId || '').trim() || undefined;
2439
+ this.observeLease('activity', params ?? {});
2440
+ this.lastActivityAtGlobal = now();
1805
2441
  if (BNCR_DEBUG_VERBOSE) {
1806
2442
  this.api.logger.info?.(
1807
2443
  `[bncr-activity] ${JSON.stringify({
@@ -1834,7 +2470,7 @@ class BncrBridgeRuntime {
1834
2470
  const accountId = normalizeAccountId(asString(params?.accountId || ''));
1835
2471
  const cfg = await this.api.runtime.config.loadConfig();
1836
2472
  const runtime = this.getAccountRuntimeSnapshot(accountId);
1837
- const diagnostics = this.buildIntegratedDiagnostics(accountId);
2473
+ const diagnostics = this.buildExtendedDiagnostics(accountId);
1838
2474
  const permissions = buildBncrPermissionSummary(cfg ?? {});
1839
2475
  const probe = probeBncrAccount({
1840
2476
  accountId,
@@ -1869,6 +2505,7 @@ class BncrBridgeRuntime {
1869
2505
  const clientId = asString((params as any)?.clientId || '').trim() || undefined;
1870
2506
  this.rememberGatewayContext(context);
1871
2507
  this.markSeen(accountId, connId, clientId);
2508
+ this.observeLease('file.init', params ?? {});
1872
2509
  this.markActivity(accountId);
1873
2510
 
1874
2511
  const transferId = asString(params?.transferId || '').trim();
@@ -1902,11 +2539,12 @@ class BncrBridgeRuntime {
1902
2539
  return;
1903
2540
  }
1904
2541
 
1905
- const route = parseRouteLike({
1906
- platform: asString(params?.platform || normalized.route.platform),
1907
- groupId: asString(params?.groupId || normalized.route.groupId),
1908
- userId: asString(params?.userId || normalized.route.userId),
1909
- }) || 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;
1910
2548
 
1911
2549
  this.fileRecvTransfers.set(transferId, {
1912
2550
  transferId,
@@ -1938,6 +2576,7 @@ class BncrBridgeRuntime {
1938
2576
  const clientId = asString((params as any)?.clientId || '').trim() || undefined;
1939
2577
  this.rememberGatewayContext(context);
1940
2578
  this.markSeen(accountId, connId, clientId);
2579
+ this.observeLease('file.chunk', params ?? {});
1941
2580
  this.markActivity(accountId);
1942
2581
 
1943
2582
  const transferId = asString(params?.transferId || '').trim();
@@ -1985,12 +2624,18 @@ class BncrBridgeRuntime {
1985
2624
  }
1986
2625
  };
1987
2626
 
1988
- handleFileComplete = async ({ params, respond, client, context }: GatewayRequestHandlerOptions) => {
2627
+ handleFileComplete = async ({
2628
+ params,
2629
+ respond,
2630
+ client,
2631
+ context,
2632
+ }: GatewayRequestHandlerOptions) => {
1989
2633
  const accountId = normalizeAccountId(asString(params?.accountId || ''));
1990
2634
  const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
1991
2635
  const clientId = asString((params as any)?.clientId || '').trim() || undefined;
1992
2636
  this.rememberGatewayContext(context);
1993
2637
  this.markSeen(accountId, connId, clientId);
2638
+ this.observeLease('file.complete', params ?? {});
1994
2639
  this.markActivity(accountId);
1995
2640
 
1996
2641
  const transferId = asString(params?.transferId || '').trim();
@@ -2007,10 +2652,14 @@ class BncrBridgeRuntime {
2007
2652
 
2008
2653
  try {
2009
2654
  if (st.receivedChunks.size < st.totalChunks) {
2010
- 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
+ );
2011
2658
  }
2012
2659
 
2013
- 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]);
2014
2663
  const merged = Buffer.concat(ordered);
2015
2664
  if (st.fileSize > 0 && merged.length !== st.fileSize) {
2016
2665
  throw new Error(`file size mismatch expected=${st.fileSize} got=${merged.length}`);
@@ -2054,6 +2703,7 @@ class BncrBridgeRuntime {
2054
2703
  const clientId = asString((params as any)?.clientId || '').trim() || undefined;
2055
2704
  this.rememberGatewayContext(context);
2056
2705
  this.markSeen(accountId, connId, clientId);
2706
+ this.observeLease('file.abort', params ?? {});
2057
2707
  this.markActivity(accountId);
2058
2708
 
2059
2709
  const transferId = asString(params?.transferId || '').trim();
@@ -2144,12 +2794,31 @@ class BncrBridgeRuntime {
2144
2794
 
2145
2795
  handleInbound = async ({ params, respond, client, context }: GatewayRequestHandlerOptions) => {
2146
2796
  const parsed = parseBncrInboundParams(params);
2147
- 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;
2148
2815
  const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
2149
2816
  const clientId = asString((params as any)?.clientId || '').trim() || undefined;
2150
2817
  this.rememberGatewayContext(context);
2151
2818
  this.markSeen(accountId, connId, clientId);
2819
+ this.observeLease('inbound', params ?? {});
2152
2820
  this.markActivity(accountId);
2821
+ this.lastInboundAtGlobal = now();
2153
2822
  this.incrementCounter(this.inboundEventsByAccount, accountId);
2154
2823
 
2155
2824
  if (!platform || (!userId && !groupId)) {
@@ -2182,13 +2851,21 @@ class BncrBridgeRuntime {
2182
2851
  return;
2183
2852
  }
2184
2853
 
2185
- const baseSessionKey = normalizeInboundSessionKey(sessionKeyfromroute, route)
2186
- || this.api.runtime.channel.routing.resolveAgentRoute({
2187
- cfg,
2188
- channel: CHANNEL_ID,
2189
- accountId,
2190
- peer,
2191
- }).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;
2192
2869
  const taskSessionKey = withTaskSessionKey(baseSessionKey, extracted.taskKey);
2193
2870
  const sessionKey = taskSessionKey || baseSessionKey;
2194
2871
 
@@ -2205,7 +2882,9 @@ class BncrBridgeRuntime {
2205
2882
  channelId: CHANNEL_ID,
2206
2883
  cfg,
2207
2884
  parsed,
2208
- rememberSessionRoute: (sessionKey, accountId, route) => this.rememberSessionRoute(sessionKey, accountId, route),
2885
+ canonicalAgentId,
2886
+ rememberSessionRoute: (sessionKey, accountId, route) =>
2887
+ this.rememberSessionRoute(sessionKey, accountId, route),
2209
2888
  enqueueFromReply: (args) => this.enqueueFromReply(args),
2210
2889
  setInboundActivity: (accountId, at) => {
2211
2890
  this.lastInboundByAccount.set(accountId, at);
@@ -2291,7 +2970,8 @@ class BncrBridgeRuntime {
2291
2970
  text: asString(ctx.text || ''),
2292
2971
  mediaLocalRoots: ctx.mediaLocalRoots,
2293
2972
  resolveVerifiedTarget: (to, accountId) => this.resolveVerifiedTarget(to, accountId),
2294
- rememberSessionRoute: (sessionKey, accountId, route) => this.rememberSessionRoute(sessionKey, accountId, route),
2973
+ rememberSessionRoute: (sessionKey, accountId, route) =>
2974
+ this.rememberSessionRoute(sessionKey, accountId, route),
2295
2975
  enqueueFromReply: (args) => this.enqueueFromReply(args),
2296
2976
  createMessageId: () => randomUUID(),
2297
2977
  });
@@ -2335,7 +3015,8 @@ class BncrBridgeRuntime {
2335
3015
  audioAsVoice,
2336
3016
  mediaLocalRoots: ctx.mediaLocalRoots,
2337
3017
  resolveVerifiedTarget: (to, accountId) => this.resolveVerifiedTarget(to, accountId),
2338
- rememberSessionRoute: (sessionKey, accountId, route) => this.rememberSessionRoute(sessionKey, accountId, route),
3018
+ rememberSessionRoute: (sessionKey, accountId, route) =>
3019
+ this.rememberSessionRoute(sessionKey, accountId, route),
2339
3020
  enqueueFromReply: (args) => this.enqueueFromReply(args),
2340
3021
  createMessageId: () => randomUUID(),
2341
3022
  });
@@ -2350,10 +3031,13 @@ export function createBncrChannelPlugin(bridge: BncrBridgeRuntime) {
2350
3031
  const messageActions: ChannelMessageActionAdapter = {
2351
3032
  describeMessageTool: ({ cfg }) => {
2352
3033
  const channelCfg = cfg?.channels?.[CHANNEL_ID];
2353
- const hasExplicitConfiguredAccount = Boolean(channelCfg && typeof channelCfg === 'object')
2354
- && resolveBncrChannelPolicy(channelCfg).enabled !== false
2355
- && Boolean(channelCfg.accounts && typeof channelCfg.accounts === 'object')
2356
- && 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
+ );
2357
3041
 
2358
3042
  const hasConnectedRuntime = listAccountIds(cfg).some((accountId) => {
2359
3043
  const resolved = resolveAccount(cfg, accountId);
@@ -2373,7 +3057,8 @@ export function createBncrChannelPlugin(bridge: BncrBridgeRuntime) {
2373
3057
  supportsAction: ({ action }) => action === 'send',
2374
3058
  extractToolSend: ({ args }) => extractToolSend(args, 'sendMessage'),
2375
3059
  handleAction: async ({ action, params, accountId, mediaLocalRoots }) => {
2376
- 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}.`);
2377
3062
  const to = readStringParam(params, 'to', { required: true });
2378
3063
  const message = readStringParam(params, 'message', { allowEmpty: true }) ?? '';
2379
3064
  const caption = readStringParam(params, 'caption', { allowEmpty: true }) ?? '';
@@ -2385,36 +3070,40 @@ export function createBncrChannelPlugin(bridge: BncrBridgeRuntime) {
2385
3070
  readStringParam(params, 'mediaUrl', { trim: false });
2386
3071
  const asVoice = readBooleanParam(params, 'asVoice') ?? false;
2387
3072
  const audioAsVoice = readBooleanParam(params, 'audioAsVoice') ?? false;
2388
- const resolvedAccountId = normalizeAccountId(readStringParam(params, 'accountId') ?? accountId);
3073
+ const resolvedAccountId = normalizeAccountId(
3074
+ readStringParam(params, 'accountId') ?? accountId,
3075
+ );
2389
3076
 
2390
3077
  if (!content.trim() && !mediaUrl) throw new Error('send requires text or media');
2391
3078
 
2392
3079
  const result = mediaUrl
2393
3080
  ? await sendBncrMedia({
2394
- channelId: CHANNEL_ID,
2395
- accountId: resolvedAccountId,
2396
- to,
2397
- text: content,
2398
- mediaUrl,
2399
- asVoice,
2400
- audioAsVoice,
2401
- mediaLocalRoots,
2402
- resolveVerifiedTarget: (to, accountId) => bridge.resolveVerifiedTarget(to, accountId),
2403
- rememberSessionRoute: (sessionKey, accountId, route) => bridge.rememberSessionRoute(sessionKey, accountId, route),
2404
- enqueueFromReply: (args) => bridge.enqueueFromReply(args as any),
2405
- createMessageId: () => randomUUID(),
2406
- })
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
+ })
2407
3095
  : await sendBncrText({
2408
- channelId: CHANNEL_ID,
2409
- accountId: resolvedAccountId,
2410
- to,
2411
- text: content,
2412
- mediaLocalRoots,
2413
- resolveVerifiedTarget: (to, accountId) => bridge.resolveVerifiedTarget(to, accountId),
2414
- rememberSessionRoute: (sessionKey, accountId, route) => bridge.rememberSessionRoute(sessionKey, accountId, route),
2415
- enqueueFromReply: (args) => bridge.enqueueFromReply(args as any),
2416
- createMessageId: () => randomUUID(),
2417
- });
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
+ });
2418
3107
 
2419
3108
  return jsonResult({ ok: true, ...result });
2420
3109
  },
@@ -2447,7 +3136,7 @@ export function createBncrChannelPlugin(bridge: BncrBridgeRuntime) {
2447
3136
  looksLikeId: (raw: string, normalized?: string) => {
2448
3137
  return Boolean(asString(normalized || raw).trim());
2449
3138
  },
2450
- 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>',
2451
3140
  },
2452
3141
  },
2453
3142
  configSchema: BncrConfigSchema,
@@ -2503,27 +3192,33 @@ export function createBncrChannelPlugin(bridge: BncrBridgeRuntime) {
2503
3192
  textChunkLimit: 4000,
2504
3193
  sendText: bridge.channelSendText,
2505
3194
  sendMedia: bridge.channelSendMedia,
2506
- replyAction: async (ctx: any) => sendBncrReplyAction({
2507
- accountId: normalizeAccountId(ctx?.accountId),
2508
- to: asString(ctx?.to || '').trim(),
2509
- text: asString(ctx?.text || ''),
2510
- replyToMessageId: asString(ctx?.replyToId || ctx?.replyToMessageId || '').trim() || undefined,
2511
- sendText: async ({ accountId, to, text }) => bridge.channelSendText({ accountId, to, text }),
2512
- }),
2513
- deleteAction: async (ctx: any) => deleteBncrMessageAction({
2514
- accountId: normalizeAccountId(ctx?.accountId),
2515
- targetMessageId: asString(ctx?.messageId || ctx?.targetMessageId || '').trim(),
2516
- }),
2517
- reactAction: async (ctx: any) => reactBncrMessageAction({
2518
- accountId: normalizeAccountId(ctx?.accountId),
2519
- targetMessageId: asString(ctx?.messageId || ctx?.targetMessageId || '').trim(),
2520
- emoji: asString(ctx?.emoji || '').trim(),
2521
- }),
2522
- editAction: async (ctx: any) => editBncrMessageAction({
2523
- accountId: normalizeAccountId(ctx?.accountId),
2524
- targetMessageId: asString(ctx?.messageId || ctx?.targetMessageId || '').trim(),
2525
- text: asString(ctx?.text || ''),
2526
- }),
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
+ }),
2527
3222
  },
2528
3223
  status: {
2529
3224
  defaultRuntime: createDefaultChannelRuntimeState(BNCR_DEFAULT_ACCOUNT_ID, {
@@ -2550,9 +3245,7 @@ export function createBncrChannelPlugin(bridge: BncrBridgeRuntime) {
2550
3245
  const lastOutboundAgo = rt?.lastOutboundAgo ?? meta.lastOutboundAgo ?? '-';
2551
3246
  const diagnostics = rt?.diagnostics ?? meta.diagnostics ?? null;
2552
3247
  // 右侧状态字段统一:离线时也显示 Status(避免出现 configured 文案)
2553
- const normalizedMode = rt?.mode === 'linked'
2554
- ? 'linked'
2555
- : 'Status';
3248
+ const normalizedMode = rt?.mode === 'linked' ? 'linked' : 'Status';
2556
3249
 
2557
3250
  const displayName = resolveDefaultDisplayName(account?.name, account?.accountId);
2558
3251