@xmoxmo/bncr 0.0.2 → 0.0.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.
Files changed (4) hide show
  1. package/README.md +106 -62
  2. package/index.ts +24 -0
  3. package/package.json +1 -1
  4. package/src/channel.ts +1017 -41
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,14 @@ 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 FILE_FORCE_CHUNK = true; // 统一走 WS 分块,保留 base64 仅作兜底
25
+ const FILE_INLINE_THRESHOLD = 5 * 1024 * 1024; // fallback 阈值(仅 FILE_FORCE_CHUNK=false 时生效)
26
+ const FILE_CHUNK_SIZE = 256 * 1024; // 256KB
27
+ const FILE_CHUNK_RETRY = 3;
28
+ const FILE_ACK_TIMEOUT_MS = 30_000;
29
+ const FILE_TRANSFER_ACK_TTL_MS = 30_000;
30
+ const FILE_TRANSFER_KEEP_MS = 6 * 60 * 60 * 1000;
31
+ const BNCR_DEBUG_VERBOSE = true; // 临时调试:打印发送入口完整请求体
23
32
 
24
33
  const BncrConfigSchema = {
25
34
  schema: {
@@ -68,6 +77,44 @@ type OutboxEntry = {
68
77
  lastError?: string;
69
78
  };
70
79
 
80
+ type FileSendTransferState = {
81
+ transferId: string;
82
+ accountId: string;
83
+ sessionKey: string;
84
+ route: BncrRoute;
85
+ fileName: string;
86
+ mimeType: string;
87
+ fileSize: number;
88
+ chunkSize: number;
89
+ totalChunks: number;
90
+ fileSha256: string;
91
+ startedAt: number;
92
+ status: 'init' | 'transferring' | 'completed' | 'aborted';
93
+ ackedChunks: Set<number>;
94
+ failedChunks: Map<number, string>;
95
+ completedPath?: string;
96
+ error?: string;
97
+ };
98
+
99
+ type FileRecvTransferState = {
100
+ transferId: string;
101
+ accountId: string;
102
+ sessionKey: string;
103
+ route: BncrRoute;
104
+ fileName: string;
105
+ mimeType: string;
106
+ fileSize: number;
107
+ chunkSize: number;
108
+ totalChunks: number;
109
+ fileSha256: string;
110
+ startedAt: number;
111
+ status: 'init' | 'transferring' | 'completed' | 'aborted';
112
+ bufferByChunk: Map<number, Buffer>;
113
+ receivedChunks: Set<number>;
114
+ completedPath?: string;
115
+ error?: string;
116
+ };
117
+
71
118
  type PersistedState = {
72
119
  outbox: OutboxEntry[];
73
120
  deadLetter: OutboxEntry[];
@@ -109,7 +156,11 @@ function asString(v: unknown, fallback = ''): string {
109
156
 
110
157
  function normalizeAccountId(accountId?: string | null): string {
111
158
  const v = asString(accountId || '').trim();
112
- return v || BNCR_DEFAULT_ACCOUNT_ID;
159
+ if (!v) return BNCR_DEFAULT_ACCOUNT_ID;
160
+ const lower = v.toLowerCase();
161
+ // 历史兼容:default/primary 统一折叠到 Primary,避免状态尾巴反复出现。
162
+ if (lower === 'default' || lower === 'primary') return BNCR_DEFAULT_ACCOUNT_ID;
163
+ return v;
113
164
  }
114
165
 
115
166
  function parseRouteFromScope(scope: string): BncrRoute | null {
@@ -124,18 +175,45 @@ function parseRouteFromDisplayScope(scope: string): BncrRoute | null {
124
175
  const raw = asString(scope).trim();
125
176
  if (!raw) return null;
126
177
 
127
- // 支持展示标签:Bncr-platform:group:user
128
- const stripped = raw.replace(/^Bncr-/i, '');
129
- return parseRouteFromScope(stripped);
178
+ // 终版兼容(6 种):
179
+ // 1) bncr:g-<hex(scope)>
180
+ // 2) bncr:<hex(scope)>
181
+ // 3) bncr:<platform>:<groupId>:<userId>
182
+ // 4) bncr:g-<platform>:<groupId>:<userId>
183
+
184
+ // 1) bncr:g-<hex> 或 bncr:g-<scope>
185
+ const gPayload = raw.match(/^bncr:g-(.+)$/i)?.[1];
186
+ if (gPayload) {
187
+ if (isLowerHex(gPayload)) {
188
+ const route = parseRouteFromHexScope(gPayload);
189
+ if (route) return route;
190
+ }
191
+ return parseRouteFromScope(gPayload);
192
+ }
193
+
194
+ // 2) / 3) bncr:<hex> or bncr:<scope>
195
+ const bPayload = raw.match(/^bncr:(.+)$/i)?.[1];
196
+ if (bPayload) {
197
+ if (isLowerHex(bPayload)) {
198
+ const route = parseRouteFromHexScope(bPayload);
199
+ if (route) return route;
200
+ }
201
+ return parseRouteFromScope(bPayload);
202
+ }
203
+
204
+ return null;
130
205
  }
131
206
 
132
207
  function formatDisplayScope(route: BncrRoute): string {
133
- return `Bncr-${route.platform}:${route.groupId}:${route.userId}`;
208
+ // 主推荐标签:bncr:<platform>:<groupId>:<userId>
209
+ // 保持原始大小写,不做平台名降级。
210
+ return `bncr:${route.platform}:${route.groupId}:${route.userId}`;
134
211
  }
135
212
 
136
213
  function isLowerHex(input: string): boolean {
137
214
  const raw = asString(input).trim();
138
- return !!raw && /^[0-9a-f]+$/.test(raw) && raw.length % 2 === 0;
215
+ // 兼容大小写十六进制,不主动降级大小写
216
+ return !!raw && /^[0-9a-fA-F]+$/.test(raw) && raw.length % 2 === 0;
139
217
  }
140
218
 
141
219
  function routeScopeToHex(route: BncrRoute): string {
@@ -144,7 +222,7 @@ function routeScopeToHex(route: BncrRoute): string {
144
222
  }
145
223
 
146
224
  function parseRouteFromHexScope(scopeHex: string): BncrRoute | null {
147
- const rawHex = asString(scopeHex).trim().toLowerCase();
225
+ const rawHex = asString(scopeHex).trim();
148
226
  if (!isLowerHex(rawHex)) return null;
149
227
 
150
228
  try {
@@ -277,24 +355,30 @@ function hex2utf8SessionKey(str: string): { sessionKey: string; scope: string }
277
355
  function parseStrictBncrSessionKey(input: string): { sessionKey: string; scopeHex: string; route: BncrRoute } | null {
278
356
  const raw = asString(input).trim();
279
357
  if (!raw) return null;
280
- if (!raw.startsWith(BNCR_SESSION_KEY_PREFIX)) return null;
281
358
 
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;
359
+ // 终版兼容 sessionKey:
360
+ // 1) agent:main:bncr:direct:<hex(scope)>
361
+ // 2) agent:main:bncr:group:<hex(scope)>
362
+ // (统一归一成 direct:<hex(scope)>)
363
+ const m = raw.match(/^agent:main:bncr:(direct|group):(.+)$/);
364
+ if (!m?.[1] || !m?.[2]) return null;
365
+
366
+ const payload = asString(m[2]).trim();
367
+ let route: BncrRoute | null = null;
368
+ let scopeHex = '';
369
+
370
+ if (isLowerHex(payload)) {
371
+ scopeHex = payload;
372
+ route = parseRouteFromHexScope(payload);
373
+ } else {
374
+ route = parseRouteFromScope(payload);
375
+ if (route) scopeHex = routeScopeToHex(route);
287
376
  }
288
377
 
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;
378
+ if (!route || !scopeHex) return null;
295
379
 
296
380
  return {
297
- sessionKey: raw,
381
+ sessionKey: `${BNCR_SESSION_KEY_PREFIX}${scopeHex}`,
298
382
  scopeHex,
299
383
  route,
300
384
  };
@@ -352,6 +436,7 @@ function withTaskSessionKey(sessionKey: string, taskKey?: string | null): string
352
436
  }
353
437
 
354
438
  function buildFallbackSessionKey(route: BncrRoute): string {
439
+ // 新主口径:sessionKey 使用 agent:main:bncr:direct:<hex(scope)>
355
440
  return `${BNCR_SESSION_KEY_PREFIX}${routeScopeToHex(route)}`;
356
441
  }
357
442
 
@@ -383,14 +468,85 @@ function inboundDedupKey(params: {
383
468
  return `${accountId}|${platform}|${groupId}|${userId}|hash:${digest}`;
384
469
  }
385
470
 
386
- function resolveChatType(route: BncrRoute): 'direct' | 'group' {
387
- return route.groupId === '0' ? 'direct' : 'group';
471
+ function resolveChatType(_route: BncrRoute): 'direct' | 'group' {
472
+ // 业务口径:无论群聊/私聊,统一按 direct 上报,避免会话层落到 group 显示分支(历史 bncr:g-*)。
473
+ return 'direct';
388
474
  }
389
475
 
390
476
  function routeKey(accountId: string, route: BncrRoute): string {
391
477
  return `${accountId}:${route.platform}:${route.groupId}:${route.userId}`.toLowerCase();
392
478
  }
393
479
 
480
+ function fileExtFromMime(mimeType?: string): string {
481
+ const mt = asString(mimeType || '').toLowerCase();
482
+ const map: Record<string, string> = {
483
+ 'image/jpeg': '.jpg',
484
+ 'image/jpg': '.jpg',
485
+ 'image/png': '.png',
486
+ 'image/webp': '.webp',
487
+ 'image/gif': '.gif',
488
+ 'video/mp4': '.mp4',
489
+ 'video/webm': '.webm',
490
+ 'video/quicktime': '.mov',
491
+ 'audio/mpeg': '.mp3',
492
+ 'audio/mp4': '.m4a',
493
+ 'application/pdf': '.pdf',
494
+ 'text/plain': '.txt',
495
+ };
496
+ return map[mt] || '';
497
+ }
498
+
499
+ function sanitizeFileName(rawName?: string, fallback = 'file.bin'): string {
500
+ const name = asString(rawName || '').trim();
501
+ const base = name || fallback;
502
+ const cleaned = base.replace(/[\\/:*?"<>|\x00-\x1F]+/g, '_').replace(/\s+/g, ' ').trim();
503
+ return cleaned || fallback;
504
+ }
505
+
506
+ function buildTimestampFileName(mimeType?: string): string {
507
+ const d = new Date();
508
+ const pad = (n: number) => String(n).padStart(2, '0');
509
+ const ts = `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}_${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
510
+ const ext = fileExtFromMime(mimeType) || '.bin';
511
+ return `bncr_${ts}_${Math.random().toString(16).slice(2, 8)}${ext}`;
512
+ }
513
+
514
+ function resolveOutboundFileName(params: { mediaUrl?: string; fileName?: string; mimeType?: string }): string {
515
+ const mediaUrl = asString(params.mediaUrl || '').trim();
516
+ const mimeType = asString(params.mimeType || '').trim();
517
+
518
+ // 线上下载的文件,统一用时间戳命名(避免超长/无意义文件名)
519
+ if (/^https?:\/\//i.test(mediaUrl)) {
520
+ return buildTimestampFileName(mimeType);
521
+ }
522
+
523
+ const candidate = sanitizeFileName(params.fileName, 'file.bin');
524
+ if (candidate.length <= 80) return candidate;
525
+
526
+ // 超长文件名做裁剪,尽量保留扩展名
527
+ const ext = path.extname(candidate);
528
+ const stem = candidate.slice(0, Math.max(1, 80 - ext.length));
529
+ return `${stem}${ext}`;
530
+ }
531
+
532
+ function resolveBncrOutboundMessageType(params: { mimeType?: string; fileName?: string; hintedType?: string }): 'text' | 'image' | 'video' | 'file' {
533
+ const hinted = asString(params.hintedType || '').toLowerCase();
534
+ // 优先使用可确定类型;“file”属于兜底,不覆盖更明确的 mime/扩展判断
535
+ if (hinted === 'text' || hinted === 'image' || hinted === 'video') return hinted as any;
536
+
537
+ const mt = asString(params.mimeType || '').toLowerCase();
538
+ const major = mt.split('/')[0] || '';
539
+ if (major === 'text') return 'text';
540
+ if (major === 'image') return 'image';
541
+ if (major === 'video') return 'video';
542
+
543
+ const fn = asString(params.fileName || '').toLowerCase();
544
+ if (/\.(png|jpe?g|gif|webp|bmp|svg)$/.test(fn)) return 'image';
545
+ if (/\.(mp4|mov|mkv|avi|webm)$/.test(fn)) return 'video';
546
+
547
+ return 'file';
548
+ }
549
+
394
550
  class BncrBridgeRuntime {
395
551
  private api: OpenClawPluginApi;
396
552
  private statePath: string | null = null;
@@ -409,11 +565,27 @@ class BncrBridgeRuntime {
409
565
  private lastInboundByAccount = new Map<string, number>();
410
566
  private lastOutboundByAccount = new Map<string, number>();
411
567
 
568
+ // 内置健康/回归计数(替代独立脚本)
569
+ private startedAt = now();
570
+ private connectEventsByAccount = new Map<string, number>();
571
+ private inboundEventsByAccount = new Map<string, number>();
572
+ private activityEventsByAccount = new Map<string, number>();
573
+ private ackEventsByAccount = new Map<string, number>();
574
+
412
575
  private saveTimer: NodeJS.Timeout | null = null;
413
576
  private pushTimer: NodeJS.Timeout | null = null;
414
577
  private waiters = new Map<string, Array<() => void>>();
415
578
  private gatewayContext: GatewayRequestHandlerOptions['context'] | null = null;
416
579
 
580
+ // 文件互传状态(V1:尽力而为,重连不续传)
581
+ private fileSendTransfers = new Map<string, FileSendTransferState>(); // OpenClaw -> Bncr(服务端发起)
582
+ private fileRecvTransfers = new Map<string, FileRecvTransferState>(); // Bncr -> OpenClaw(客户端发起)
583
+ private fileAckWaiters = new Map<string, {
584
+ resolve: (payload: Record<string, unknown>) => void;
585
+ reject: (err: Error) => void;
586
+ timer: NodeJS.Timeout;
587
+ }>();
588
+
417
589
  constructor(api: OpenClawPluginApi) {
418
590
  this.api = api;
419
591
  }
@@ -421,7 +593,8 @@ class BncrBridgeRuntime {
421
593
  startService = async (ctx: OpenClawPluginServiceContext) => {
422
594
  this.statePath = path.join(ctx.stateDir, 'bncr-bridge-state.json');
423
595
  await this.loadState();
424
- this.api.logger.info('bncr-channel service started');
596
+ const bootDiag = this.buildIntegratedDiagnostics(BNCR_DEFAULT_ACCOUNT_ID);
597
+ 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
598
  };
426
599
 
427
600
  stopService = async () => {
@@ -441,6 +614,96 @@ class BncrBridgeRuntime {
441
614
  }, 300);
442
615
  }
443
616
 
617
+ private incrementCounter(map: Map<string, number>, accountId: string) {
618
+ const acc = normalizeAccountId(accountId);
619
+ map.set(acc, (map.get(acc) || 0) + 1);
620
+ }
621
+
622
+ private getCounter(map: Map<string, number>, accountId: string): number {
623
+ return map.get(normalizeAccountId(accountId)) || 0;
624
+ }
625
+
626
+ private countInvalidOutboxSessionKeys(accountId: string): number {
627
+ const acc = normalizeAccountId(accountId);
628
+ let count = 0;
629
+ for (const entry of this.outbox.values()) {
630
+ if (entry.accountId !== acc) continue;
631
+ if (!parseStrictBncrSessionKey(entry.sessionKey)) count += 1;
632
+ }
633
+ return count;
634
+ }
635
+
636
+ private countLegacyAccountResidue(accountId: string): number {
637
+ const acc = normalizeAccountId(accountId);
638
+ const mismatched = (raw?: string | null) => asString(raw || '').trim() && normalizeAccountId(raw) !== acc;
639
+
640
+ let count = 0;
641
+
642
+ for (const entry of this.outbox.values()) {
643
+ if (mismatched(entry.accountId)) count += 1;
644
+ }
645
+ for (const entry of this.deadLetter) {
646
+ if (mismatched(entry.accountId)) count += 1;
647
+ }
648
+ for (const info of this.sessionRoutes.values()) {
649
+ if (mismatched(info.accountId)) count += 1;
650
+ }
651
+ for (const key of this.lastSessionByAccount.keys()) {
652
+ if (mismatched(key)) count += 1;
653
+ }
654
+ for (const key of this.lastActivityByAccount.keys()) {
655
+ if (mismatched(key)) count += 1;
656
+ }
657
+ for (const key of this.lastInboundByAccount.keys()) {
658
+ if (mismatched(key)) count += 1;
659
+ }
660
+ for (const key of this.lastOutboundByAccount.keys()) {
661
+ if (mismatched(key)) count += 1;
662
+ }
663
+
664
+ return count;
665
+ }
666
+
667
+ private buildIntegratedDiagnostics(accountId: string) {
668
+ const acc = normalizeAccountId(accountId);
669
+ const t = now();
670
+
671
+ const pending = Array.from(this.outbox.values()).filter((v) => v.accountId === acc).length;
672
+ const dead = this.deadLetter.filter((v) => v.accountId === acc).length;
673
+ const invalidOutboxSessionKeys = this.countInvalidOutboxSessionKeys(acc);
674
+ const legacyAccountResidue = this.countLegacyAccountResidue(acc);
675
+
676
+ const totalKnownRoutes = Array.from(this.sessionRoutes.values()).filter((v) => v.accountId === acc).length;
677
+ const connected = this.isOnline(acc);
678
+
679
+ const pluginIndexExists = fs.existsSync(path.join(process.cwd(), 'plugins', 'bncr', 'index.ts'));
680
+ const pluginChannelExists = fs.existsSync(path.join(process.cwd(), 'plugins', 'bncr', 'src', 'channel.ts'));
681
+
682
+ const health = {
683
+ connected,
684
+ pending,
685
+ deadLetter: dead,
686
+ activeConnections: this.activeConnectionCount(acc),
687
+ connectEvents: this.getCounter(this.connectEventsByAccount, acc),
688
+ inboundEvents: this.getCounter(this.inboundEventsByAccount, acc),
689
+ activityEvents: this.getCounter(this.activityEventsByAccount, acc),
690
+ ackEvents: this.getCounter(this.ackEventsByAccount, acc),
691
+ uptimeSec: Math.floor((t - this.startedAt) / 1000),
692
+ };
693
+
694
+ const regression = {
695
+ pluginFilesPresent: pluginIndexExists && pluginChannelExists,
696
+ pluginIndexExists,
697
+ pluginChannelExists,
698
+ totalKnownRoutes,
699
+ invalidOutboxSessionKeys,
700
+ legacyAccountResidue,
701
+ ok: invalidOutboxSessionKeys === 0 && legacyAccountResidue === 0,
702
+ };
703
+
704
+ return { health, regression };
705
+ }
706
+
444
707
  private async loadState() {
445
708
  if (!this.statePath) return;
446
709
  const loaded = await readJsonFileWithFallback(this.statePath, {
@@ -799,6 +1062,18 @@ class BncrBridgeRuntime {
799
1062
  for (const [key, ts] of this.recentInbound.entries()) {
800
1063
  if (t - ts > dedupWindowMs) this.recentInbound.delete(key);
801
1064
  }
1065
+
1066
+ this.cleanupFileTransfers();
1067
+ }
1068
+
1069
+ private cleanupFileTransfers() {
1070
+ const t = now();
1071
+ for (const [id, st] of this.fileSendTransfers.entries()) {
1072
+ if (t - st.startedAt > FILE_TRANSFER_KEEP_MS) this.fileSendTransfers.delete(id);
1073
+ }
1074
+ for (const [id, st] of this.fileRecvTransfers.entries()) {
1075
+ if (t - st.startedAt > FILE_TRANSFER_KEEP_MS) this.fileRecvTransfers.delete(id);
1076
+ }
802
1077
  }
803
1078
 
804
1079
  private markSeen(accountId: string, connId: string, clientId?: string) {
@@ -906,10 +1181,10 @@ class BncrBridgeRuntime {
906
1181
  return alias?.route || parsed.route;
907
1182
  }
908
1183
 
909
- // 严谨目标解析:
910
- // 1) 先接受任意标签输入(strict / platform:group:user / Bncr-platform:group:user
911
- // 2) 再通过已知会话路由反查“真实 sessionKey
912
- // 3) 若反查不到或不属于 bncr,会直接失败(禁止拼凑 key 发送)
1184
+ // 严谨目标解析(终版兼容模式):
1185
+ // 1) 兼容 6 种输入格式(to/sessionKey
1186
+ // 2) 统一反查并归一为 sessionKey=agent:main:bncr:direct:<hex(scope)>
1187
+ // 3) 非兼容格式直接失败,并输出标准格式提示日志
913
1188
  private resolveVerifiedTarget(rawTarget: string, accountId: string): { sessionKey: string; route: BncrRoute; displayScope: string } {
914
1189
  const acc = normalizeAccountId(accountId);
915
1190
  const raw = asString(rawTarget).trim();
@@ -927,8 +1202,10 @@ class BncrBridgeRuntime {
927
1202
  }
928
1203
 
929
1204
  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}`);
1205
+ this.api.logger.warn?.(
1206
+ `[bncr-target-invalid] raw=${raw} accountId=${acc} reason=unparseable-or-unknown standardTo=bncr:<platform>:<groupId>:<userId> standardSessionKey=agent:main:bncr:direct:<hex(scope)>`,
1207
+ );
1208
+ throw new Error(`bncr invalid target(standard: to=bncr:<platform>:<groupId>:<userId>): ${raw}`);
932
1209
  }
933
1210
 
934
1211
  const wantedRouteKey = routeKey(acc, route);
@@ -955,6 +1232,14 @@ class BncrBridgeRuntime {
955
1232
  throw new Error(`bncr target not found in known sessions: ${raw}`);
956
1233
  }
957
1234
 
1235
+ // 发送链路命中目标时,同步刷新 lastSession,避免状态页显示过期会话。
1236
+ this.lastSessionByAccount.set(acc, {
1237
+ sessionKey: best.sessionKey,
1238
+ scope: formatDisplayScope(best.route),
1239
+ updatedAt: now(),
1240
+ });
1241
+ this.scheduleSave();
1242
+
958
1243
  return {
959
1244
  sessionKey: best.sessionKey,
960
1245
  route: best.route,
@@ -966,6 +1251,87 @@ class BncrBridgeRuntime {
966
1251
  this.lastActivityByAccount.set(normalizeAccountId(accountId), at);
967
1252
  }
968
1253
 
1254
+ private fileAckKey(transferId: string, stage: string, chunkIndex?: number): string {
1255
+ const idx = Number.isFinite(Number(chunkIndex)) ? String(Number(chunkIndex)) : '-';
1256
+ return `${transferId}|${stage}|${idx}`;
1257
+ }
1258
+
1259
+ private waitForFileAck(params: { transferId: string; stage: string; chunkIndex?: number; timeoutMs?: number }) {
1260
+ const transferId = asString(params.transferId).trim();
1261
+ const stage = asString(params.stage).trim();
1262
+ const key = this.fileAckKey(transferId, stage, params.chunkIndex);
1263
+ const timeoutMs = Math.max(1_000, Math.min(Number(params.timeoutMs || FILE_ACK_TIMEOUT_MS), 120_000));
1264
+
1265
+ return new Promise<Record<string, unknown>>((resolve, reject) => {
1266
+ const timer = setTimeout(() => {
1267
+ this.fileAckWaiters.delete(key);
1268
+ reject(new Error(`file ack timeout: ${key}`));
1269
+ }, timeoutMs);
1270
+ this.fileAckWaiters.set(key, { resolve, reject, timer });
1271
+ });
1272
+ }
1273
+
1274
+ private resolveFileAck(params: { transferId: string; stage: string; chunkIndex?: number; payload: Record<string, unknown>; ok: boolean }) {
1275
+ const transferId = asString(params.transferId).trim();
1276
+ const stage = asString(params.stage).trim();
1277
+ const key = this.fileAckKey(transferId, stage, params.chunkIndex);
1278
+ const waiter = this.fileAckWaiters.get(key);
1279
+ if (!waiter) return false;
1280
+ this.fileAckWaiters.delete(key);
1281
+ clearTimeout(waiter.timer);
1282
+ if (params.ok) waiter.resolve(params.payload);
1283
+ else waiter.reject(new Error(asString(params.payload?.errorMessage || params.payload?.error || 'file ack failed')));
1284
+ return true;
1285
+ }
1286
+
1287
+ private pushFileEventToAccount(accountId: string, event: string, payload: Record<string, unknown>) {
1288
+ const connIds = this.resolvePushConnIds(accountId);
1289
+ if (!connIds.size || !this.gatewayContext) {
1290
+ throw new Error(`no active bncr connection for account=${accountId}`);
1291
+ }
1292
+ this.gatewayContext.broadcastToConnIds(event, payload, connIds);
1293
+ }
1294
+
1295
+ private resolveInboundFileType(mimeType: string, fileName: string): string {
1296
+ const mt = asString(mimeType).toLowerCase();
1297
+ const fn = asString(fileName).toLowerCase();
1298
+ if (mt.startsWith('image/') || /\.(png|jpe?g|gif|webp|bmp|svg)$/.test(fn)) return 'image';
1299
+ if (mt.startsWith('video/') || /\.(mp4|mov|mkv|avi|webm)$/.test(fn)) return 'video';
1300
+ if (mt.startsWith('audio/') || /\.(mp3|wav|m4a|aac|ogg|flac)$/.test(fn)) return 'audio';
1301
+ return mt || 'file';
1302
+ }
1303
+
1304
+ private resolveInboundFilesDir(): string {
1305
+ const dir = path.join(process.cwd(), '.openclaw', 'media', 'inbound', 'bncr');
1306
+ fs.mkdirSync(dir, { recursive: true });
1307
+ return dir;
1308
+ }
1309
+
1310
+ private async materializeRecvTransfer(st: FileRecvTransferState): Promise<{ path: string; fileSha256: string }> {
1311
+ const dir = this.resolveInboundFilesDir();
1312
+ const safeName = asString(st.fileName).trim() || `${st.transferId}.bin`;
1313
+ const finalPath = path.join(dir, safeName);
1314
+
1315
+ const ordered: Buffer[] = [];
1316
+ for (let i = 0; i < st.totalChunks; i++) {
1317
+ const chunk = st.bufferByChunk.get(i);
1318
+ if (!chunk) throw new Error(`missing chunk ${i}`);
1319
+ ordered.push(chunk);
1320
+ }
1321
+ const merged = Buffer.concat(ordered);
1322
+ if (Number(st.fileSize || 0) > 0 && merged.length !== Number(st.fileSize || 0)) {
1323
+ throw new Error(`size mismatch expected=${st.fileSize} got=${merged.length}`);
1324
+ }
1325
+
1326
+ const sha = createHash('sha256').update(merged).digest('hex');
1327
+ if (st.fileSha256 && sha !== st.fileSha256) {
1328
+ throw new Error(`sha256 mismatch expected=${st.fileSha256} got=${sha}`);
1329
+ }
1330
+
1331
+ fs.writeFileSync(finalPath, merged);
1332
+ return { path: finalPath, fileSha256: sha };
1333
+ }
1334
+
969
1335
  private fmtAgo(ts?: number | null): string {
970
1336
  if (!ts || !Number.isFinite(ts) || ts <= 0) return '-';
971
1337
  const diff = Math.max(0, now() - ts);
@@ -989,6 +1355,7 @@ class BncrBridgeRuntime {
989
1355
  const lastActivityAgo = this.fmtAgo(lastActAt);
990
1356
  const lastInboundAgo = this.fmtAgo(lastInboundAt);
991
1357
  const lastOutboundAgo = this.fmtAgo(lastOutboundAt);
1358
+ const diagnostics = this.buildIntegratedDiagnostics(acc);
992
1359
 
993
1360
  return {
994
1361
  pending,
@@ -1003,6 +1370,7 @@ class BncrBridgeRuntime {
1003
1370
  lastInboundAgo,
1004
1371
  lastOutboundAt,
1005
1372
  lastOutboundAgo,
1373
+ diagnostics,
1006
1374
  };
1007
1375
  }
1008
1376
 
@@ -1026,21 +1394,49 @@ class BncrBridgeRuntime {
1026
1394
  };
1027
1395
  }
1028
1396
 
1397
+ private buildStatusHeadline(accountId: string): string {
1398
+ const acc = normalizeAccountId(accountId);
1399
+ const diag = this.buildIntegratedDiagnostics(acc);
1400
+ const h = diag.health;
1401
+ const r = diag.regression;
1402
+
1403
+ const parts = [
1404
+ r.ok ? 'diag:ok' : 'diag:warn',
1405
+ `p:${h.pending}`,
1406
+ `d:${h.deadLetter}`,
1407
+ `c:${h.activeConnections}`,
1408
+ ];
1409
+
1410
+ if (!r.ok) {
1411
+ if (r.invalidOutboxSessionKeys > 0) parts.push(`invalid:${r.invalidOutboxSessionKeys}`);
1412
+ if (r.legacyAccountResidue > 0) parts.push(`legacy:${r.legacyAccountResidue}`);
1413
+ }
1414
+
1415
+ return parts.join(' ');
1416
+ }
1417
+
1418
+ getStatusHeadline(accountId: string): string {
1419
+ return this.buildStatusHeadline(accountId);
1420
+ }
1421
+
1029
1422
  getChannelSummary(defaultAccountId: string) {
1030
- const runtime = this.getAccountRuntimeSnapshot(defaultAccountId);
1423
+ const accountId = normalizeAccountId(defaultAccountId);
1424
+ const runtime = this.getAccountRuntimeSnapshot(accountId);
1425
+ const headline = this.buildStatusHeadline(accountId);
1426
+
1031
1427
  if (runtime.connected) {
1032
- return { linked: true };
1428
+ return { linked: true, self: { e164: headline } };
1033
1429
  }
1034
1430
 
1035
1431
  // 顶层汇总不绑定某个 accountId:任一账号在线都应显示 linked
1036
1432
  const t = now();
1037
1433
  for (const c of this.connections.values()) {
1038
1434
  if (t - c.lastSeenAt <= CONNECT_TTL_MS) {
1039
- return { linked: true };
1435
+ return { linked: true, self: { e164: headline } };
1040
1436
  }
1041
1437
  }
1042
1438
 
1043
- return { linked: false };
1439
+ return { linked: false, self: { e164: headline } };
1044
1440
  }
1045
1441
 
1046
1442
  private enqueueOutbound(entry: OutboxEntry) {
@@ -1111,6 +1507,211 @@ class BncrBridgeRuntime {
1111
1507
  };
1112
1508
  }
1113
1509
 
1510
+ private async sleepMs(ms: number): Promise<void> {
1511
+ await new Promise<void>((resolve) => setTimeout(resolve, Math.max(0, Number(ms || 0))));
1512
+ }
1513
+
1514
+ private waitChunkAck(params: {
1515
+ transferId: string;
1516
+ chunkIndex: number;
1517
+ timeoutMs?: number;
1518
+ }): Promise<void> {
1519
+ const { transferId, chunkIndex } = params;
1520
+ const timeoutMs = Math.max(1_000, Math.min(Number(params.timeoutMs || FILE_TRANSFER_ACK_TTL_MS), 60_000));
1521
+ const started = now();
1522
+
1523
+ return new Promise<void>((resolve, reject) => {
1524
+ const tick = async () => {
1525
+ const st = this.fileSendTransfers.get(transferId);
1526
+ if (!st) {
1527
+ reject(new Error('transfer state missing'));
1528
+ return;
1529
+ }
1530
+ if (st.failedChunks.has(chunkIndex)) {
1531
+ reject(new Error(st.failedChunks.get(chunkIndex) || `chunk ${chunkIndex} failed`));
1532
+ return;
1533
+ }
1534
+ if (st.ackedChunks.has(chunkIndex)) {
1535
+ resolve();
1536
+ return;
1537
+ }
1538
+ if (now() - started >= timeoutMs) {
1539
+ reject(new Error(`chunk ack timeout index=${chunkIndex}`));
1540
+ return;
1541
+ }
1542
+ await this.sleepMs(120);
1543
+ void tick();
1544
+ };
1545
+ void tick();
1546
+ });
1547
+ }
1548
+
1549
+ private waitCompleteAck(params: {
1550
+ transferId: string;
1551
+ timeoutMs?: number;
1552
+ }): Promise<{ path: string }> {
1553
+ const { transferId } = params;
1554
+ const timeoutMs = Math.max(2_000, Math.min(Number(params.timeoutMs || 60_000), 120_000));
1555
+ const started = now();
1556
+
1557
+ return new Promise<{ path: string }>((resolve, reject) => {
1558
+ const tick = async () => {
1559
+ const st = this.fileSendTransfers.get(transferId);
1560
+ if (!st) {
1561
+ reject(new Error('transfer state missing'));
1562
+ return;
1563
+ }
1564
+ if (st.status === 'aborted') {
1565
+ reject(new Error(st.error || 'transfer aborted'));
1566
+ return;
1567
+ }
1568
+ if (st.status === 'completed' && st.completedPath) {
1569
+ resolve({ path: st.completedPath });
1570
+ return;
1571
+ }
1572
+ if (now() - started >= timeoutMs) {
1573
+ reject(new Error('complete ack timeout'));
1574
+ return;
1575
+ }
1576
+ await this.sleepMs(150);
1577
+ void tick();
1578
+ };
1579
+ void tick();
1580
+ });
1581
+ }
1582
+
1583
+ private async transferMediaToBncrClient(params: {
1584
+ accountId: string;
1585
+ sessionKey: string;
1586
+ route: BncrRoute;
1587
+ mediaUrl: string;
1588
+ mediaLocalRoots?: readonly string[];
1589
+ }): Promise<{ mode: 'base64' | 'chunk'; mimeType?: string; fileName?: string; mediaBase64?: string; path?: string }> {
1590
+ const loaded = await this.api.runtime.media.loadWebMedia(params.mediaUrl, {
1591
+ localRoots: params.mediaLocalRoots,
1592
+ maxBytes: 50 * 1024 * 1024,
1593
+ });
1594
+
1595
+ const size = loaded.buffer.byteLength;
1596
+ const mimeType = loaded.contentType;
1597
+ const fileName = resolveOutboundFileName({
1598
+ mediaUrl: params.mediaUrl,
1599
+ fileName: loaded.fileName,
1600
+ mimeType,
1601
+ });
1602
+
1603
+ if (!FILE_FORCE_CHUNK && size <= FILE_INLINE_THRESHOLD) {
1604
+ return {
1605
+ mode: 'base64',
1606
+ mimeType,
1607
+ fileName,
1608
+ mediaBase64: loaded.buffer.toString('base64'),
1609
+ };
1610
+ }
1611
+
1612
+ const ctx = this.gatewayContext;
1613
+ if (!ctx) throw new Error('gateway context unavailable');
1614
+
1615
+ const connIds = this.resolvePushConnIds(params.accountId);
1616
+ if (!connIds.size) throw new Error('no active bncr client for file chunk transfer');
1617
+
1618
+ const transferId = randomUUID();
1619
+ const chunkSize = 256 * 1024;
1620
+ const totalChunks = Math.ceil(size / chunkSize);
1621
+ const fileSha256 = createHash('sha256').update(loaded.buffer).digest('hex');
1622
+
1623
+ const st: FileSendTransferState = {
1624
+ transferId,
1625
+ accountId: normalizeAccountId(params.accountId),
1626
+ sessionKey: params.sessionKey,
1627
+ route: params.route,
1628
+ fileName,
1629
+ mimeType: mimeType || 'application/octet-stream',
1630
+ fileSize: size,
1631
+ chunkSize,
1632
+ totalChunks,
1633
+ fileSha256,
1634
+ startedAt: now(),
1635
+ status: 'init',
1636
+ ackedChunks: new Set(),
1637
+ failedChunks: new Map(),
1638
+ };
1639
+ this.fileSendTransfers.set(transferId, st);
1640
+
1641
+ ctx.broadcastToConnIds('bncr.file.init', {
1642
+ transferId,
1643
+ direction: 'oc2bncr',
1644
+ sessionKey: params.sessionKey,
1645
+ platform: params.route.platform,
1646
+ groupId: params.route.groupId,
1647
+ userId: params.route.userId,
1648
+ fileName,
1649
+ mimeType,
1650
+ fileSize: size,
1651
+ chunkSize,
1652
+ totalChunks,
1653
+ fileSha256,
1654
+ ts: now(),
1655
+ }, connIds);
1656
+
1657
+ // 逐块发送并等待 ACK
1658
+ for (let idx = 0; idx < totalChunks; idx++) {
1659
+ const start = idx * chunkSize;
1660
+ const end = Math.min(start + chunkSize, size);
1661
+ const slice = loaded.buffer.subarray(start, end);
1662
+ const chunkSha256 = createHash('sha256').update(slice).digest('hex');
1663
+
1664
+ let ok = false;
1665
+ let lastErr: unknown = null;
1666
+ for (let attempt = 1; attempt <= 3; attempt++) {
1667
+ ctx.broadcastToConnIds('bncr.file.chunk', {
1668
+ transferId,
1669
+ chunkIndex: idx,
1670
+ offset: start,
1671
+ size: slice.byteLength,
1672
+ chunkSha256,
1673
+ base64: slice.toString('base64'),
1674
+ ts: now(),
1675
+ }, connIds);
1676
+
1677
+ try {
1678
+ await this.waitChunkAck({ transferId, chunkIndex: idx, timeoutMs: FILE_TRANSFER_ACK_TTL_MS });
1679
+ ok = true;
1680
+ break;
1681
+ } catch (err) {
1682
+ lastErr = err;
1683
+ await this.sleepMs(150 * attempt);
1684
+ }
1685
+ }
1686
+
1687
+ if (!ok) {
1688
+ st.status = 'aborted';
1689
+ st.error = String((lastErr as any)?.message || lastErr || `chunk-${idx}-failed`);
1690
+ this.fileSendTransfers.set(transferId, st);
1691
+ ctx.broadcastToConnIds('bncr.file.abort', {
1692
+ transferId,
1693
+ reason: st.error,
1694
+ ts: now(),
1695
+ }, connIds);
1696
+ throw new Error(st.error);
1697
+ }
1698
+ }
1699
+
1700
+ ctx.broadcastToConnIds('bncr.file.complete', {
1701
+ transferId,
1702
+ ts: now(),
1703
+ }, connIds);
1704
+
1705
+ const done = await this.waitCompleteAck({ transferId, timeoutMs: 60_000 });
1706
+
1707
+ return {
1708
+ mode: 'chunk',
1709
+ mimeType,
1710
+ fileName,
1711
+ path: done.path,
1712
+ };
1713
+ }
1714
+
1114
1715
  private async enqueueFromReply(params: {
1115
1716
  accountId: string;
1116
1717
  sessionKey: string;
@@ -1129,7 +1730,13 @@ class BncrBridgeRuntime {
1129
1730
  if (mediaList.length > 0) {
1130
1731
  let first = true;
1131
1732
  for (const mediaUrl of mediaList) {
1132
- const media = await this.payloadMediaToBase64(mediaUrl, mediaLocalRoots);
1733
+ const media = await this.transferMediaToBncrClient({
1734
+ accountId,
1735
+ sessionKey,
1736
+ route,
1737
+ mediaUrl,
1738
+ mediaLocalRoots,
1739
+ });
1133
1740
  const messageId = randomUUID();
1134
1741
  const mediaMsg = first ? asString(payload.text || '') : '';
1135
1742
  const frame = {
@@ -1141,11 +1748,21 @@ class BncrBridgeRuntime {
1141
1748
  platform: route.platform,
1142
1749
  groupId: route.groupId,
1143
1750
  userId: route.userId,
1144
- type: media.mimeType,
1751
+ type: resolveBncrOutboundMessageType({
1752
+ mimeType: media.mimeType,
1753
+ fileName: media.fileName,
1754
+ hintedType: 'file',
1755
+ }),
1756
+ mimeType: media.mimeType || '',
1145
1757
  msg: mediaMsg,
1146
- path: mediaUrl,
1147
- base64: media.mediaBase64,
1148
- fileName: media.fileName,
1758
+ path: media.path || mediaUrl,
1759
+ base64: media.mediaBase64 || '',
1760
+ fileName: resolveOutboundFileName({
1761
+ mediaUrl,
1762
+ fileName: media.fileName,
1763
+ mimeType: media.mimeType,
1764
+ }),
1765
+ transferMode: media.mode,
1149
1766
  },
1150
1767
  ts: now(),
1151
1768
  };
@@ -1207,6 +1824,7 @@ class BncrBridgeRuntime {
1207
1824
  this.rememberGatewayContext(context);
1208
1825
  this.markSeen(accountId, connId, clientId);
1209
1826
  this.markActivity(accountId);
1827
+ this.incrementCounter(this.connectEventsByAccount, accountId);
1210
1828
 
1211
1829
  respond(true, {
1212
1830
  channel: CHANNEL_ID,
@@ -1218,6 +1836,7 @@ class BncrBridgeRuntime {
1218
1836
  activeConnections: this.activeConnectionCount(accountId),
1219
1837
  pending: Array.from(this.outbox.values()).filter((v) => v.accountId === accountId).length,
1220
1838
  deadLetter: this.deadLetter.filter((v) => v.accountId === accountId).length,
1839
+ diagnostics: this.buildIntegratedDiagnostics(accountId),
1221
1840
  now: now(),
1222
1841
  });
1223
1842
 
@@ -1231,6 +1850,7 @@ class BncrBridgeRuntime {
1231
1850
  const clientId = asString((params as any)?.clientId || '').trim() || undefined;
1232
1851
  this.rememberGatewayContext(context);
1233
1852
  this.markSeen(accountId, connId, clientId);
1853
+ this.incrementCounter(this.ackEventsByAccount, accountId);
1234
1854
 
1235
1855
  const messageId = asString(params?.messageId || '').trim();
1236
1856
  if (!messageId) {
@@ -1280,6 +1900,7 @@ class BncrBridgeRuntime {
1280
1900
  this.rememberGatewayContext(context);
1281
1901
  this.markSeen(accountId, connId, clientId);
1282
1902
  this.markActivity(accountId);
1903
+ this.incrementCounter(this.activityEventsByAccount, accountId);
1283
1904
 
1284
1905
  // 轻量活动心跳:仅刷新在线活跃状态,不承担拉取职责。
1285
1906
  respond(true, {
@@ -1293,6 +1914,299 @@ class BncrBridgeRuntime {
1293
1914
  });
1294
1915
  };
1295
1916
 
1917
+ handleDiagnostics = async ({ params, respond }: GatewayRequestHandlerOptions) => {
1918
+ const accountId = normalizeAccountId(asString(params?.accountId || ''));
1919
+ const runtime = this.getAccountRuntimeSnapshot(accountId);
1920
+ const diagnostics = this.buildIntegratedDiagnostics(accountId);
1921
+
1922
+ respond(true, {
1923
+ channel: CHANNEL_ID,
1924
+ accountId,
1925
+ runtime,
1926
+ diagnostics,
1927
+ now: now(),
1928
+ });
1929
+ };
1930
+
1931
+ handleFileInit = async ({ params, respond, client, context }: GatewayRequestHandlerOptions) => {
1932
+ const accountId = normalizeAccountId(asString(params?.accountId || ''));
1933
+ const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
1934
+ const clientId = asString((params as any)?.clientId || '').trim() || undefined;
1935
+ this.rememberGatewayContext(context);
1936
+ this.markSeen(accountId, connId, clientId);
1937
+ this.markActivity(accountId);
1938
+
1939
+ const transferId = asString(params?.transferId || '').trim();
1940
+ const sessionKey = asString(params?.sessionKey || '').trim();
1941
+ const fileName = asString(params?.fileName || '').trim() || 'file.bin';
1942
+ const mimeType = asString(params?.mimeType || '').trim() || 'application/octet-stream';
1943
+ const fileSize = Number(params?.fileSize || 0);
1944
+ const chunkSize = Number(params?.chunkSize || 256 * 1024);
1945
+ const totalChunks = Number(params?.totalChunks || 0);
1946
+ const fileSha256 = asString(params?.fileSha256 || '').trim();
1947
+
1948
+ if (!transferId || !sessionKey || !fileSize || !chunkSize || !totalChunks) {
1949
+ respond(false, { error: 'transferId/sessionKey/fileSize/chunkSize/totalChunks required' });
1950
+ return;
1951
+ }
1952
+
1953
+ const normalized = normalizeStoredSessionKey(sessionKey);
1954
+ if (!normalized) {
1955
+ respond(false, { error: 'invalid sessionKey' });
1956
+ return;
1957
+ }
1958
+
1959
+ const existing = this.fileRecvTransfers.get(transferId);
1960
+ if (existing) {
1961
+ respond(true, {
1962
+ ok: true,
1963
+ transferId,
1964
+ status: existing.status,
1965
+ duplicated: true,
1966
+ });
1967
+ return;
1968
+ }
1969
+
1970
+ const route = parseRouteLike({
1971
+ platform: asString(params?.platform || normalized.route.platform),
1972
+ groupId: asString(params?.groupId || normalized.route.groupId),
1973
+ userId: asString(params?.userId || normalized.route.userId),
1974
+ }) || normalized.route;
1975
+
1976
+ this.fileRecvTransfers.set(transferId, {
1977
+ transferId,
1978
+ accountId,
1979
+ sessionKey: normalized.sessionKey,
1980
+ route,
1981
+ fileName,
1982
+ mimeType,
1983
+ fileSize,
1984
+ chunkSize,
1985
+ totalChunks,
1986
+ fileSha256,
1987
+ startedAt: now(),
1988
+ status: 'init',
1989
+ bufferByChunk: new Map(),
1990
+ receivedChunks: new Set(),
1991
+ });
1992
+
1993
+ respond(true, {
1994
+ ok: true,
1995
+ transferId,
1996
+ status: 'init',
1997
+ });
1998
+ };
1999
+
2000
+ handleFileChunk = async ({ params, respond, client, context }: GatewayRequestHandlerOptions) => {
2001
+ const accountId = normalizeAccountId(asString(params?.accountId || ''));
2002
+ const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
2003
+ const clientId = asString((params as any)?.clientId || '').trim() || undefined;
2004
+ this.rememberGatewayContext(context);
2005
+ this.markSeen(accountId, connId, clientId);
2006
+ this.markActivity(accountId);
2007
+
2008
+ const transferId = asString(params?.transferId || '').trim();
2009
+ const chunkIndex = Number(params?.chunkIndex ?? -1);
2010
+ const offset = Number(params?.offset ?? 0);
2011
+ const size = Number(params?.size ?? 0);
2012
+ const chunkSha256 = asString(params?.chunkSha256 || '').trim();
2013
+ const base64 = asString(params?.base64 || '');
2014
+
2015
+ if (!transferId || chunkIndex < 0 || !base64) {
2016
+ respond(false, { error: 'transferId/chunkIndex/base64 required' });
2017
+ return;
2018
+ }
2019
+
2020
+ const st = this.fileRecvTransfers.get(transferId);
2021
+ if (!st) {
2022
+ respond(false, { error: 'transfer not found' });
2023
+ return;
2024
+ }
2025
+
2026
+ try {
2027
+ const buf = Buffer.from(base64, 'base64');
2028
+ if (size > 0 && buf.length !== size) {
2029
+ throw new Error(`chunk size mismatch expected=${size} got=${buf.length}`);
2030
+ }
2031
+ if (chunkSha256) {
2032
+ const digest = createHash('sha256').update(buf).digest('hex');
2033
+ if (digest !== chunkSha256) throw new Error('chunk sha256 mismatch');
2034
+ }
2035
+ st.bufferByChunk.set(chunkIndex, buf);
2036
+ st.receivedChunks.add(chunkIndex);
2037
+ st.status = 'transferring';
2038
+ this.fileRecvTransfers.set(transferId, st);
2039
+
2040
+ respond(true, {
2041
+ ok: true,
2042
+ transferId,
2043
+ chunkIndex,
2044
+ offset,
2045
+ received: st.receivedChunks.size,
2046
+ totalChunks: st.totalChunks,
2047
+ });
2048
+ } catch (error) {
2049
+ respond(false, { error: String((error as any)?.message || error || 'chunk invalid') });
2050
+ }
2051
+ };
2052
+
2053
+ handleFileComplete = async ({ params, respond, client, context }: GatewayRequestHandlerOptions) => {
2054
+ const accountId = normalizeAccountId(asString(params?.accountId || ''));
2055
+ const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
2056
+ const clientId = asString((params as any)?.clientId || '').trim() || undefined;
2057
+ this.rememberGatewayContext(context);
2058
+ this.markSeen(accountId, connId, clientId);
2059
+ this.markActivity(accountId);
2060
+
2061
+ const transferId = asString(params?.transferId || '').trim();
2062
+ if (!transferId) {
2063
+ respond(false, { error: 'transferId required' });
2064
+ return;
2065
+ }
2066
+
2067
+ const st = this.fileRecvTransfers.get(transferId);
2068
+ if (!st) {
2069
+ respond(false, { error: 'transfer not found' });
2070
+ return;
2071
+ }
2072
+
2073
+ try {
2074
+ if (st.receivedChunks.size < st.totalChunks) {
2075
+ throw new Error(`chunk not complete received=${st.receivedChunks.size} total=${st.totalChunks}`);
2076
+ }
2077
+
2078
+ const ordered = Array.from(st.bufferByChunk.entries()).sort((a, b) => a[0] - b[0]).map((x) => x[1]);
2079
+ const merged = Buffer.concat(ordered);
2080
+ if (st.fileSize > 0 && merged.length !== st.fileSize) {
2081
+ throw new Error(`file size mismatch expected=${st.fileSize} got=${merged.length}`);
2082
+ }
2083
+ const digest = createHash('sha256').update(merged).digest('hex');
2084
+ if (st.fileSha256 && digest !== st.fileSha256) {
2085
+ throw new Error('file sha256 mismatch');
2086
+ }
2087
+
2088
+ const saved = await this.api.runtime.channel.media.saveMediaBuffer(
2089
+ merged,
2090
+ st.mimeType,
2091
+ 'inbound',
2092
+ 50 * 1024 * 1024,
2093
+ st.fileName,
2094
+ );
2095
+ st.completedPath = saved.path;
2096
+ st.status = 'completed';
2097
+ this.fileRecvTransfers.set(transferId, st);
2098
+
2099
+ respond(true, {
2100
+ ok: true,
2101
+ transferId,
2102
+ path: saved.path,
2103
+ size: merged.length,
2104
+ fileName: st.fileName,
2105
+ mimeType: st.mimeType,
2106
+ fileSha256: digest,
2107
+ });
2108
+ } catch (error) {
2109
+ st.status = 'aborted';
2110
+ st.error = String((error as any)?.message || error || 'complete failed');
2111
+ this.fileRecvTransfers.set(transferId, st);
2112
+ respond(false, { error: st.error });
2113
+ }
2114
+ };
2115
+
2116
+ handleFileAbort = async ({ params, respond, client, context }: GatewayRequestHandlerOptions) => {
2117
+ const accountId = normalizeAccountId(asString(params?.accountId || ''));
2118
+ const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
2119
+ const clientId = asString((params as any)?.clientId || '').trim() || undefined;
2120
+ this.rememberGatewayContext(context);
2121
+ this.markSeen(accountId, connId, clientId);
2122
+ this.markActivity(accountId);
2123
+
2124
+ const transferId = asString(params?.transferId || '').trim();
2125
+ if (!transferId) {
2126
+ respond(false, { error: 'transferId required' });
2127
+ return;
2128
+ }
2129
+
2130
+ const st = this.fileRecvTransfers.get(transferId);
2131
+ if (!st) {
2132
+ respond(true, { ok: true, transferId, message: 'not-found' });
2133
+ return;
2134
+ }
2135
+
2136
+ st.status = 'aborted';
2137
+ st.error = asString(params?.reason || 'aborted');
2138
+ this.fileRecvTransfers.set(transferId, st);
2139
+
2140
+ respond(true, {
2141
+ ok: true,
2142
+ transferId,
2143
+ status: 'aborted',
2144
+ });
2145
+ };
2146
+
2147
+ handleFileAck = async ({ params, respond, client, context }: GatewayRequestHandlerOptions) => {
2148
+ const accountId = normalizeAccountId(asString(params?.accountId || ''));
2149
+ const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
2150
+ const clientId = asString((params as any)?.clientId || '').trim() || undefined;
2151
+ this.rememberGatewayContext(context);
2152
+ this.markSeen(accountId, connId, clientId);
2153
+ this.markActivity(accountId);
2154
+
2155
+ const transferId = asString(params?.transferId || '').trim();
2156
+ const stage = asString(params?.stage || '').trim();
2157
+ const ok = params?.ok !== false;
2158
+ const chunkIndex = Number(params?.chunkIndex ?? -1);
2159
+
2160
+ if (!transferId || !stage) {
2161
+ respond(false, { error: 'transferId/stage required' });
2162
+ return;
2163
+ }
2164
+
2165
+ const st = this.fileSendTransfers.get(transferId);
2166
+ if (st) {
2167
+ if (!ok) {
2168
+ const code = asString(params?.errorCode || 'ACK_FAILED');
2169
+ const msg = asString(params?.errorMessage || 'ack failed');
2170
+ st.error = `${code}:${msg}`;
2171
+ if (stage === 'chunk' && chunkIndex >= 0) st.failedChunks.set(chunkIndex, st.error);
2172
+ if (stage === 'complete') st.status = 'aborted';
2173
+ } else {
2174
+ if (stage === 'chunk' && chunkIndex >= 0) {
2175
+ st.ackedChunks.add(chunkIndex);
2176
+ st.status = 'transferring';
2177
+ }
2178
+ if (stage === 'complete') {
2179
+ st.status = 'completed';
2180
+ st.completedPath = asString(params?.path || '').trim() || st.completedPath;
2181
+ }
2182
+ }
2183
+ this.fileSendTransfers.set(transferId, st);
2184
+ }
2185
+
2186
+ // 唤醒等待中的 chunk/complete ACK
2187
+ this.resolveFileAck({
2188
+ transferId,
2189
+ stage,
2190
+ chunkIndex: chunkIndex >= 0 ? chunkIndex : undefined,
2191
+ payload: {
2192
+ ok,
2193
+ transferId,
2194
+ stage,
2195
+ path: asString(params?.path || '').trim(),
2196
+ errorCode: asString(params?.errorCode || ''),
2197
+ errorMessage: asString(params?.errorMessage || ''),
2198
+ },
2199
+ ok,
2200
+ });
2201
+
2202
+ respond(true, {
2203
+ ok: true,
2204
+ transferId,
2205
+ stage,
2206
+ state: st?.status || 'late',
2207
+ });
2208
+ };
2209
+
1296
2210
  handleInbound = async ({ params, respond, client, context }: GatewayRequestHandlerOptions) => {
1297
2211
  const accountId = normalizeAccountId(asString(params?.accountId || ''));
1298
2212
  const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
@@ -1300,6 +2214,7 @@ class BncrBridgeRuntime {
1300
2214
  this.rememberGatewayContext(context);
1301
2215
  this.markSeen(accountId, connId, clientId);
1302
2216
  this.markActivity(accountId);
2217
+ this.incrementCounter(this.inboundEventsByAccount, accountId);
1303
2218
 
1304
2219
  const platform = asString(params?.platform || '').trim();
1305
2220
  const groupId = asString(params?.groupId || '0').trim() || '0';
@@ -1320,6 +2235,7 @@ class BncrBridgeRuntime {
1320
2235
  const text = asString(params?.msg || '');
1321
2236
  const msgType = asString(params?.type || 'text') || 'text';
1322
2237
  const mediaBase64 = asString(params?.base64 || '');
2238
+ const mediaPathFromTransfer = asString(params?.path || '').trim();
1323
2239
  const mimeType = asString(params?.mimeType || '').trim() || undefined;
1324
2240
  const fileName = asString(params?.fileName || '').trim() || undefined;
1325
2241
  const msgId = asString(params?.msgId || '').trim() || undefined;
@@ -1396,6 +2312,8 @@ class BncrBridgeRuntime {
1396
2312
  fileName,
1397
2313
  );
1398
2314
  mediaPath = saved.path;
2315
+ } else if (mediaPathFromTransfer && fs.existsSync(mediaPathFromTransfer)) {
2316
+ mediaPath = mediaPathFromTransfer;
1399
2317
  }
1400
2318
 
1401
2319
  const rawBody = agentText || (msgType === 'text' ? '' : `[${msgType}]`);
@@ -1412,6 +2330,11 @@ class BncrBridgeRuntime {
1412
2330
  });
1413
2331
 
1414
2332
  const displayTo = formatDisplayScope(route);
2333
+ // 全场景口径:SenderId 统一上报为 hex(scope)
2334
+ // 作为平台侧稳定身份键,避免退化为裸 userId。
2335
+ const senderIdForContext =
2336
+ parseStrictBncrSessionKey(baseSessionKey)?.scopeHex
2337
+ || routeScopeToHex(route);
1415
2338
  const ctxPayload = this.api.runtime.channel.reply.finalizeInboundContext({
1416
2339
  Body: body,
1417
2340
  BodyForAgent: rawBody,
@@ -1425,7 +2348,7 @@ class BncrBridgeRuntime {
1425
2348
  AccountId: accountId,
1426
2349
  ChatType: peer.kind,
1427
2350
  ConversationLabel: displayTo,
1428
- SenderId: userId,
2351
+ SenderId: senderIdForContext,
1429
2352
  Provider: CHANNEL_ID,
1430
2353
  Surface: CHANNEL_ID,
1431
2354
  MessageSid: msgId,
@@ -1529,6 +2452,25 @@ class BncrBridgeRuntime {
1529
2452
  const accountId = normalizeAccountId(ctx.accountId);
1530
2453
  const to = asString(ctx.to || '').trim();
1531
2454
 
2455
+ if (BNCR_DEBUG_VERBOSE) {
2456
+ this.api.logger.info?.(
2457
+ `[bncr-send-entry:text] ${JSON.stringify({
2458
+ accountId,
2459
+ to,
2460
+ text: asString(ctx?.text || ''),
2461
+ mediaUrl: asString(ctx?.mediaUrl || ''),
2462
+ sessionKey: asString(ctx?.sessionKey || ''),
2463
+ mirrorSessionKey: asString(ctx?.mirror?.sessionKey || ''),
2464
+ rawCtx: {
2465
+ to: ctx?.to,
2466
+ accountId: ctx?.accountId,
2467
+ threadId: ctx?.threadId,
2468
+ replyToId: ctx?.replyToId,
2469
+ },
2470
+ })}`,
2471
+ );
2472
+ }
2473
+
1532
2474
  const verified = this.resolveVerifiedTarget(to, accountId);
1533
2475
 
1534
2476
  this.rememberSessionRoute(verified.sessionKey, accountId, verified.route);
@@ -1550,6 +2492,26 @@ class BncrBridgeRuntime {
1550
2492
  const accountId = normalizeAccountId(ctx.accountId);
1551
2493
  const to = asString(ctx.to || '').trim();
1552
2494
 
2495
+ if (BNCR_DEBUG_VERBOSE) {
2496
+ this.api.logger.info?.(
2497
+ `[bncr-send-entry:media] ${JSON.stringify({
2498
+ accountId,
2499
+ to,
2500
+ text: asString(ctx?.text || ''),
2501
+ mediaUrl: asString(ctx?.mediaUrl || ''),
2502
+ mediaUrls: Array.isArray(ctx?.mediaUrls) ? ctx.mediaUrls : undefined,
2503
+ sessionKey: asString(ctx?.sessionKey || ''),
2504
+ mirrorSessionKey: asString(ctx?.mirror?.sessionKey || ''),
2505
+ rawCtx: {
2506
+ to: ctx?.to,
2507
+ accountId: ctx?.accountId,
2508
+ threadId: ctx?.threadId,
2509
+ replyToId: ctx?.replyToId,
2510
+ },
2511
+ })}`,
2512
+ );
2513
+ }
2514
+
1553
2515
  const verified = this.resolveVerifiedTarget(to, accountId);
1554
2516
 
1555
2517
  this.rememberSessionRoute(verified.sessionKey, accountId, verified.route);
@@ -1632,7 +2594,7 @@ export function createBncrChannelPlugin(bridge: BncrBridgeRuntime) {
1632
2594
  looksLikeId: (raw: string, normalized?: string) => {
1633
2595
  return Boolean(asString(normalized || raw).trim());
1634
2596
  },
1635
- hint: 'Any label accepted; will be validated against known bncr sessions before send',
2597
+ 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
2598
  },
1637
2599
  },
1638
2600
  configSchema: BncrConfigSchema,
@@ -1709,6 +2671,7 @@ export function createBncrChannelPlugin(bridge: BncrBridgeRuntime) {
1709
2671
  const lastInboundAgo = rt?.lastInboundAgo ?? meta.lastInboundAgo ?? '-';
1710
2672
  const lastOutboundAt = rt?.lastOutboundAt ?? meta.lastOutboundAt ?? null;
1711
2673
  const lastOutboundAgo = rt?.lastOutboundAgo ?? meta.lastOutboundAgo ?? '-';
2674
+ const diagnostics = rt?.diagnostics ?? meta.diagnostics ?? null;
1712
2675
  // 右侧状态字段统一:离线时也显示 Status(避免出现 configured 文案)
1713
2676
  const normalizedMode = rt?.mode === 'linked'
1714
2677
  ? 'linked'
@@ -1730,6 +2693,7 @@ export function createBncrChannelPlugin(bridge: BncrBridgeRuntime) {
1730
2693
  mode: normalizedMode,
1731
2694
  pending,
1732
2695
  deadLetter,
2696
+ healthSummary: bridge.getStatusHeadline(account?.accountId),
1733
2697
  lastSessionKey,
1734
2698
  lastSessionScope,
1735
2699
  lastSessionAt,
@@ -1740,6 +2704,7 @@ export function createBncrChannelPlugin(bridge: BncrBridgeRuntime) {
1740
2704
  lastInboundAgo,
1741
2705
  lastOutboundAt,
1742
2706
  lastOutboundAgo,
2707
+ diagnostics,
1743
2708
  };
1744
2709
  },
1745
2710
  resolveAccountState: ({ enabled, configured, account, cfg, runtime }: any) => {
@@ -1750,7 +2715,18 @@ export function createBncrChannelPlugin(bridge: BncrBridgeRuntime) {
1750
2715
  return rt?.connected ? 'linked' : 'configured';
1751
2716
  },
1752
2717
  },
1753
- gatewayMethods: ['bncr.connect', 'bncr.inbound', 'bncr.activity', 'bncr.ack'],
2718
+ gatewayMethods: [
2719
+ 'bncr.connect',
2720
+ 'bncr.inbound',
2721
+ 'bncr.activity',
2722
+ 'bncr.ack',
2723
+ 'bncr.diagnostics',
2724
+ 'bncr.file.init',
2725
+ 'bncr.file.chunk',
2726
+ 'bncr.file.complete',
2727
+ 'bncr.file.abort',
2728
+ 'bncr.file.ack',
2729
+ ],
1754
2730
  gateway: {
1755
2731
  startAccount: bridge.channelStartAccount,
1756
2732
  stopAccount: bridge.channelStopAccount,