@xmoxmo/bncr 0.0.2 → 0.0.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,3 +1,4 @@
1
+ import fs from 'node:fs';
1
2
  import path from 'node:path';
2
3
  import { createHash, randomUUID } from 'node:crypto';
3
4
  import type {
@@ -20,6 +21,15 @@ const BRIDGE_VERSION = 2;
20
21
  const BNCR_PUSH_EVENT = 'bncr.push';
21
22
  const CONNECT_TTL_MS = 120_000;
22
23
  const MAX_RETRY = 10;
24
+ const PUSH_DRAIN_INTERVAL_MS = 500;
25
+ const FILE_FORCE_CHUNK = true; // 统一走 WS 分块,保留 base64 仅作兜底
26
+ const FILE_INLINE_THRESHOLD = 5 * 1024 * 1024; // fallback 阈值(仅 FILE_FORCE_CHUNK=false 时生效)
27
+ const FILE_CHUNK_SIZE = 256 * 1024; // 256KB
28
+ const FILE_CHUNK_RETRY = 3;
29
+ const FILE_ACK_TIMEOUT_MS = 30_000;
30
+ const FILE_TRANSFER_ACK_TTL_MS = 30_000;
31
+ const FILE_TRANSFER_KEEP_MS = 6 * 60 * 60 * 1000;
32
+ const BNCR_DEBUG_VERBOSE = true; // 临时调试:打印发送入口完整请求体
23
33
 
24
34
  const BncrConfigSchema = {
25
35
  schema: {
@@ -68,6 +78,44 @@ type OutboxEntry = {
68
78
  lastError?: string;
69
79
  };
70
80
 
81
+ type FileSendTransferState = {
82
+ transferId: string;
83
+ accountId: string;
84
+ sessionKey: string;
85
+ route: BncrRoute;
86
+ fileName: string;
87
+ mimeType: string;
88
+ fileSize: number;
89
+ chunkSize: number;
90
+ totalChunks: number;
91
+ fileSha256: string;
92
+ startedAt: number;
93
+ status: 'init' | 'transferring' | 'completed' | 'aborted';
94
+ ackedChunks: Set<number>;
95
+ failedChunks: Map<number, string>;
96
+ completedPath?: string;
97
+ error?: string;
98
+ };
99
+
100
+ type FileRecvTransferState = {
101
+ transferId: string;
102
+ accountId: string;
103
+ sessionKey: string;
104
+ route: BncrRoute;
105
+ fileName: string;
106
+ mimeType: string;
107
+ fileSize: number;
108
+ chunkSize: number;
109
+ totalChunks: number;
110
+ fileSha256: string;
111
+ startedAt: number;
112
+ status: 'init' | 'transferring' | 'completed' | 'aborted';
113
+ bufferByChunk: Map<number, Buffer>;
114
+ receivedChunks: Set<number>;
115
+ completedPath?: string;
116
+ error?: string;
117
+ };
118
+
71
119
  type PersistedState = {
72
120
  outbox: OutboxEntry[];
73
121
  deadLetter: OutboxEntry[];
@@ -109,7 +157,11 @@ function asString(v: unknown, fallback = ''): string {
109
157
 
110
158
  function normalizeAccountId(accountId?: string | null): string {
111
159
  const v = asString(accountId || '').trim();
112
- return v || BNCR_DEFAULT_ACCOUNT_ID;
160
+ if (!v) return BNCR_DEFAULT_ACCOUNT_ID;
161
+ const lower = v.toLowerCase();
162
+ // 历史兼容:default/primary 统一折叠到 Primary,避免状态尾巴反复出现。
163
+ if (lower === 'default' || lower === 'primary') return BNCR_DEFAULT_ACCOUNT_ID;
164
+ return v;
113
165
  }
114
166
 
115
167
  function parseRouteFromScope(scope: string): BncrRoute | null {
@@ -124,18 +176,45 @@ function parseRouteFromDisplayScope(scope: string): BncrRoute | null {
124
176
  const raw = asString(scope).trim();
125
177
  if (!raw) return null;
126
178
 
127
- // 支持展示标签:Bncr-platform:group:user
128
- const stripped = raw.replace(/^Bncr-/i, '');
129
- return parseRouteFromScope(stripped);
179
+ // 终版兼容(6 种):
180
+ // 1) bncr:g-<hex(scope)>
181
+ // 2) bncr:<hex(scope)>
182
+ // 3) bncr:<platform>:<groupId>:<userId>
183
+ // 4) bncr:g-<platform>:<groupId>:<userId>
184
+
185
+ // 1) bncr:g-<hex> 或 bncr:g-<scope>
186
+ const gPayload = raw.match(/^bncr:g-(.+)$/i)?.[1];
187
+ if (gPayload) {
188
+ if (isLowerHex(gPayload)) {
189
+ const route = parseRouteFromHexScope(gPayload);
190
+ if (route) return route;
191
+ }
192
+ return parseRouteFromScope(gPayload);
193
+ }
194
+
195
+ // 2) / 3) bncr:<hex> or bncr:<scope>
196
+ const bPayload = raw.match(/^bncr:(.+)$/i)?.[1];
197
+ if (bPayload) {
198
+ if (isLowerHex(bPayload)) {
199
+ const route = parseRouteFromHexScope(bPayload);
200
+ if (route) return route;
201
+ }
202
+ return parseRouteFromScope(bPayload);
203
+ }
204
+
205
+ return null;
130
206
  }
131
207
 
132
208
  function formatDisplayScope(route: BncrRoute): string {
133
- return `Bncr-${route.platform}:${route.groupId}:${route.userId}`;
209
+ // 主推荐标签:bncr:<platform>:<groupId>:<userId>
210
+ // 保持原始大小写,不做平台名降级。
211
+ return `bncr:${route.platform}:${route.groupId}:${route.userId}`;
134
212
  }
135
213
 
136
214
  function isLowerHex(input: string): boolean {
137
215
  const raw = asString(input).trim();
138
- return !!raw && /^[0-9a-f]+$/.test(raw) && raw.length % 2 === 0;
216
+ // 兼容大小写十六进制,不主动降级大小写
217
+ return !!raw && /^[0-9a-fA-F]+$/.test(raw) && raw.length % 2 === 0;
139
218
  }
140
219
 
141
220
  function routeScopeToHex(route: BncrRoute): string {
@@ -144,7 +223,7 @@ function routeScopeToHex(route: BncrRoute): string {
144
223
  }
145
224
 
146
225
  function parseRouteFromHexScope(scopeHex: string): BncrRoute | null {
147
- const rawHex = asString(scopeHex).trim().toLowerCase();
226
+ const rawHex = asString(scopeHex).trim();
148
227
  if (!isLowerHex(rawHex)) return null;
149
228
 
150
229
  try {
@@ -277,24 +356,30 @@ function hex2utf8SessionKey(str: string): { sessionKey: string; scope: string }
277
356
  function parseStrictBncrSessionKey(input: string): { sessionKey: string; scopeHex: string; route: BncrRoute } | null {
278
357
  const raw = asString(input).trim();
279
358
  if (!raw) return null;
280
- if (!raw.startsWith(BNCR_SESSION_KEY_PREFIX)) return null;
281
359
 
282
- const parts = raw.split(':');
283
- // 仅接受:agent:main:bncr:direct:<hexScope>
284
- if (parts.length !== 5) return null;
285
- if (parts[0] !== 'agent' || parts[1] !== 'main' || parts[2] !== 'bncr' || parts[3] !== 'direct') {
286
- return null;
360
+ // 终版兼容 sessionKey:
361
+ // 1) agent:main:bncr:direct:<hex(scope)>
362
+ // 2) agent:main:bncr:group:<hex(scope)>
363
+ // (统一归一成 direct:<hex(scope)>)
364
+ const m = raw.match(/^agent:main:bncr:(direct|group):(.+)$/);
365
+ if (!m?.[1] || !m?.[2]) return null;
366
+
367
+ const payload = asString(m[2]).trim();
368
+ let route: BncrRoute | null = null;
369
+ let scopeHex = '';
370
+
371
+ if (isLowerHex(payload)) {
372
+ scopeHex = payload;
373
+ route = parseRouteFromHexScope(payload);
374
+ } else {
375
+ route = parseRouteFromScope(payload);
376
+ if (route) scopeHex = routeScopeToHex(route);
287
377
  }
288
378
 
289
- const scopeHex = asString(parts[4]).trim().toLowerCase();
290
- if (!isLowerHex(scopeHex)) return null;
291
-
292
- const decoded = hex2utf8SessionKey(raw);
293
- const route = parseRouteFromScope(decoded.scope);
294
- if (!route) return null;
379
+ if (!route || !scopeHex) return null;
295
380
 
296
381
  return {
297
- sessionKey: raw,
382
+ sessionKey: `${BNCR_SESSION_KEY_PREFIX}${scopeHex}`,
298
383
  scopeHex,
299
384
  route,
300
385
  };
@@ -352,6 +437,7 @@ function withTaskSessionKey(sessionKey: string, taskKey?: string | null): string
352
437
  }
353
438
 
354
439
  function buildFallbackSessionKey(route: BncrRoute): string {
440
+ // 新主口径:sessionKey 使用 agent:main:bncr:direct:<hex(scope)>
355
441
  return `${BNCR_SESSION_KEY_PREFIX}${routeScopeToHex(route)}`;
356
442
  }
357
443
 
@@ -383,14 +469,85 @@ function inboundDedupKey(params: {
383
469
  return `${accountId}|${platform}|${groupId}|${userId}|hash:${digest}`;
384
470
  }
385
471
 
386
- function resolveChatType(route: BncrRoute): 'direct' | 'group' {
387
- return route.groupId === '0' ? 'direct' : 'group';
472
+ function resolveChatType(_route: BncrRoute): 'direct' | 'group' {
473
+ // 业务口径:无论群聊/私聊,统一按 direct 上报,避免会话层落到 group 显示分支(历史 bncr:g-*)。
474
+ return 'direct';
388
475
  }
389
476
 
390
477
  function routeKey(accountId: string, route: BncrRoute): string {
391
478
  return `${accountId}:${route.platform}:${route.groupId}:${route.userId}`.toLowerCase();
392
479
  }
393
480
 
481
+ function fileExtFromMime(mimeType?: string): string {
482
+ const mt = asString(mimeType || '').toLowerCase();
483
+ const map: Record<string, string> = {
484
+ 'image/jpeg': '.jpg',
485
+ 'image/jpg': '.jpg',
486
+ 'image/png': '.png',
487
+ 'image/webp': '.webp',
488
+ 'image/gif': '.gif',
489
+ 'video/mp4': '.mp4',
490
+ 'video/webm': '.webm',
491
+ 'video/quicktime': '.mov',
492
+ 'audio/mpeg': '.mp3',
493
+ 'audio/mp4': '.m4a',
494
+ 'application/pdf': '.pdf',
495
+ 'text/plain': '.txt',
496
+ };
497
+ return map[mt] || '';
498
+ }
499
+
500
+ function sanitizeFileName(rawName?: string, fallback = 'file.bin'): string {
501
+ const name = asString(rawName || '').trim();
502
+ const base = name || fallback;
503
+ const cleaned = base.replace(/[\\/:*?"<>|\x00-\x1F]+/g, '_').replace(/\s+/g, ' ').trim();
504
+ return cleaned || fallback;
505
+ }
506
+
507
+ function buildTimestampFileName(mimeType?: string): string {
508
+ const d = new Date();
509
+ const pad = (n: number) => String(n).padStart(2, '0');
510
+ const ts = `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}_${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
511
+ const ext = fileExtFromMime(mimeType) || '.bin';
512
+ return `bncr_${ts}_${Math.random().toString(16).slice(2, 8)}${ext}`;
513
+ }
514
+
515
+ function resolveOutboundFileName(params: { mediaUrl?: string; fileName?: string; mimeType?: string }): string {
516
+ const mediaUrl = asString(params.mediaUrl || '').trim();
517
+ const mimeType = asString(params.mimeType || '').trim();
518
+
519
+ // 线上下载的文件,统一用时间戳命名(避免超长/无意义文件名)
520
+ if (/^https?:\/\//i.test(mediaUrl)) {
521
+ return buildTimestampFileName(mimeType);
522
+ }
523
+
524
+ const candidate = sanitizeFileName(params.fileName, 'file.bin');
525
+ if (candidate.length <= 80) return candidate;
526
+
527
+ // 超长文件名做裁剪,尽量保留扩展名
528
+ const ext = path.extname(candidate);
529
+ const stem = candidate.slice(0, Math.max(1, 80 - ext.length));
530
+ return `${stem}${ext}`;
531
+ }
532
+
533
+ function resolveBncrOutboundMessageType(params: { mimeType?: string; fileName?: string; hintedType?: string; hasPayload?: boolean }): 'text' | 'image' | 'video' | 'voice' | 'audio' | 'file' {
534
+ const hinted = asString(params.hintedType || '').toLowerCase();
535
+ const hasPayload = !!params.hasPayload;
536
+ const mt = asString(params.mimeType || '').toLowerCase();
537
+ const major = mt.split('/')[0] || '';
538
+ const isStandard = hinted === 'text' || hinted === 'image' || hinted === 'video' || hinted === 'voice' || hinted === 'audio' || hinted === 'file';
539
+
540
+ // 文本类附件不应落成 text:当上游显式给 text,或上游 type 不在标准列表时,若带文件载荷且 mime 主类型为 text,则归到 file。
541
+ if (hasPayload && major === 'text' && (hinted === 'text' || !isStandard)) return 'file';
542
+
543
+ // 优先使用上游已给出的标准类型;仅当不在支持列表时再尝试纠正
544
+ if (isStandard) return hinted as any;
545
+
546
+ if (major === 'text' || major === 'image' || major === 'video' || major === 'audio') return major as any;
547
+
548
+ return 'file';
549
+ }
550
+
394
551
  class BncrBridgeRuntime {
395
552
  private api: OpenClawPluginApi;
396
553
  private statePath: string | null = null;
@@ -409,11 +566,28 @@ class BncrBridgeRuntime {
409
566
  private lastInboundByAccount = new Map<string, number>();
410
567
  private lastOutboundByAccount = new Map<string, number>();
411
568
 
569
+ // 内置健康/回归计数(替代独立脚本)
570
+ private startedAt = now();
571
+ private connectEventsByAccount = new Map<string, number>();
572
+ private inboundEventsByAccount = new Map<string, number>();
573
+ private activityEventsByAccount = new Map<string, number>();
574
+ private ackEventsByAccount = new Map<string, number>();
575
+
412
576
  private saveTimer: NodeJS.Timeout | null = null;
413
577
  private pushTimer: NodeJS.Timeout | null = null;
578
+ private pushDrainRunningAccounts = new Set<string>();
414
579
  private waiters = new Map<string, Array<() => void>>();
415
580
  private gatewayContext: GatewayRequestHandlerOptions['context'] | null = null;
416
581
 
582
+ // 文件互传状态(V1:尽力而为,重连不续传)
583
+ private fileSendTransfers = new Map<string, FileSendTransferState>(); // OpenClaw -> Bncr(服务端发起)
584
+ private fileRecvTransfers = new Map<string, FileRecvTransferState>(); // Bncr -> OpenClaw(客户端发起)
585
+ private fileAckWaiters = new Map<string, {
586
+ resolve: (payload: Record<string, unknown>) => void;
587
+ reject: (err: Error) => void;
588
+ timer: NodeJS.Timeout;
589
+ }>();
590
+
417
591
  constructor(api: OpenClawPluginApi) {
418
592
  this.api = api;
419
593
  }
@@ -421,7 +595,8 @@ class BncrBridgeRuntime {
421
595
  startService = async (ctx: OpenClawPluginServiceContext) => {
422
596
  this.statePath = path.join(ctx.stateDir, 'bncr-bridge-state.json');
423
597
  await this.loadState();
424
- this.api.logger.info('bncr-channel service started');
598
+ const bootDiag = this.buildIntegratedDiagnostics(BNCR_DEFAULT_ACCOUNT_ID);
599
+ this.api.logger.info(`bncr-channel service started (diag.ok=${bootDiag.regression.ok} routes=${bootDiag.regression.totalKnownRoutes} pending=${bootDiag.health.pending} dead=${bootDiag.health.deadLetter})`);
425
600
  };
426
601
 
427
602
  stopService = async () => {
@@ -441,6 +616,96 @@ class BncrBridgeRuntime {
441
616
  }, 300);
442
617
  }
443
618
 
619
+ private incrementCounter(map: Map<string, number>, accountId: string) {
620
+ const acc = normalizeAccountId(accountId);
621
+ map.set(acc, (map.get(acc) || 0) + 1);
622
+ }
623
+
624
+ private getCounter(map: Map<string, number>, accountId: string): number {
625
+ return map.get(normalizeAccountId(accountId)) || 0;
626
+ }
627
+
628
+ private countInvalidOutboxSessionKeys(accountId: string): number {
629
+ const acc = normalizeAccountId(accountId);
630
+ let count = 0;
631
+ for (const entry of this.outbox.values()) {
632
+ if (entry.accountId !== acc) continue;
633
+ if (!parseStrictBncrSessionKey(entry.sessionKey)) count += 1;
634
+ }
635
+ return count;
636
+ }
637
+
638
+ private countLegacyAccountResidue(accountId: string): number {
639
+ const acc = normalizeAccountId(accountId);
640
+ const mismatched = (raw?: string | null) => asString(raw || '').trim() && normalizeAccountId(raw) !== acc;
641
+
642
+ let count = 0;
643
+
644
+ for (const entry of this.outbox.values()) {
645
+ if (mismatched(entry.accountId)) count += 1;
646
+ }
647
+ for (const entry of this.deadLetter) {
648
+ if (mismatched(entry.accountId)) count += 1;
649
+ }
650
+ for (const info of this.sessionRoutes.values()) {
651
+ if (mismatched(info.accountId)) count += 1;
652
+ }
653
+ for (const key of this.lastSessionByAccount.keys()) {
654
+ if (mismatched(key)) count += 1;
655
+ }
656
+ for (const key of this.lastActivityByAccount.keys()) {
657
+ if (mismatched(key)) count += 1;
658
+ }
659
+ for (const key of this.lastInboundByAccount.keys()) {
660
+ if (mismatched(key)) count += 1;
661
+ }
662
+ for (const key of this.lastOutboundByAccount.keys()) {
663
+ if (mismatched(key)) count += 1;
664
+ }
665
+
666
+ return count;
667
+ }
668
+
669
+ private buildIntegratedDiagnostics(accountId: string) {
670
+ const acc = normalizeAccountId(accountId);
671
+ const t = now();
672
+
673
+ const pending = Array.from(this.outbox.values()).filter((v) => v.accountId === acc).length;
674
+ const dead = this.deadLetter.filter((v) => v.accountId === acc).length;
675
+ const invalidOutboxSessionKeys = this.countInvalidOutboxSessionKeys(acc);
676
+ const legacyAccountResidue = this.countLegacyAccountResidue(acc);
677
+
678
+ const totalKnownRoutes = Array.from(this.sessionRoutes.values()).filter((v) => v.accountId === acc).length;
679
+ const connected = this.isOnline(acc);
680
+
681
+ const pluginIndexExists = fs.existsSync(path.join(process.cwd(), 'plugins', 'bncr', 'index.ts'));
682
+ const pluginChannelExists = fs.existsSync(path.join(process.cwd(), 'plugins', 'bncr', 'src', 'channel.ts'));
683
+
684
+ const health = {
685
+ connected,
686
+ pending,
687
+ deadLetter: dead,
688
+ activeConnections: this.activeConnectionCount(acc),
689
+ connectEvents: this.getCounter(this.connectEventsByAccount, acc),
690
+ inboundEvents: this.getCounter(this.inboundEventsByAccount, acc),
691
+ activityEvents: this.getCounter(this.activityEventsByAccount, acc),
692
+ ackEvents: this.getCounter(this.ackEventsByAccount, acc),
693
+ uptimeSec: Math.floor((t - this.startedAt) / 1000),
694
+ };
695
+
696
+ const regression = {
697
+ pluginFilesPresent: pluginIndexExists && pluginChannelExists,
698
+ pluginIndexExists,
699
+ pluginChannelExists,
700
+ totalKnownRoutes,
701
+ invalidOutboxSessionKeys,
702
+ legacyAccountResidue,
703
+ ok: invalidOutboxSessionKeys === 0 && legacyAccountResidue === 0,
704
+ };
705
+
706
+ return { health, regression };
707
+ }
708
+
444
709
  private async loadState() {
445
710
  if (!this.statePath) return;
446
711
  const loaded = await readJsonFileWithFallback(this.statePath, {
@@ -702,55 +967,76 @@ class BncrBridgeRuntime {
702
967
  const delay = Math.max(0, Math.min(Number(delayMs || 0), 30_000));
703
968
  this.pushTimer = setTimeout(() => {
704
969
  this.pushTimer = null;
705
- this.flushPushQueue();
970
+ void this.flushPushQueue();
706
971
  }, delay);
707
972
  }
708
973
 
709
- private flushPushQueue(accountId?: string) {
710
- const t = now();
974
+ private async flushPushQueue(accountId?: string): Promise<void> {
711
975
  const filterAcc = accountId ? normalizeAccountId(accountId) : null;
712
- const entries = Array.from(this.outbox.values())
713
- .filter((entry) => (filterAcc ? entry.accountId === filterAcc : true))
714
- .sort((a, b) => a.createdAt - b.createdAt);
976
+ const targetAccounts = filterAcc
977
+ ? [filterAcc]
978
+ : Array.from(new Set(Array.from(this.outbox.values()).map((entry) => normalizeAccountId(entry.accountId))));
715
979
 
716
- let changed = false;
717
- let nextDelay: number | null = null;
980
+ let globalNextDelay: number | null = null;
718
981
 
719
- for (const entry of entries) {
720
- if (!this.isOnline(entry.accountId)) continue;
982
+ for (const acc of targetAccounts) {
983
+ if (!acc || this.pushDrainRunningAccounts.has(acc)) continue;
984
+ if (!this.isOnline(acc)) continue;
721
985
 
722
- if (entry.nextAttemptAt > t) {
723
- const wait = entry.nextAttemptAt - t;
724
- nextDelay = nextDelay == null ? wait : Math.min(nextDelay, wait);
725
- continue;
726
- }
727
-
728
- const pushed = this.tryPushEntry(entry);
729
- if (pushed) {
730
- changed = true;
731
- continue;
732
- }
986
+ this.pushDrainRunningAccounts.add(acc);
987
+ try {
988
+ let localNextDelay: number | null = null;
989
+
990
+ while (true) {
991
+ const t = now();
992
+ const entries = Array.from(this.outbox.values())
993
+ .filter((entry) => normalizeAccountId(entry.accountId) === acc)
994
+ .sort((a, b) => a.createdAt - b.createdAt);
995
+
996
+ if (!entries.length) break;
997
+ if (!this.isOnline(acc)) break;
998
+
999
+ const entry = entries.find((item) => item.nextAttemptAt <= t);
1000
+ if (!entry) {
1001
+ const wait = Math.max(0, entries[0].nextAttemptAt - t);
1002
+ localNextDelay = localNextDelay == null ? wait : Math.min(localNextDelay, wait);
1003
+ break;
1004
+ }
1005
+
1006
+ const pushed = this.tryPushEntry(entry);
1007
+ if (pushed) {
1008
+ this.scheduleSave();
1009
+ await this.sleepMs(PUSH_DRAIN_INTERVAL_MS);
1010
+ continue;
1011
+ }
1012
+
1013
+ const nextAttempt = entry.retryCount + 1;
1014
+ if (nextAttempt > MAX_RETRY) {
1015
+ this.moveToDeadLetter(entry, entry.lastError || 'push-retry-limit');
1016
+ continue;
1017
+ }
1018
+
1019
+ entry.retryCount = nextAttempt;
1020
+ entry.lastAttemptAt = t;
1021
+ entry.nextAttemptAt = t + backoffMs(nextAttempt);
1022
+ entry.lastError = entry.lastError || 'push-retry';
1023
+ this.outbox.set(entry.messageId, entry);
1024
+ this.scheduleSave();
1025
+
1026
+ const wait = Math.max(0, entry.nextAttemptAt - t);
1027
+ localNextDelay = localNextDelay == null ? wait : Math.min(localNextDelay, wait);
1028
+ break;
1029
+ }
733
1030
 
734
- const nextAttempt = entry.retryCount + 1;
735
- if (nextAttempt > MAX_RETRY) {
736
- this.moveToDeadLetter(entry, entry.lastError || 'push-retry-limit');
737
- changed = true;
738
- continue;
1031
+ if (localNextDelay != null) {
1032
+ globalNextDelay = globalNextDelay == null ? localNextDelay : Math.min(globalNextDelay, localNextDelay);
1033
+ }
1034
+ } finally {
1035
+ this.pushDrainRunningAccounts.delete(acc);
739
1036
  }
740
-
741
- entry.retryCount = nextAttempt;
742
- entry.lastAttemptAt = t;
743
- entry.nextAttemptAt = t + backoffMs(nextAttempt);
744
- entry.lastError = entry.lastError || 'push-retry';
745
- this.outbox.set(entry.messageId, entry);
746
- changed = true;
747
-
748
- const wait = entry.nextAttemptAt - t;
749
- nextDelay = nextDelay == null ? wait : Math.min(nextDelay, wait);
750
1037
  }
751
1038
 
752
- if (changed) this.scheduleSave();
753
- if (nextDelay != null) this.schedulePushDrain(nextDelay);
1039
+ if (globalNextDelay != null) this.schedulePushDrain(globalNextDelay);
754
1040
  }
755
1041
 
756
1042
  private async waitForOutbound(accountId: string, waitMs: number): Promise<void> {
@@ -799,6 +1085,18 @@ class BncrBridgeRuntime {
799
1085
  for (const [key, ts] of this.recentInbound.entries()) {
800
1086
  if (t - ts > dedupWindowMs) this.recentInbound.delete(key);
801
1087
  }
1088
+
1089
+ this.cleanupFileTransfers();
1090
+ }
1091
+
1092
+ private cleanupFileTransfers() {
1093
+ const t = now();
1094
+ for (const [id, st] of this.fileSendTransfers.entries()) {
1095
+ if (t - st.startedAt > FILE_TRANSFER_KEEP_MS) this.fileSendTransfers.delete(id);
1096
+ }
1097
+ for (const [id, st] of this.fileRecvTransfers.entries()) {
1098
+ if (t - st.startedAt > FILE_TRANSFER_KEEP_MS) this.fileRecvTransfers.delete(id);
1099
+ }
802
1100
  }
803
1101
 
804
1102
  private markSeen(accountId: string, connId: string, clientId?: string) {
@@ -906,10 +1204,10 @@ class BncrBridgeRuntime {
906
1204
  return alias?.route || parsed.route;
907
1205
  }
908
1206
 
909
- // 严谨目标解析:
910
- // 1) 先接受任意标签输入(strict / platform:group:user / Bncr-platform:group:user
911
- // 2) 再通过已知会话路由反查“真实 sessionKey
912
- // 3) 若反查不到或不属于 bncr,会直接失败(禁止拼凑 key 发送)
1207
+ // 严谨目标解析(终版兼容模式):
1208
+ // 1) 兼容 6 种输入格式(to/sessionKey
1209
+ // 2) 统一反查并归一为 sessionKey=agent:main:bncr:direct:<hex(scope)>
1210
+ // 3) 非兼容格式直接失败,并输出标准格式提示日志
913
1211
  private resolveVerifiedTarget(rawTarget: string, accountId: string): { sessionKey: string; route: BncrRoute; displayScope: string } {
914
1212
  const acc = normalizeAccountId(accountId);
915
1213
  const raw = asString(rawTarget).trim();
@@ -927,8 +1225,10 @@ class BncrBridgeRuntime {
927
1225
  }
928
1226
 
929
1227
  if (!route) {
930
- this.api.logger.warn?.(`[bncr-target-invalid] raw=${raw} accountId=${acc} reason=unparseable-or-unknown`);
931
- throw new Error(`bncr invalid target(label/sessionKey required): ${raw}`);
1228
+ this.api.logger.warn?.(
1229
+ `[bncr-target-invalid] raw=${raw} accountId=${acc} reason=unparseable-or-unknown standardTo=bncr:<platform>:<groupId>:<userId> standardSessionKey=agent:main:bncr:direct:<hex(scope)>`,
1230
+ );
1231
+ throw new Error(`bncr invalid target(standard: to=bncr:<platform>:<groupId>:<userId>): ${raw}`);
932
1232
  }
933
1233
 
934
1234
  const wantedRouteKey = routeKey(acc, route);
@@ -955,6 +1255,14 @@ class BncrBridgeRuntime {
955
1255
  throw new Error(`bncr target not found in known sessions: ${raw}`);
956
1256
  }
957
1257
 
1258
+ // 发送链路命中目标时,同步刷新 lastSession,避免状态页显示过期会话。
1259
+ this.lastSessionByAccount.set(acc, {
1260
+ sessionKey: best.sessionKey,
1261
+ scope: formatDisplayScope(best.route),
1262
+ updatedAt: now(),
1263
+ });
1264
+ this.scheduleSave();
1265
+
958
1266
  return {
959
1267
  sessionKey: best.sessionKey,
960
1268
  route: best.route,
@@ -966,6 +1274,87 @@ class BncrBridgeRuntime {
966
1274
  this.lastActivityByAccount.set(normalizeAccountId(accountId), at);
967
1275
  }
968
1276
 
1277
+ private fileAckKey(transferId: string, stage: string, chunkIndex?: number): string {
1278
+ const idx = Number.isFinite(Number(chunkIndex)) ? String(Number(chunkIndex)) : '-';
1279
+ return `${transferId}|${stage}|${idx}`;
1280
+ }
1281
+
1282
+ private waitForFileAck(params: { transferId: string; stage: string; chunkIndex?: number; timeoutMs?: number }) {
1283
+ const transferId = asString(params.transferId).trim();
1284
+ const stage = asString(params.stage).trim();
1285
+ const key = this.fileAckKey(transferId, stage, params.chunkIndex);
1286
+ const timeoutMs = Math.max(1_000, Math.min(Number(params.timeoutMs || FILE_ACK_TIMEOUT_MS), 120_000));
1287
+
1288
+ return new Promise<Record<string, unknown>>((resolve, reject) => {
1289
+ const timer = setTimeout(() => {
1290
+ this.fileAckWaiters.delete(key);
1291
+ reject(new Error(`file ack timeout: ${key}`));
1292
+ }, timeoutMs);
1293
+ this.fileAckWaiters.set(key, { resolve, reject, timer });
1294
+ });
1295
+ }
1296
+
1297
+ private resolveFileAck(params: { transferId: string; stage: string; chunkIndex?: number; payload: Record<string, unknown>; ok: boolean }) {
1298
+ const transferId = asString(params.transferId).trim();
1299
+ const stage = asString(params.stage).trim();
1300
+ const key = this.fileAckKey(transferId, stage, params.chunkIndex);
1301
+ const waiter = this.fileAckWaiters.get(key);
1302
+ if (!waiter) return false;
1303
+ this.fileAckWaiters.delete(key);
1304
+ clearTimeout(waiter.timer);
1305
+ if (params.ok) waiter.resolve(params.payload);
1306
+ else waiter.reject(new Error(asString(params.payload?.errorMessage || params.payload?.error || 'file ack failed')));
1307
+ return true;
1308
+ }
1309
+
1310
+ private pushFileEventToAccount(accountId: string, event: string, payload: Record<string, unknown>) {
1311
+ const connIds = this.resolvePushConnIds(accountId);
1312
+ if (!connIds.size || !this.gatewayContext) {
1313
+ throw new Error(`no active bncr connection for account=${accountId}`);
1314
+ }
1315
+ this.gatewayContext.broadcastToConnIds(event, payload, connIds);
1316
+ }
1317
+
1318
+ private resolveInboundFileType(mimeType: string, fileName: string): string {
1319
+ const mt = asString(mimeType).toLowerCase();
1320
+ const fn = asString(fileName).toLowerCase();
1321
+ if (mt.startsWith('image/') || /\.(png|jpe?g|gif|webp|bmp|svg)$/.test(fn)) return 'image';
1322
+ if (mt.startsWith('video/') || /\.(mp4|mov|mkv|avi|webm)$/.test(fn)) return 'video';
1323
+ if (mt.startsWith('audio/') || /\.(mp3|wav|m4a|aac|ogg|flac)$/.test(fn)) return 'audio';
1324
+ return mt || 'file';
1325
+ }
1326
+
1327
+ private resolveInboundFilesDir(): string {
1328
+ const dir = path.join(process.cwd(), '.openclaw', 'media', 'inbound', 'bncr');
1329
+ fs.mkdirSync(dir, { recursive: true });
1330
+ return dir;
1331
+ }
1332
+
1333
+ private async materializeRecvTransfer(st: FileRecvTransferState): Promise<{ path: string; fileSha256: string }> {
1334
+ const dir = this.resolveInboundFilesDir();
1335
+ const safeName = asString(st.fileName).trim() || `${st.transferId}.bin`;
1336
+ const finalPath = path.join(dir, safeName);
1337
+
1338
+ const ordered: Buffer[] = [];
1339
+ for (let i = 0; i < st.totalChunks; i++) {
1340
+ const chunk = st.bufferByChunk.get(i);
1341
+ if (!chunk) throw new Error(`missing chunk ${i}`);
1342
+ ordered.push(chunk);
1343
+ }
1344
+ const merged = Buffer.concat(ordered);
1345
+ if (Number(st.fileSize || 0) > 0 && merged.length !== Number(st.fileSize || 0)) {
1346
+ throw new Error(`size mismatch expected=${st.fileSize} got=${merged.length}`);
1347
+ }
1348
+
1349
+ const sha = createHash('sha256').update(merged).digest('hex');
1350
+ if (st.fileSha256 && sha !== st.fileSha256) {
1351
+ throw new Error(`sha256 mismatch expected=${st.fileSha256} got=${sha}`);
1352
+ }
1353
+
1354
+ fs.writeFileSync(finalPath, merged);
1355
+ return { path: finalPath, fileSha256: sha };
1356
+ }
1357
+
969
1358
  private fmtAgo(ts?: number | null): string {
970
1359
  if (!ts || !Number.isFinite(ts) || ts <= 0) return '-';
971
1360
  const diff = Math.max(0, now() - ts);
@@ -989,6 +1378,7 @@ class BncrBridgeRuntime {
989
1378
  const lastActivityAgo = this.fmtAgo(lastActAt);
990
1379
  const lastInboundAgo = this.fmtAgo(lastInboundAt);
991
1380
  const lastOutboundAgo = this.fmtAgo(lastOutboundAt);
1381
+ const diagnostics = this.buildIntegratedDiagnostics(acc);
992
1382
 
993
1383
  return {
994
1384
  pending,
@@ -1003,6 +1393,7 @@ class BncrBridgeRuntime {
1003
1393
  lastInboundAgo,
1004
1394
  lastOutboundAt,
1005
1395
  lastOutboundAgo,
1396
+ diagnostics,
1006
1397
  };
1007
1398
  }
1008
1399
 
@@ -1026,21 +1417,49 @@ class BncrBridgeRuntime {
1026
1417
  };
1027
1418
  }
1028
1419
 
1420
+ private buildStatusHeadline(accountId: string): string {
1421
+ const acc = normalizeAccountId(accountId);
1422
+ const diag = this.buildIntegratedDiagnostics(acc);
1423
+ const h = diag.health;
1424
+ const r = diag.regression;
1425
+
1426
+ const parts = [
1427
+ r.ok ? 'diag:ok' : 'diag:warn',
1428
+ `p:${h.pending}`,
1429
+ `d:${h.deadLetter}`,
1430
+ `c:${h.activeConnections}`,
1431
+ ];
1432
+
1433
+ if (!r.ok) {
1434
+ if (r.invalidOutboxSessionKeys > 0) parts.push(`invalid:${r.invalidOutboxSessionKeys}`);
1435
+ if (r.legacyAccountResidue > 0) parts.push(`legacy:${r.legacyAccountResidue}`);
1436
+ }
1437
+
1438
+ return parts.join(' ');
1439
+ }
1440
+
1441
+ getStatusHeadline(accountId: string): string {
1442
+ return this.buildStatusHeadline(accountId);
1443
+ }
1444
+
1029
1445
  getChannelSummary(defaultAccountId: string) {
1030
- const runtime = this.getAccountRuntimeSnapshot(defaultAccountId);
1446
+ const accountId = normalizeAccountId(defaultAccountId);
1447
+ const runtime = this.getAccountRuntimeSnapshot(accountId);
1448
+ const headline = this.buildStatusHeadline(accountId);
1449
+
1031
1450
  if (runtime.connected) {
1032
- return { linked: true };
1451
+ return { linked: true, self: { e164: headline } };
1033
1452
  }
1034
1453
 
1035
1454
  // 顶层汇总不绑定某个 accountId:任一账号在线都应显示 linked
1036
1455
  const t = now();
1037
1456
  for (const c of this.connections.values()) {
1038
1457
  if (t - c.lastSeenAt <= CONNECT_TTL_MS) {
1039
- return { linked: true };
1458
+ return { linked: true, self: { e164: headline } };
1040
1459
  }
1041
1460
  }
1042
1461
 
1043
- return { linked: false };
1462
+ return { linked: false, self: { e164: headline } };
1044
1463
  }
1045
1464
 
1046
1465
  private enqueueOutbound(entry: OutboxEntry) {
@@ -1111,6 +1530,211 @@ class BncrBridgeRuntime {
1111
1530
  };
1112
1531
  }
1113
1532
 
1533
+ private async sleepMs(ms: number): Promise<void> {
1534
+ await new Promise<void>((resolve) => setTimeout(resolve, Math.max(0, Number(ms || 0))));
1535
+ }
1536
+
1537
+ private waitChunkAck(params: {
1538
+ transferId: string;
1539
+ chunkIndex: number;
1540
+ timeoutMs?: number;
1541
+ }): Promise<void> {
1542
+ const { transferId, chunkIndex } = params;
1543
+ const timeoutMs = Math.max(1_000, Math.min(Number(params.timeoutMs || FILE_TRANSFER_ACK_TTL_MS), 60_000));
1544
+ const started = now();
1545
+
1546
+ return new Promise<void>((resolve, reject) => {
1547
+ const tick = async () => {
1548
+ const st = this.fileSendTransfers.get(transferId);
1549
+ if (!st) {
1550
+ reject(new Error('transfer state missing'));
1551
+ return;
1552
+ }
1553
+ if (st.failedChunks.has(chunkIndex)) {
1554
+ reject(new Error(st.failedChunks.get(chunkIndex) || `chunk ${chunkIndex} failed`));
1555
+ return;
1556
+ }
1557
+ if (st.ackedChunks.has(chunkIndex)) {
1558
+ resolve();
1559
+ return;
1560
+ }
1561
+ if (now() - started >= timeoutMs) {
1562
+ reject(new Error(`chunk ack timeout index=${chunkIndex}`));
1563
+ return;
1564
+ }
1565
+ await this.sleepMs(120);
1566
+ void tick();
1567
+ };
1568
+ void tick();
1569
+ });
1570
+ }
1571
+
1572
+ private waitCompleteAck(params: {
1573
+ transferId: string;
1574
+ timeoutMs?: number;
1575
+ }): Promise<{ path: string }> {
1576
+ const { transferId } = params;
1577
+ const timeoutMs = Math.max(2_000, Math.min(Number(params.timeoutMs || 60_000), 120_000));
1578
+ const started = now();
1579
+
1580
+ return new Promise<{ path: string }>((resolve, reject) => {
1581
+ const tick = async () => {
1582
+ const st = this.fileSendTransfers.get(transferId);
1583
+ if (!st) {
1584
+ reject(new Error('transfer state missing'));
1585
+ return;
1586
+ }
1587
+ if (st.status === 'aborted') {
1588
+ reject(new Error(st.error || 'transfer aborted'));
1589
+ return;
1590
+ }
1591
+ if (st.status === 'completed' && st.completedPath) {
1592
+ resolve({ path: st.completedPath });
1593
+ return;
1594
+ }
1595
+ if (now() - started >= timeoutMs) {
1596
+ reject(new Error('complete ack timeout'));
1597
+ return;
1598
+ }
1599
+ await this.sleepMs(150);
1600
+ void tick();
1601
+ };
1602
+ void tick();
1603
+ });
1604
+ }
1605
+
1606
+ private async transferMediaToBncrClient(params: {
1607
+ accountId: string;
1608
+ sessionKey: string;
1609
+ route: BncrRoute;
1610
+ mediaUrl: string;
1611
+ mediaLocalRoots?: readonly string[];
1612
+ }): Promise<{ mode: 'base64' | 'chunk'; mimeType?: string; fileName?: string; mediaBase64?: string; path?: string }> {
1613
+ const loaded = await this.api.runtime.media.loadWebMedia(params.mediaUrl, {
1614
+ localRoots: params.mediaLocalRoots,
1615
+ maxBytes: 50 * 1024 * 1024,
1616
+ });
1617
+
1618
+ const size = loaded.buffer.byteLength;
1619
+ const mimeType = loaded.contentType;
1620
+ const fileName = resolveOutboundFileName({
1621
+ mediaUrl: params.mediaUrl,
1622
+ fileName: loaded.fileName,
1623
+ mimeType,
1624
+ });
1625
+
1626
+ if (!FILE_FORCE_CHUNK && size <= FILE_INLINE_THRESHOLD) {
1627
+ return {
1628
+ mode: 'base64',
1629
+ mimeType,
1630
+ fileName,
1631
+ mediaBase64: loaded.buffer.toString('base64'),
1632
+ };
1633
+ }
1634
+
1635
+ const ctx = this.gatewayContext;
1636
+ if (!ctx) throw new Error('gateway context unavailable');
1637
+
1638
+ const connIds = this.resolvePushConnIds(params.accountId);
1639
+ if (!connIds.size) throw new Error('no active bncr client for file chunk transfer');
1640
+
1641
+ const transferId = randomUUID();
1642
+ const chunkSize = 256 * 1024;
1643
+ const totalChunks = Math.ceil(size / chunkSize);
1644
+ const fileSha256 = createHash('sha256').update(loaded.buffer).digest('hex');
1645
+
1646
+ const st: FileSendTransferState = {
1647
+ transferId,
1648
+ accountId: normalizeAccountId(params.accountId),
1649
+ sessionKey: params.sessionKey,
1650
+ route: params.route,
1651
+ fileName,
1652
+ mimeType: mimeType || 'application/octet-stream',
1653
+ fileSize: size,
1654
+ chunkSize,
1655
+ totalChunks,
1656
+ fileSha256,
1657
+ startedAt: now(),
1658
+ status: 'init',
1659
+ ackedChunks: new Set(),
1660
+ failedChunks: new Map(),
1661
+ };
1662
+ this.fileSendTransfers.set(transferId, st);
1663
+
1664
+ ctx.broadcastToConnIds('bncr.file.init', {
1665
+ transferId,
1666
+ direction: 'oc2bncr',
1667
+ sessionKey: params.sessionKey,
1668
+ platform: params.route.platform,
1669
+ groupId: params.route.groupId,
1670
+ userId: params.route.userId,
1671
+ fileName,
1672
+ mimeType,
1673
+ fileSize: size,
1674
+ chunkSize,
1675
+ totalChunks,
1676
+ fileSha256,
1677
+ ts: now(),
1678
+ }, connIds);
1679
+
1680
+ // 逐块发送并等待 ACK
1681
+ for (let idx = 0; idx < totalChunks; idx++) {
1682
+ const start = idx * chunkSize;
1683
+ const end = Math.min(start + chunkSize, size);
1684
+ const slice = loaded.buffer.subarray(start, end);
1685
+ const chunkSha256 = createHash('sha256').update(slice).digest('hex');
1686
+
1687
+ let ok = false;
1688
+ let lastErr: unknown = null;
1689
+ for (let attempt = 1; attempt <= 3; attempt++) {
1690
+ ctx.broadcastToConnIds('bncr.file.chunk', {
1691
+ transferId,
1692
+ chunkIndex: idx,
1693
+ offset: start,
1694
+ size: slice.byteLength,
1695
+ chunkSha256,
1696
+ base64: slice.toString('base64'),
1697
+ ts: now(),
1698
+ }, connIds);
1699
+
1700
+ try {
1701
+ await this.waitChunkAck({ transferId, chunkIndex: idx, timeoutMs: FILE_TRANSFER_ACK_TTL_MS });
1702
+ ok = true;
1703
+ break;
1704
+ } catch (err) {
1705
+ lastErr = err;
1706
+ await this.sleepMs(150 * attempt);
1707
+ }
1708
+ }
1709
+
1710
+ if (!ok) {
1711
+ st.status = 'aborted';
1712
+ st.error = String((lastErr as any)?.message || lastErr || `chunk-${idx}-failed`);
1713
+ this.fileSendTransfers.set(transferId, st);
1714
+ ctx.broadcastToConnIds('bncr.file.abort', {
1715
+ transferId,
1716
+ reason: st.error,
1717
+ ts: now(),
1718
+ }, connIds);
1719
+ throw new Error(st.error);
1720
+ }
1721
+ }
1722
+
1723
+ ctx.broadcastToConnIds('bncr.file.complete', {
1724
+ transferId,
1725
+ ts: now(),
1726
+ }, connIds);
1727
+
1728
+ const done = await this.waitCompleteAck({ transferId, timeoutMs: 60_000 });
1729
+
1730
+ return {
1731
+ mode: 'chunk',
1732
+ mimeType,
1733
+ fileName,
1734
+ path: done.path,
1735
+ };
1736
+ }
1737
+
1114
1738
  private async enqueueFromReply(params: {
1115
1739
  accountId: string;
1116
1740
  sessionKey: string;
@@ -1129,7 +1753,13 @@ class BncrBridgeRuntime {
1129
1753
  if (mediaList.length > 0) {
1130
1754
  let first = true;
1131
1755
  for (const mediaUrl of mediaList) {
1132
- const media = await this.payloadMediaToBase64(mediaUrl, mediaLocalRoots);
1756
+ const media = await this.transferMediaToBncrClient({
1757
+ accountId,
1758
+ sessionKey,
1759
+ route,
1760
+ mediaUrl,
1761
+ mediaLocalRoots,
1762
+ });
1133
1763
  const messageId = randomUUID();
1134
1764
  const mediaMsg = first ? asString(payload.text || '') : '';
1135
1765
  const frame = {
@@ -1141,11 +1771,21 @@ class BncrBridgeRuntime {
1141
1771
  platform: route.platform,
1142
1772
  groupId: route.groupId,
1143
1773
  userId: route.userId,
1144
- type: media.mimeType,
1774
+ type: resolveBncrOutboundMessageType({
1775
+ mimeType: media.mimeType,
1776
+ fileName: media.fileName,
1777
+ hasPayload: !!(media.path || media.mediaBase64),
1778
+ }),
1779
+ mimeType: media.mimeType || '',
1145
1780
  msg: mediaMsg,
1146
- path: mediaUrl,
1147
- base64: media.mediaBase64,
1148
- fileName: media.fileName,
1781
+ path: media.path || mediaUrl,
1782
+ base64: media.mediaBase64 || '',
1783
+ fileName: resolveOutboundFileName({
1784
+ mediaUrl,
1785
+ fileName: media.fileName,
1786
+ mimeType: media.mimeType,
1787
+ }),
1788
+ transferMode: media.mode,
1149
1789
  },
1150
1790
  ts: now(),
1151
1791
  };
@@ -1207,6 +1847,7 @@ class BncrBridgeRuntime {
1207
1847
  this.rememberGatewayContext(context);
1208
1848
  this.markSeen(accountId, connId, clientId);
1209
1849
  this.markActivity(accountId);
1850
+ this.incrementCounter(this.connectEventsByAccount, accountId);
1210
1851
 
1211
1852
  respond(true, {
1212
1853
  channel: CHANNEL_ID,
@@ -1218,6 +1859,7 @@ class BncrBridgeRuntime {
1218
1859
  activeConnections: this.activeConnectionCount(accountId),
1219
1860
  pending: Array.from(this.outbox.values()).filter((v) => v.accountId === accountId).length,
1220
1861
  deadLetter: this.deadLetter.filter((v) => v.accountId === accountId).length,
1862
+ diagnostics: this.buildIntegratedDiagnostics(accountId),
1221
1863
  now: now(),
1222
1864
  });
1223
1865
 
@@ -1231,6 +1873,7 @@ class BncrBridgeRuntime {
1231
1873
  const clientId = asString((params as any)?.clientId || '').trim() || undefined;
1232
1874
  this.rememberGatewayContext(context);
1233
1875
  this.markSeen(accountId, connId, clientId);
1876
+ this.incrementCounter(this.ackEventsByAccount, accountId);
1234
1877
 
1235
1878
  const messageId = asString(params?.messageId || '').trim();
1236
1879
  if (!messageId) {
@@ -1280,6 +1923,7 @@ class BncrBridgeRuntime {
1280
1923
  this.rememberGatewayContext(context);
1281
1924
  this.markSeen(accountId, connId, clientId);
1282
1925
  this.markActivity(accountId);
1926
+ this.incrementCounter(this.activityEventsByAccount, accountId);
1283
1927
 
1284
1928
  // 轻量活动心跳:仅刷新在线活跃状态,不承担拉取职责。
1285
1929
  respond(true, {
@@ -1293,6 +1937,299 @@ class BncrBridgeRuntime {
1293
1937
  });
1294
1938
  };
1295
1939
 
1940
+ handleDiagnostics = async ({ params, respond }: GatewayRequestHandlerOptions) => {
1941
+ const accountId = normalizeAccountId(asString(params?.accountId || ''));
1942
+ const runtime = this.getAccountRuntimeSnapshot(accountId);
1943
+ const diagnostics = this.buildIntegratedDiagnostics(accountId);
1944
+
1945
+ respond(true, {
1946
+ channel: CHANNEL_ID,
1947
+ accountId,
1948
+ runtime,
1949
+ diagnostics,
1950
+ now: now(),
1951
+ });
1952
+ };
1953
+
1954
+ handleFileInit = async ({ params, respond, client, context }: GatewayRequestHandlerOptions) => {
1955
+ const accountId = normalizeAccountId(asString(params?.accountId || ''));
1956
+ const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
1957
+ const clientId = asString((params as any)?.clientId || '').trim() || undefined;
1958
+ this.rememberGatewayContext(context);
1959
+ this.markSeen(accountId, connId, clientId);
1960
+ this.markActivity(accountId);
1961
+
1962
+ const transferId = asString(params?.transferId || '').trim();
1963
+ const sessionKey = asString(params?.sessionKey || '').trim();
1964
+ const fileName = asString(params?.fileName || '').trim() || 'file.bin';
1965
+ const mimeType = asString(params?.mimeType || '').trim() || 'application/octet-stream';
1966
+ const fileSize = Number(params?.fileSize || 0);
1967
+ const chunkSize = Number(params?.chunkSize || 256 * 1024);
1968
+ const totalChunks = Number(params?.totalChunks || 0);
1969
+ const fileSha256 = asString(params?.fileSha256 || '').trim();
1970
+
1971
+ if (!transferId || !sessionKey || !fileSize || !chunkSize || !totalChunks) {
1972
+ respond(false, { error: 'transferId/sessionKey/fileSize/chunkSize/totalChunks required' });
1973
+ return;
1974
+ }
1975
+
1976
+ const normalized = normalizeStoredSessionKey(sessionKey);
1977
+ if (!normalized) {
1978
+ respond(false, { error: 'invalid sessionKey' });
1979
+ return;
1980
+ }
1981
+
1982
+ const existing = this.fileRecvTransfers.get(transferId);
1983
+ if (existing) {
1984
+ respond(true, {
1985
+ ok: true,
1986
+ transferId,
1987
+ status: existing.status,
1988
+ duplicated: true,
1989
+ });
1990
+ return;
1991
+ }
1992
+
1993
+ const route = parseRouteLike({
1994
+ platform: asString(params?.platform || normalized.route.platform),
1995
+ groupId: asString(params?.groupId || normalized.route.groupId),
1996
+ userId: asString(params?.userId || normalized.route.userId),
1997
+ }) || normalized.route;
1998
+
1999
+ this.fileRecvTransfers.set(transferId, {
2000
+ transferId,
2001
+ accountId,
2002
+ sessionKey: normalized.sessionKey,
2003
+ route,
2004
+ fileName,
2005
+ mimeType,
2006
+ fileSize,
2007
+ chunkSize,
2008
+ totalChunks,
2009
+ fileSha256,
2010
+ startedAt: now(),
2011
+ status: 'init',
2012
+ bufferByChunk: new Map(),
2013
+ receivedChunks: new Set(),
2014
+ });
2015
+
2016
+ respond(true, {
2017
+ ok: true,
2018
+ transferId,
2019
+ status: 'init',
2020
+ });
2021
+ };
2022
+
2023
+ handleFileChunk = async ({ params, respond, client, context }: GatewayRequestHandlerOptions) => {
2024
+ const accountId = normalizeAccountId(asString(params?.accountId || ''));
2025
+ const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
2026
+ const clientId = asString((params as any)?.clientId || '').trim() || undefined;
2027
+ this.rememberGatewayContext(context);
2028
+ this.markSeen(accountId, connId, clientId);
2029
+ this.markActivity(accountId);
2030
+
2031
+ const transferId = asString(params?.transferId || '').trim();
2032
+ const chunkIndex = Number(params?.chunkIndex ?? -1);
2033
+ const offset = Number(params?.offset ?? 0);
2034
+ const size = Number(params?.size ?? 0);
2035
+ const chunkSha256 = asString(params?.chunkSha256 || '').trim();
2036
+ const base64 = asString(params?.base64 || '');
2037
+
2038
+ if (!transferId || chunkIndex < 0 || !base64) {
2039
+ respond(false, { error: 'transferId/chunkIndex/base64 required' });
2040
+ return;
2041
+ }
2042
+
2043
+ const st = this.fileRecvTransfers.get(transferId);
2044
+ if (!st) {
2045
+ respond(false, { error: 'transfer not found' });
2046
+ return;
2047
+ }
2048
+
2049
+ try {
2050
+ const buf = Buffer.from(base64, 'base64');
2051
+ if (size > 0 && buf.length !== size) {
2052
+ throw new Error(`chunk size mismatch expected=${size} got=${buf.length}`);
2053
+ }
2054
+ if (chunkSha256) {
2055
+ const digest = createHash('sha256').update(buf).digest('hex');
2056
+ if (digest !== chunkSha256) throw new Error('chunk sha256 mismatch');
2057
+ }
2058
+ st.bufferByChunk.set(chunkIndex, buf);
2059
+ st.receivedChunks.add(chunkIndex);
2060
+ st.status = 'transferring';
2061
+ this.fileRecvTransfers.set(transferId, st);
2062
+
2063
+ respond(true, {
2064
+ ok: true,
2065
+ transferId,
2066
+ chunkIndex,
2067
+ offset,
2068
+ received: st.receivedChunks.size,
2069
+ totalChunks: st.totalChunks,
2070
+ });
2071
+ } catch (error) {
2072
+ respond(false, { error: String((error as any)?.message || error || 'chunk invalid') });
2073
+ }
2074
+ };
2075
+
2076
+ handleFileComplete = async ({ params, respond, client, context }: GatewayRequestHandlerOptions) => {
2077
+ const accountId = normalizeAccountId(asString(params?.accountId || ''));
2078
+ const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
2079
+ const clientId = asString((params as any)?.clientId || '').trim() || undefined;
2080
+ this.rememberGatewayContext(context);
2081
+ this.markSeen(accountId, connId, clientId);
2082
+ this.markActivity(accountId);
2083
+
2084
+ const transferId = asString(params?.transferId || '').trim();
2085
+ if (!transferId) {
2086
+ respond(false, { error: 'transferId required' });
2087
+ return;
2088
+ }
2089
+
2090
+ const st = this.fileRecvTransfers.get(transferId);
2091
+ if (!st) {
2092
+ respond(false, { error: 'transfer not found' });
2093
+ return;
2094
+ }
2095
+
2096
+ try {
2097
+ if (st.receivedChunks.size < st.totalChunks) {
2098
+ throw new Error(`chunk not complete received=${st.receivedChunks.size} total=${st.totalChunks}`);
2099
+ }
2100
+
2101
+ const ordered = Array.from(st.bufferByChunk.entries()).sort((a, b) => a[0] - b[0]).map((x) => x[1]);
2102
+ const merged = Buffer.concat(ordered);
2103
+ if (st.fileSize > 0 && merged.length !== st.fileSize) {
2104
+ throw new Error(`file size mismatch expected=${st.fileSize} got=${merged.length}`);
2105
+ }
2106
+ const digest = createHash('sha256').update(merged).digest('hex');
2107
+ if (st.fileSha256 && digest !== st.fileSha256) {
2108
+ throw new Error('file sha256 mismatch');
2109
+ }
2110
+
2111
+ const saved = await this.api.runtime.channel.media.saveMediaBuffer(
2112
+ merged,
2113
+ st.mimeType,
2114
+ 'inbound',
2115
+ 50 * 1024 * 1024,
2116
+ st.fileName,
2117
+ );
2118
+ st.completedPath = saved.path;
2119
+ st.status = 'completed';
2120
+ this.fileRecvTransfers.set(transferId, st);
2121
+
2122
+ respond(true, {
2123
+ ok: true,
2124
+ transferId,
2125
+ path: saved.path,
2126
+ size: merged.length,
2127
+ fileName: st.fileName,
2128
+ mimeType: st.mimeType,
2129
+ fileSha256: digest,
2130
+ });
2131
+ } catch (error) {
2132
+ st.status = 'aborted';
2133
+ st.error = String((error as any)?.message || error || 'complete failed');
2134
+ this.fileRecvTransfers.set(transferId, st);
2135
+ respond(false, { error: st.error });
2136
+ }
2137
+ };
2138
+
2139
+ handleFileAbort = async ({ params, respond, client, context }: GatewayRequestHandlerOptions) => {
2140
+ const accountId = normalizeAccountId(asString(params?.accountId || ''));
2141
+ const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
2142
+ const clientId = asString((params as any)?.clientId || '').trim() || undefined;
2143
+ this.rememberGatewayContext(context);
2144
+ this.markSeen(accountId, connId, clientId);
2145
+ this.markActivity(accountId);
2146
+
2147
+ const transferId = asString(params?.transferId || '').trim();
2148
+ if (!transferId) {
2149
+ respond(false, { error: 'transferId required' });
2150
+ return;
2151
+ }
2152
+
2153
+ const st = this.fileRecvTransfers.get(transferId);
2154
+ if (!st) {
2155
+ respond(true, { ok: true, transferId, message: 'not-found' });
2156
+ return;
2157
+ }
2158
+
2159
+ st.status = 'aborted';
2160
+ st.error = asString(params?.reason || 'aborted');
2161
+ this.fileRecvTransfers.set(transferId, st);
2162
+
2163
+ respond(true, {
2164
+ ok: true,
2165
+ transferId,
2166
+ status: 'aborted',
2167
+ });
2168
+ };
2169
+
2170
+ handleFileAck = async ({ params, respond, client, context }: GatewayRequestHandlerOptions) => {
2171
+ const accountId = normalizeAccountId(asString(params?.accountId || ''));
2172
+ const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
2173
+ const clientId = asString((params as any)?.clientId || '').trim() || undefined;
2174
+ this.rememberGatewayContext(context);
2175
+ this.markSeen(accountId, connId, clientId);
2176
+ this.markActivity(accountId);
2177
+
2178
+ const transferId = asString(params?.transferId || '').trim();
2179
+ const stage = asString(params?.stage || '').trim();
2180
+ const ok = params?.ok !== false;
2181
+ const chunkIndex = Number(params?.chunkIndex ?? -1);
2182
+
2183
+ if (!transferId || !stage) {
2184
+ respond(false, { error: 'transferId/stage required' });
2185
+ return;
2186
+ }
2187
+
2188
+ const st = this.fileSendTransfers.get(transferId);
2189
+ if (st) {
2190
+ if (!ok) {
2191
+ const code = asString(params?.errorCode || 'ACK_FAILED');
2192
+ const msg = asString(params?.errorMessage || 'ack failed');
2193
+ st.error = `${code}:${msg}`;
2194
+ if (stage === 'chunk' && chunkIndex >= 0) st.failedChunks.set(chunkIndex, st.error);
2195
+ if (stage === 'complete') st.status = 'aborted';
2196
+ } else {
2197
+ if (stage === 'chunk' && chunkIndex >= 0) {
2198
+ st.ackedChunks.add(chunkIndex);
2199
+ st.status = 'transferring';
2200
+ }
2201
+ if (stage === 'complete') {
2202
+ st.status = 'completed';
2203
+ st.completedPath = asString(params?.path || '').trim() || st.completedPath;
2204
+ }
2205
+ }
2206
+ this.fileSendTransfers.set(transferId, st);
2207
+ }
2208
+
2209
+ // 唤醒等待中的 chunk/complete ACK
2210
+ this.resolveFileAck({
2211
+ transferId,
2212
+ stage,
2213
+ chunkIndex: chunkIndex >= 0 ? chunkIndex : undefined,
2214
+ payload: {
2215
+ ok,
2216
+ transferId,
2217
+ stage,
2218
+ path: asString(params?.path || '').trim(),
2219
+ errorCode: asString(params?.errorCode || ''),
2220
+ errorMessage: asString(params?.errorMessage || ''),
2221
+ },
2222
+ ok,
2223
+ });
2224
+
2225
+ respond(true, {
2226
+ ok: true,
2227
+ transferId,
2228
+ stage,
2229
+ state: st?.status || 'late',
2230
+ });
2231
+ };
2232
+
1296
2233
  handleInbound = async ({ params, respond, client, context }: GatewayRequestHandlerOptions) => {
1297
2234
  const accountId = normalizeAccountId(asString(params?.accountId || ''));
1298
2235
  const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
@@ -1300,6 +2237,7 @@ class BncrBridgeRuntime {
1300
2237
  this.rememberGatewayContext(context);
1301
2238
  this.markSeen(accountId, connId, clientId);
1302
2239
  this.markActivity(accountId);
2240
+ this.incrementCounter(this.inboundEventsByAccount, accountId);
1303
2241
 
1304
2242
  const platform = asString(params?.platform || '').trim();
1305
2243
  const groupId = asString(params?.groupId || '0').trim() || '0';
@@ -1320,6 +2258,7 @@ class BncrBridgeRuntime {
1320
2258
  const text = asString(params?.msg || '');
1321
2259
  const msgType = asString(params?.type || 'text') || 'text';
1322
2260
  const mediaBase64 = asString(params?.base64 || '');
2261
+ const mediaPathFromTransfer = asString(params?.path || '').trim();
1323
2262
  const mimeType = asString(params?.mimeType || '').trim() || undefined;
1324
2263
  const fileName = asString(params?.fileName || '').trim() || undefined;
1325
2264
  const msgId = asString(params?.msgId || '').trim() || undefined;
@@ -1396,6 +2335,8 @@ class BncrBridgeRuntime {
1396
2335
  fileName,
1397
2336
  );
1398
2337
  mediaPath = saved.path;
2338
+ } else if (mediaPathFromTransfer && fs.existsSync(mediaPathFromTransfer)) {
2339
+ mediaPath = mediaPathFromTransfer;
1399
2340
  }
1400
2341
 
1401
2342
  const rawBody = agentText || (msgType === 'text' ? '' : `[${msgType}]`);
@@ -1412,6 +2353,11 @@ class BncrBridgeRuntime {
1412
2353
  });
1413
2354
 
1414
2355
  const displayTo = formatDisplayScope(route);
2356
+ // 全场景口径:SenderId 统一上报为 hex(scope)
2357
+ // 作为平台侧稳定身份键,避免退化为裸 userId。
2358
+ const senderIdForContext =
2359
+ parseStrictBncrSessionKey(baseSessionKey)?.scopeHex
2360
+ || routeScopeToHex(route);
1415
2361
  const ctxPayload = this.api.runtime.channel.reply.finalizeInboundContext({
1416
2362
  Body: body,
1417
2363
  BodyForAgent: rawBody,
@@ -1425,7 +2371,7 @@ class BncrBridgeRuntime {
1425
2371
  AccountId: accountId,
1426
2372
  ChatType: peer.kind,
1427
2373
  ConversationLabel: displayTo,
1428
- SenderId: userId,
2374
+ SenderId: senderIdForContext,
1429
2375
  Provider: CHANNEL_ID,
1430
2376
  Surface: CHANNEL_ID,
1431
2377
  MessageSid: msgId,
@@ -1529,6 +2475,25 @@ class BncrBridgeRuntime {
1529
2475
  const accountId = normalizeAccountId(ctx.accountId);
1530
2476
  const to = asString(ctx.to || '').trim();
1531
2477
 
2478
+ if (BNCR_DEBUG_VERBOSE) {
2479
+ this.api.logger.info?.(
2480
+ `[bncr-send-entry:text] ${JSON.stringify({
2481
+ accountId,
2482
+ to,
2483
+ text: asString(ctx?.text || ''),
2484
+ mediaUrl: asString(ctx?.mediaUrl || ''),
2485
+ sessionKey: asString(ctx?.sessionKey || ''),
2486
+ mirrorSessionKey: asString(ctx?.mirror?.sessionKey || ''),
2487
+ rawCtx: {
2488
+ to: ctx?.to,
2489
+ accountId: ctx?.accountId,
2490
+ threadId: ctx?.threadId,
2491
+ replyToId: ctx?.replyToId,
2492
+ },
2493
+ })}`,
2494
+ );
2495
+ }
2496
+
1532
2497
  const verified = this.resolveVerifiedTarget(to, accountId);
1533
2498
 
1534
2499
  this.rememberSessionRoute(verified.sessionKey, accountId, verified.route);
@@ -1550,6 +2515,26 @@ class BncrBridgeRuntime {
1550
2515
  const accountId = normalizeAccountId(ctx.accountId);
1551
2516
  const to = asString(ctx.to || '').trim();
1552
2517
 
2518
+ if (BNCR_DEBUG_VERBOSE) {
2519
+ this.api.logger.info?.(
2520
+ `[bncr-send-entry:media] ${JSON.stringify({
2521
+ accountId,
2522
+ to,
2523
+ text: asString(ctx?.text || ''),
2524
+ mediaUrl: asString(ctx?.mediaUrl || ''),
2525
+ mediaUrls: Array.isArray(ctx?.mediaUrls) ? ctx.mediaUrls : undefined,
2526
+ sessionKey: asString(ctx?.sessionKey || ''),
2527
+ mirrorSessionKey: asString(ctx?.mirror?.sessionKey || ''),
2528
+ rawCtx: {
2529
+ to: ctx?.to,
2530
+ accountId: ctx?.accountId,
2531
+ threadId: ctx?.threadId,
2532
+ replyToId: ctx?.replyToId,
2533
+ },
2534
+ })}`,
2535
+ );
2536
+ }
2537
+
1553
2538
  const verified = this.resolveVerifiedTarget(to, accountId);
1554
2539
 
1555
2540
  this.rememberSessionRoute(verified.sessionKey, accountId, verified.route);
@@ -1632,7 +2617,7 @@ export function createBncrChannelPlugin(bridge: BncrBridgeRuntime) {
1632
2617
  looksLikeId: (raw: string, normalized?: string) => {
1633
2618
  return Boolean(asString(normalized || raw).trim());
1634
2619
  },
1635
- hint: 'Any label accepted; will be validated against known bncr sessions before send',
2620
+ hint: 'Compat(6): agent:main:bncr:direct:<hex>, agent:main:bncr:group:<hex>, bncr:<hex>, bncr:g-<hex>, bncr:<platform>:<group>:<user>, bncr:g-<platform>:<group>:<user>; preferred to=bncr:<platform>:<group>:<user>, canonical sessionKey=agent:main:bncr:direct:<hex>',
1636
2621
  },
1637
2622
  },
1638
2623
  configSchema: BncrConfigSchema,
@@ -1709,6 +2694,7 @@ export function createBncrChannelPlugin(bridge: BncrBridgeRuntime) {
1709
2694
  const lastInboundAgo = rt?.lastInboundAgo ?? meta.lastInboundAgo ?? '-';
1710
2695
  const lastOutboundAt = rt?.lastOutboundAt ?? meta.lastOutboundAt ?? null;
1711
2696
  const lastOutboundAgo = rt?.lastOutboundAgo ?? meta.lastOutboundAgo ?? '-';
2697
+ const diagnostics = rt?.diagnostics ?? meta.diagnostics ?? null;
1712
2698
  // 右侧状态字段统一:离线时也显示 Status(避免出现 configured 文案)
1713
2699
  const normalizedMode = rt?.mode === 'linked'
1714
2700
  ? 'linked'
@@ -1730,6 +2716,7 @@ export function createBncrChannelPlugin(bridge: BncrBridgeRuntime) {
1730
2716
  mode: normalizedMode,
1731
2717
  pending,
1732
2718
  deadLetter,
2719
+ healthSummary: bridge.getStatusHeadline(account?.accountId),
1733
2720
  lastSessionKey,
1734
2721
  lastSessionScope,
1735
2722
  lastSessionAt,
@@ -1740,6 +2727,7 @@ export function createBncrChannelPlugin(bridge: BncrBridgeRuntime) {
1740
2727
  lastInboundAgo,
1741
2728
  lastOutboundAt,
1742
2729
  lastOutboundAgo,
2730
+ diagnostics,
1743
2731
  };
1744
2732
  },
1745
2733
  resolveAccountState: ({ enabled, configured, account, cfg, runtime }: any) => {
@@ -1750,7 +2738,18 @@ export function createBncrChannelPlugin(bridge: BncrBridgeRuntime) {
1750
2738
  return rt?.connected ? 'linked' : 'configured';
1751
2739
  },
1752
2740
  },
1753
- gatewayMethods: ['bncr.connect', 'bncr.inbound', 'bncr.activity', 'bncr.ack'],
2741
+ gatewayMethods: [
2742
+ 'bncr.connect',
2743
+ 'bncr.inbound',
2744
+ 'bncr.activity',
2745
+ 'bncr.ack',
2746
+ 'bncr.diagnostics',
2747
+ 'bncr.file.init',
2748
+ 'bncr.file.chunk',
2749
+ 'bncr.file.complete',
2750
+ 'bncr.file.abort',
2751
+ 'bncr.file.ack',
2752
+ ],
1754
2753
  gateway: {
1755
2754
  startAccount: bridge.channelStartAccount,
1756
2755
  stopAccount: bridge.channelStopAccount,