@xmoxmo/bncr 0.2.2 → 0.2.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
@@ -83,6 +83,8 @@ const RECENT_INBOUND_SEND_WINDOW_MS = 60_000;
83
83
  const MAX_RETRY = 10;
84
84
  const PUSH_DRAIN_INTERVAL_MS = 500;
85
85
  const PUSH_ACK_TIMEOUT_MS = 30_000;
86
+ const OUTBOUND_READY_TTL_MS = 30_000;
87
+ const PREFERRED_OUTBOUND_TTL_MS = 12_000;
86
88
  const FILE_FORCE_CHUNK = true; // 统一走 WS 分块,保留 base64 仅作兜底
87
89
  const FILE_INLINE_THRESHOLD = 5 * 1024 * 1024; // fallback 阈值(仅 FILE_FORCE_CHUNK=false 时生效)
88
90
  const FILE_CHUNK_SIZE = 256 * 1024; // 256KB
@@ -135,6 +137,12 @@ type FileRecvTransferState = {
135
137
  error?: string;
136
138
  };
137
139
 
140
+ type FileAckPayloadState = {
141
+ payload: Record<string, unknown>;
142
+ ok: boolean;
143
+ at: number;
144
+ };
145
+
138
146
  type ChatType = 'direct' | 'group' | (string & {});
139
147
 
140
148
  type ChannelMessageActionAdapter = {
@@ -190,6 +198,99 @@ type PersistedState = {
190
198
  } | null;
191
199
  };
192
200
 
201
+ type NormalizedBncrSendParams = {
202
+ to: string;
203
+ accountId: string;
204
+ message: string;
205
+ caption: string;
206
+ mediaUrl?: string;
207
+ asVoice: boolean;
208
+ audioAsVoice: boolean;
209
+ };
210
+
211
+ function normalizeBncrSendParams(input: {
212
+ params: unknown;
213
+ accountId: string;
214
+ }): NormalizedBncrSendParams {
215
+ const paramsObj = isPlainObject(input.params) ? input.params : {};
216
+ const to = readStringParam(paramsObj, 'to', { required: true });
217
+ const resolvedAccountId = normalizeAccountId(
218
+ readStringParam(paramsObj, 'accountId') ?? input.accountId,
219
+ );
220
+
221
+ const message = readStringParam(paramsObj, 'message', { allowEmpty: true }) ?? '';
222
+ const caption = readStringParam(paramsObj, 'caption', { allowEmpty: true }) ?? '';
223
+ const mediaUrl =
224
+ readStringParam(paramsObj, 'media', { trim: false }) ??
225
+ readStringParam(paramsObj, 'path', { trim: false }) ??
226
+ readStringParam(paramsObj, 'filePath', { trim: false }) ??
227
+ readStringParam(paramsObj, 'mediaUrl', { trim: false });
228
+ const asVoice = readBooleanParam(paramsObj, 'asVoice') ?? false;
229
+ const audioAsVoice = readBooleanParam(paramsObj, 'audioAsVoice') ?? false;
230
+
231
+ if (asVoice && !mediaUrl) throw new Error('send voice requires media path');
232
+
233
+ const normalizedMessage = mediaUrl ? '' : message || caption || '';
234
+ const normalizedCaption = mediaUrl ? caption || message || '' : '';
235
+
236
+ if (!normalizedMessage.trim() && !normalizedCaption.trim() && !mediaUrl) {
237
+ throw new Error('send requires message or media');
238
+ }
239
+
240
+ return {
241
+ to,
242
+ accountId: resolvedAccountId,
243
+ message: normalizedMessage,
244
+ caption: normalizedCaption,
245
+ mediaUrl: mediaUrl || undefined,
246
+ asVoice,
247
+ audioAsVoice,
248
+ };
249
+ }
250
+
251
+ type MediaDedupeCacheEntry = {
252
+ mediaUrl: string;
253
+ text: string;
254
+ replyToId: string;
255
+ createdAt: number;
256
+ };
257
+
258
+ function normalizeReplyToId(value: unknown): string {
259
+ return asString(value || '').trim();
260
+ }
261
+
262
+ function normalizeMessageText(value: unknown): string {
263
+ return asString(value || '').trim();
264
+ }
265
+
266
+ function shouldTreatReplyToAsSame(currentReplyToId: string, previousReplyToId: string): boolean {
267
+ if (!currentReplyToId || !previousReplyToId) return true;
268
+ return currentReplyToId === previousReplyToId;
269
+ }
270
+
271
+ function buildMediaTextFallback(params: {
272
+ currentText: string;
273
+ previousText: string;
274
+ currentReplyToId: string;
275
+ previousReplyToId: string;
276
+ }): { text: string; reason: 'same-text-sent-checkmark' | 'text-changed-downgrade' } | null {
277
+ if (!shouldTreatReplyToAsSame(params.currentReplyToId, params.previousReplyToId)) {
278
+ return null;
279
+ }
280
+
281
+ if (params.currentText && params.currentText !== params.previousText) {
282
+ return {
283
+ text: params.currentText,
284
+ reason: 'text-changed-downgrade',
285
+ };
286
+ }
287
+
288
+ return {
289
+ text: '✅已发送',
290
+ reason: 'same-text-sent-checkmark',
291
+ };
292
+ }
293
+
193
294
  function now() {
194
295
  return Date.now();
195
296
  }
@@ -200,6 +301,10 @@ function asString(v: unknown, fallback = ''): string {
200
301
  return String(v);
201
302
  }
202
303
 
304
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
305
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
306
+ }
307
+
203
308
  function backoffMs(retryCount: number): number {
204
309
  // 1s,2s,4s,8s... capped by retry count checks
205
310
  return Math.max(1_000, 1_000 * 2 ** Math.max(0, retryCount - 1));
@@ -273,6 +378,7 @@ class BncrBridgeRuntime {
273
378
  private api: OpenClawPluginApi;
274
379
  private statePath: string | null = null;
275
380
  private bridgeId = `${process.pid}-${Math.random().toString(16).slice(2, 8)}`;
381
+ private recentMediaDedupeBySession = new Map<string, Map<string, MediaDedupeCacheEntry>>();
276
382
  private gatewayPid = process.pid;
277
383
  private registerCount = 0;
278
384
  private apiGeneration = 0;
@@ -349,6 +455,8 @@ class BncrBridgeRuntime {
349
455
  private lastActivityByAccount = new Map<string, number>();
350
456
  private lastInboundByAccount = new Map<string, number>();
351
457
  private lastOutboundByAccount = new Map<string, number>();
458
+ private channelAccountTimers = new Map<string, NodeJS.Timeout>();
459
+ private logDedupeState = new Map<string, { at: number; sig: string }>();
352
460
  private canonicalAgentId: string | null = null;
353
461
  private canonicalAgentSource: 'startup' | 'runtime' | 'fallback-main' | null = null;
354
462
  private canonicalAgentResolvedAt: number | null = null;
@@ -383,6 +491,7 @@ class BncrBridgeRuntime {
383
491
  timer: NodeJS.Timeout;
384
492
  }
385
493
  >();
494
+ private earlyFileAcks = new Map<string, FileAckPayloadState>();
386
495
 
387
496
  constructor(api: OpenClawPluginApi) {
388
497
  this.api = api;
@@ -408,6 +517,102 @@ class BncrBridgeRuntime {
408
517
  emitBncrLog('error', scope, message, options, () => this.isDebugEnabled());
409
518
  }
410
519
 
520
+ private buildDebugJsonMessage(event: string, payload: Record<string, unknown>) {
521
+ return `${event} ${JSON.stringify(payload)}`;
522
+ }
523
+
524
+ private logInfoJson(
525
+ scope: string | undefined,
526
+ event: string,
527
+ payload: Record<string, unknown>,
528
+ options?: { debugOnly?: boolean },
529
+ ) {
530
+ this.logInfo(scope, this.buildDebugJsonMessage(event, payload), options);
531
+ }
532
+
533
+ private logWarnJson(
534
+ scope: string | undefined,
535
+ event: string,
536
+ payload: Record<string, unknown>,
537
+ options?: { debugOnly?: boolean },
538
+ ) {
539
+ this.logWarn(scope, this.buildDebugJsonMessage(event, payload), options);
540
+ }
541
+
542
+ private logErrorJson(
543
+ scope: string | undefined,
544
+ event: string,
545
+ payload: Record<string, unknown>,
546
+ options?: { debugOnly?: boolean },
547
+ ) {
548
+ this.logError(scope, this.buildDebugJsonMessage(event, payload), options);
549
+ }
550
+
551
+ private shouldEmitDedupLog(key: string, sig: string, windowMs = 5 * 60 * 1000) {
552
+ const t = now();
553
+ const prev = this.logDedupeState.get(key) || null;
554
+ if (prev && prev.sig === sig && t - prev.at < windowMs) return false;
555
+ this.logDedupeState.set(key, { at: t, sig });
556
+ return true;
557
+ }
558
+
559
+ private logInfoDedup(
560
+ scope: string | undefined,
561
+ message: string,
562
+ options: { key: string; sig: string; debugOnly?: boolean; windowMs?: number },
563
+ ) {
564
+ if (!this.shouldEmitDedupLog(options.key, options.sig, options.windowMs)) return;
565
+ this.logInfo(scope, message, { debugOnly: options.debugOnly });
566
+ }
567
+
568
+ private logWarnDedup(
569
+ scope: string | undefined,
570
+ message: string,
571
+ options: { key: string; sig: string; debugOnly?: boolean; windowMs?: number },
572
+ ) {
573
+ if (!this.shouldEmitDedupLog(options.key, options.sig, options.windowMs)) return;
574
+ this.logWarn(scope, message, { debugOnly: options.debugOnly });
575
+ }
576
+
577
+ private logErrorDedup(
578
+ scope: string | undefined,
579
+ message: string,
580
+ options: { key: string; sig: string; debugOnly?: boolean; windowMs?: number },
581
+ ) {
582
+ if (!this.shouldEmitDedupLog(options.key, options.sig, options.windowMs)) return;
583
+ this.logError(scope, message, { debugOnly: options.debugOnly });
584
+ }
585
+
586
+ private logInfoDedupJson(
587
+ scope: string | undefined,
588
+ event: string,
589
+ payload: Record<string, unknown>,
590
+ options: { key: string; sig: string; debugOnly?: boolean; windowMs?: number },
591
+ ) {
592
+ if (!this.shouldEmitDedupLog(options.key, options.sig, options.windowMs)) return;
593
+ this.logInfoJson(scope, event, payload, { debugOnly: options.debugOnly });
594
+ }
595
+
596
+ private logWarnDedupJson(
597
+ scope: string | undefined,
598
+ event: string,
599
+ payload: Record<string, unknown>,
600
+ options: { key: string; sig: string; debugOnly?: boolean; windowMs?: number },
601
+ ) {
602
+ if (!this.shouldEmitDedupLog(options.key, options.sig, options.windowMs)) return;
603
+ this.logWarnJson(scope, event, payload, { debugOnly: options.debugOnly });
604
+ }
605
+
606
+ private logErrorDedupJson(
607
+ scope: string | undefined,
608
+ event: string,
609
+ payload: Record<string, unknown>,
610
+ options: { key: string; sig: string; debugOnly?: boolean; windowMs?: number },
611
+ ) {
612
+ if (!this.shouldEmitDedupLog(options.key, options.sig, options.windowMs)) return;
613
+ this.logErrorJson(scope, event, payload, { debugOnly: options.debugOnly });
614
+ }
615
+
411
616
  private summarizeTextPreview(raw: string, limit = 8) {
412
617
  const compact = asString(raw || '')
413
618
  .replace(/\s+/g, ' ')
@@ -441,6 +646,19 @@ class BncrBridgeRuntime {
441
646
  this.logInfo('outbound', [type, this.summarizeScope(entry.route), preview].join('|'));
442
647
  }
443
648
 
649
+ private clearChannelAccountWorker(accountId: string, reason: string) {
650
+ const timer = this.channelAccountTimers.get(accountId);
651
+ if (!timer) return false;
652
+ clearInterval(timer);
653
+ this.channelAccountTimers.delete(accountId);
654
+ this.logInfo(
655
+ 'health',
656
+ `status-worker cleared ${JSON.stringify({ bridge: this.bridgeId, accountId, reason })}`,
657
+ { debugOnly: true },
658
+ );
659
+ return true;
660
+ }
661
+
444
662
  private classifyRegisterTrace(stack: string) {
445
663
  if (
446
664
  stack.includes('prepareSecretsRuntimeSnapshot') ||
@@ -715,7 +933,8 @@ class BncrBridgeRuntime {
715
933
  }
716
934
 
717
935
  private buildExtendedDiagnostics(accountId: string) {
718
- const diagnostics = this.buildIntegratedDiagnostics(accountId) as Record<string, any>;
936
+ const acc = normalizeAccountId(accountId);
937
+ const diagnostics = this.buildIntegratedDiagnostics(acc) as Record<string, any>;
719
938
  return {
720
939
  ...diagnostics,
721
940
  register: {
@@ -735,7 +954,7 @@ class BncrBridgeRuntime {
735
954
  lastDriftSnapshot: this.lastDriftSnapshot,
736
955
  },
737
956
  connection: {
738
- active: this.activeConnectionCount(accountId),
957
+ active: this.activeConnectionCount(acc),
739
958
  primaryLeaseId: this.primaryLeaseId,
740
959
  primaryEpoch: this.connectionEpoch || null,
741
960
  acceptedConnections: this.acceptedConnections,
@@ -821,6 +1040,7 @@ class BncrBridgeRuntime {
821
1040
  clearTimeout(waiter.timer);
822
1041
  }
823
1042
  this.fileAckWaiters.clear();
1043
+ this.earlyFileAcks.clear();
824
1044
  }
825
1045
 
826
1046
  private scheduleSave() {
@@ -1262,11 +1482,92 @@ class BncrBridgeRuntime {
1262
1482
  const acc = normalizeAccountId(accountId);
1263
1483
  const t = now();
1264
1484
  const primaryKey = this.activeConnectionByAccount.get(acc);
1265
- if (!primaryKey) return null;
1266
- const primary = this.connections.get(primaryKey);
1267
- if (!primary?.connId) return null;
1268
- if (t - primary.lastSeenAt > CONNECT_TTL_MS) return null;
1269
- return primary;
1485
+ const primary = primaryKey ? this.connections.get(primaryKey) : null;
1486
+
1487
+ const isEligible = (conn: BncrConnection | null | undefined): conn is BncrConnection & {
1488
+ outboundReadyUntil?: number;
1489
+ preferredForOutboundUntil?: number;
1490
+ inboundOnly?: boolean;
1491
+ } => {
1492
+ if (!conn?.connId) return false;
1493
+ if (t - conn.lastSeenAt > CONNECT_TTL_MS) return false;
1494
+ if ((conn as any).inboundOnly === true) return false;
1495
+ return true;
1496
+ };
1497
+
1498
+ const recentInboundConnIds = this.resolveRecentInboundConnIds(acc);
1499
+ const candidateScore = (conn: BncrConnection) => {
1500
+ const preferredForOutboundUntil = Number((conn as any).preferredForOutboundUntil || 0);
1501
+ const outboundReadyUntil = Number((conn as any).outboundReadyUntil || 0);
1502
+ const lastPushTimeoutAt = Number((conn as any).lastPushTimeoutAt || 0);
1503
+ const lastAckOkAt = Number((conn as any).lastAckOkAt || 0);
1504
+ const pushFailureScore = Number((conn as any).pushFailureScore || 0);
1505
+ const recentTimeoutPenalty = lastPushTimeoutAt > 0 && t - lastPushTimeoutAt <= 30_000 ? 1 : 0;
1506
+ return {
1507
+ preferred: preferredForOutboundUntil > t ? 1 : 0,
1508
+ ready: outboundReadyUntil > t ? 1 : 0,
1509
+ recentInbound: recentInboundConnIds.has(conn.connId) ? 1 : 0,
1510
+ recentTimeoutPenalty,
1511
+ pushFailureScore,
1512
+ lastAckOkAt,
1513
+ lastPushTimeoutAt,
1514
+ lastSeenAt: conn.lastSeenAt,
1515
+ connectedAt: conn.connectedAt,
1516
+ };
1517
+ };
1518
+
1519
+ if (isEligible(primary)) {
1520
+ const score = candidateScore(primary);
1521
+ if (score.preferred || score.ready) return primary;
1522
+ }
1523
+
1524
+ const candidates = Array.from(this.connections.values())
1525
+ .filter((c): c is BncrConnection => c.accountId === acc)
1526
+ .filter((c) => isEligible(c))
1527
+ .sort((a, b) => {
1528
+ const sa = candidateScore(a);
1529
+ const sb = candidateScore(b);
1530
+ if (sb.preferred !== sa.preferred) return sb.preferred - sa.preferred;
1531
+ if (sb.ready !== sa.ready) return sb.ready - sa.ready;
1532
+ if (sa.recentTimeoutPenalty !== sb.recentTimeoutPenalty) return sa.recentTimeoutPenalty - sb.recentTimeoutPenalty;
1533
+ if (sa.pushFailureScore !== sb.pushFailureScore) return sa.pushFailureScore - sb.pushFailureScore;
1534
+ if (sb.lastAckOkAt !== sa.lastAckOkAt) return sb.lastAckOkAt - sa.lastAckOkAt;
1535
+ if (sa.lastPushTimeoutAt !== sb.lastPushTimeoutAt) return sa.lastPushTimeoutAt - sb.lastPushTimeoutAt;
1536
+ if (sb.recentInbound !== sa.recentInbound) return sb.recentInbound - sa.recentInbound;
1537
+ if (sb.lastSeenAt !== sa.lastSeenAt) return sb.lastSeenAt - sa.lastSeenAt;
1538
+ return sb.connectedAt - sa.connectedAt;
1539
+ });
1540
+
1541
+ const next = candidates[0] || null;
1542
+ if (!next) return null;
1543
+
1544
+ const nextKey = this.connectionKey(acc, next.clientId);
1545
+ if (primaryKey !== nextKey) {
1546
+ this.activeConnectionByAccount.set(acc, nextKey);
1547
+ this.logInfo(
1548
+ 'connection',
1549
+ `owner:promote ${JSON.stringify({
1550
+ bridge: this.bridgeId,
1551
+ accountId: acc,
1552
+ previousActiveKey: primaryKey || null,
1553
+ previousActiveConn: primary || null,
1554
+ nextActiveKey: nextKey,
1555
+ nextActiveConn: {
1556
+ connId: next.connId,
1557
+ clientId: next.clientId,
1558
+ connectedAt: next.connectedAt,
1559
+ lastSeenAt: next.lastSeenAt,
1560
+ outboundReadyUntil: (next as any).outboundReadyUntil || null,
1561
+ preferredForOutboundUntil: (next as any).preferredForOutboundUntil || null,
1562
+ inboundOnly: (next as any).inboundOnly === true,
1563
+ },
1564
+ reason: 'better-outbound-candidate',
1565
+ })}`,
1566
+ { debugOnly: true },
1567
+ );
1568
+ }
1569
+
1570
+ return next;
1270
1571
  }
1271
1572
 
1272
1573
  private resolvePushConnIds(accountId: string): Set<string> {
@@ -1274,14 +1575,67 @@ class BncrBridgeRuntime {
1274
1575
  const t = now();
1275
1576
  const connIds = new Set<string>();
1276
1577
 
1578
+ const isEligible = (conn: BncrConnection | null | undefined): conn is BncrConnection & {
1579
+ outboundReadyUntil?: number;
1580
+ preferredForOutboundUntil?: number;
1581
+ inboundOnly?: boolean;
1582
+ } => {
1583
+ if (!conn?.connId) return false;
1584
+ if (t - conn.lastSeenAt > CONNECT_TTL_MS) return false;
1585
+ if ((conn as any).inboundOnly === true) return false;
1586
+ return true;
1587
+ };
1588
+
1589
+ const recentInboundConnIds = this.resolveRecentInboundConnIds(acc);
1590
+ const candidateScore = (conn: BncrConnection) => {
1591
+ const preferredForOutboundUntil = Number((conn as any).preferredForOutboundUntil || 0);
1592
+ const outboundReadyUntil = Number((conn as any).outboundReadyUntil || 0);
1593
+ const lastPushTimeoutAt = Number((conn as any).lastPushTimeoutAt || 0);
1594
+ const lastAckOkAt = Number((conn as any).lastAckOkAt || 0);
1595
+ const pushFailureScore = Number((conn as any).pushFailureScore || 0);
1596
+ const recentTimeoutPenalty = lastPushTimeoutAt > 0 && t - lastPushTimeoutAt <= 30_000 ? 1 : 0;
1597
+ return {
1598
+ preferred: preferredForOutboundUntil > t ? 1 : 0,
1599
+ ready: outboundReadyUntil > t ? 1 : 0,
1600
+ recentInbound: recentInboundConnIds.has(conn.connId) ? 1 : 0,
1601
+ recentTimeoutPenalty,
1602
+ pushFailureScore,
1603
+ lastAckOkAt,
1604
+ lastPushTimeoutAt,
1605
+ lastSeenAt: conn.lastSeenAt,
1606
+ connectedAt: conn.connectedAt,
1607
+ };
1608
+ };
1609
+
1277
1610
  const primaryKey = this.activeConnectionByAccount.get(acc);
1278
1611
  if (primaryKey) {
1279
1612
  const primary = this.connections.get(primaryKey);
1280
- if (primary?.connId && t - primary.lastSeenAt <= CONNECT_TTL_MS) {
1613
+ if (isEligible(primary)) {
1281
1614
  connIds.add(primary.connId);
1282
1615
  }
1283
1616
  }
1284
1617
 
1618
+ const candidates = Array.from(this.connections.values())
1619
+ .filter((c): c is BncrConnection => c.accountId === acc)
1620
+ .filter((c) => isEligible(c))
1621
+ .sort((a, b) => {
1622
+ const sa = candidateScore(a);
1623
+ const sb = candidateScore(b);
1624
+ if (sb.preferred !== sa.preferred) return sb.preferred - sa.preferred;
1625
+ if (sb.ready !== sa.ready) return sb.ready - sa.ready;
1626
+ if (sa.recentTimeoutPenalty !== sb.recentTimeoutPenalty) return sa.recentTimeoutPenalty - sb.recentTimeoutPenalty;
1627
+ if (sa.pushFailureScore !== sb.pushFailureScore) return sa.pushFailureScore - sb.pushFailureScore;
1628
+ if (sb.lastAckOkAt !== sa.lastAckOkAt) return sb.lastAckOkAt - sa.lastAckOkAt;
1629
+ if (sa.lastPushTimeoutAt !== sb.lastPushTimeoutAt) return sa.lastPushTimeoutAt - sb.lastPushTimeoutAt;
1630
+ if (sb.recentInbound !== sa.recentInbound) return sb.recentInbound - sa.recentInbound;
1631
+ if (sb.lastSeenAt !== sa.lastSeenAt) return sb.lastSeenAt - sa.lastSeenAt;
1632
+ return sb.connectedAt - sa.connectedAt;
1633
+ });
1634
+
1635
+ for (const c of candidates) {
1636
+ connIds.add(c.connId);
1637
+ }
1638
+
1285
1639
  if (connIds.size > 0) return connIds;
1286
1640
 
1287
1641
  for (const c of this.connections.values()) {
@@ -1319,7 +1673,480 @@ class BncrBridgeRuntime {
1319
1673
  return connIds;
1320
1674
  }
1321
1675
 
1322
- private tryPushEntry(entry: OutboxEntry): boolean {
1676
+ private isRecentlyReachableConn(accountId: string, connId?: string, clientId?: string): boolean {
1677
+ const acc = normalizeAccountId(accountId);
1678
+ const cid = asString(connId || '').trim();
1679
+ const client = asString(clientId || '').trim() || undefined;
1680
+ if (!cid) return false;
1681
+
1682
+ const recentConnIds = this.resolveRecentInboundConnIds(acc);
1683
+ if (recentConnIds.has(cid)) return true;
1684
+
1685
+ const activeKey = this.activeConnectionByAccount.get(acc);
1686
+ if (!activeKey) return false;
1687
+ const active = this.connections.get(activeKey);
1688
+ if (!active?.connId) return false;
1689
+ if (active.connId !== cid) return false;
1690
+ if (client && active.clientId && active.clientId !== client) return false;
1691
+ return true;
1692
+ }
1693
+
1694
+ private isRevalidatedAttemptedConn(entry: OutboxEntry, connId: string): boolean {
1695
+ const targetConnId = asString(connId || '').trim();
1696
+ if (!targetConnId) return false;
1697
+
1698
+ const acc = normalizeAccountId(entry.accountId);
1699
+ const t = now();
1700
+ const lastAttemptAt = Number(entry.lastAttemptAt || 0);
1701
+ const recentInboundReachable = this.hasRecentInboundReachability(acc);
1702
+
1703
+ for (const conn of this.connections.values()) {
1704
+ if (conn.accountId !== acc) continue;
1705
+ if (conn.connId !== targetConnId) continue;
1706
+ if (t - conn.lastSeenAt > CONNECT_TTL_MS) continue;
1707
+ if ((conn as any).inboundOnly === true) continue;
1708
+
1709
+ const preferredForOutboundUntil = Number((conn as any).preferredForOutboundUntil || 0);
1710
+ const outboundReadyUntil = Number((conn as any).outboundReadyUntil || 0);
1711
+ const lastAckOkAt = Number((conn as any).lastAckOkAt || 0);
1712
+ const lastPushTimeoutAt = Number((conn as any).lastPushTimeoutAt || 0);
1713
+
1714
+ const revalidatedByPreferred = preferredForOutboundUntil > t;
1715
+ const revalidatedByReady = outboundReadyUntil > t;
1716
+ const revalidatedByAck = lastAckOkAt > 0 && lastAckOkAt > lastAttemptAt;
1717
+ const revalidatedByFreshReachability =
1718
+ recentInboundReachable &&
1719
+ lastPushTimeoutAt > 0 &&
1720
+ lastPushTimeoutAt <= lastAttemptAt &&
1721
+ conn.lastSeenAt > lastPushTimeoutAt;
1722
+
1723
+ const revalidated =
1724
+ revalidatedByPreferred ||
1725
+ revalidatedByReady ||
1726
+ revalidatedByAck ||
1727
+ revalidatedByFreshReachability;
1728
+
1729
+ if (revalidated) {
1730
+ this.logInfo(
1731
+ 'outbox',
1732
+ `revalidated-retry ${JSON.stringify({
1733
+ messageId: entry.messageId,
1734
+ accountId: acc,
1735
+ connId: targetConnId,
1736
+ reason: revalidatedByAck
1737
+ ? 'ack-after-last-attempt'
1738
+ : revalidatedByPreferred
1739
+ ? 'preferred-ttl'
1740
+ : revalidatedByReady
1741
+ ? 'ready-ttl'
1742
+ : 'fresh-reachability',
1743
+ lastAttemptAt,
1744
+ lastAckOkAt: lastAckOkAt || null,
1745
+ lastPushTimeoutAt: lastPushTimeoutAt || null,
1746
+ outboundReadyUntil: outboundReadyUntil || null,
1747
+ preferredForOutboundUntil: preferredForOutboundUntil || null,
1748
+ lastSeenAt: conn.lastSeenAt,
1749
+ recentInboundReachable,
1750
+ })}`,
1751
+ { debugOnly: true },
1752
+ );
1753
+ }
1754
+
1755
+ return revalidated;
1756
+ }
1757
+
1758
+ return false;
1759
+ }
1760
+
1761
+ private tryAdoptTransferOwner(args: {
1762
+ accountId: string;
1763
+ transfer:
1764
+ | FileSendTransferState
1765
+ | FileRecvTransferState
1766
+ | undefined;
1767
+ connId: string;
1768
+ clientId?: string;
1769
+ }): boolean {
1770
+ const { accountId, transfer, connId, clientId } = args;
1771
+ if (!transfer) return false;
1772
+ if (!this.hasRecentInboundReachability(accountId)) return false;
1773
+ if (!this.isRecentlyReachableConn(accountId, connId, clientId)) return false;
1774
+
1775
+ transfer.ownerConnId = connId;
1776
+ transfer.ownerClientId = asString(clientId || '').trim() || undefined;
1777
+ return true;
1778
+ }
1779
+
1780
+ private isRetryableFileTransferError(error: unknown): boolean {
1781
+ const msg = asString((error as any)?.message || error || '')
1782
+ .trim()
1783
+ .toLowerCase();
1784
+ if (!msg) return true;
1785
+
1786
+ const retryableMarkers = [
1787
+ 'gateway context unavailable',
1788
+ 'no active bncr client for file chunk transfer',
1789
+ 'chunk ack timeout',
1790
+ 'complete ack timeout',
1791
+ 'transfer state missing',
1792
+ 'transfer aborted',
1793
+ 'temporarily unavailable',
1794
+ 'timeout',
1795
+ 'econn',
1796
+ 'socket',
1797
+ 'network',
1798
+ ];
1799
+
1800
+ return retryableMarkers.some((marker) => msg.includes(marker));
1801
+ }
1802
+
1803
+ private buildFileTransferOutboxEntry(params: {
1804
+ accountId: string;
1805
+ sessionKey: string;
1806
+ route: BncrRoute;
1807
+ mediaUrl: string;
1808
+ mediaLocalRoots?: readonly string[];
1809
+ text?: string;
1810
+ asVoice?: boolean;
1811
+ audioAsVoice?: boolean;
1812
+ kind?: 'tool' | 'block' | 'final';
1813
+ replyToId?: string;
1814
+ }): OutboxEntry {
1815
+ const messageId = randomUUID();
1816
+ return {
1817
+ messageId,
1818
+ accountId: normalizeAccountId(params.accountId),
1819
+ sessionKey: params.sessionKey,
1820
+ route: params.route,
1821
+ payload: {
1822
+ type: 'message.outbound',
1823
+ sessionKey: params.sessionKey,
1824
+ _meta: {
1825
+ kind: 'file-transfer',
1826
+ mediaUrl: params.mediaUrl,
1827
+ mediaLocalRoots: params.mediaLocalRoots ? Array.from(params.mediaLocalRoots) : undefined,
1828
+ text: asString(params.text || ''),
1829
+ asVoice: params.asVoice === true,
1830
+ audioAsVoice: params.audioAsVoice === true,
1831
+ finalEvent: BNCR_PUSH_EVENT,
1832
+ replyToId: asString(params.replyToId || '').trim() || undefined,
1833
+ messageKind: params.kind,
1834
+ },
1835
+ },
1836
+ createdAt: now(),
1837
+ retryCount: 0,
1838
+ nextAttemptAt: now(),
1839
+ };
1840
+ }
1841
+
1842
+ private pruneMediaDedupeCache(sessionKey: string, currentTime = now()) {
1843
+ const sessionCache = this.recentMediaDedupeBySession.get(sessionKey);
1844
+ if (!sessionCache) return;
1845
+
1846
+ for (const [mediaUrl, entry] of sessionCache.entries()) {
1847
+ if (currentTime - entry.createdAt > 10_000) {
1848
+ sessionCache.delete(mediaUrl);
1849
+ }
1850
+ }
1851
+
1852
+ if (sessionCache.size === 0) {
1853
+ this.recentMediaDedupeBySession.delete(sessionKey);
1854
+ }
1855
+ }
1856
+
1857
+ private rememberRecentMediaSend(params: {
1858
+ sessionKey: string;
1859
+ mediaUrl: string;
1860
+ text: string;
1861
+ replyToId: string;
1862
+ createdAt?: number;
1863
+ }) {
1864
+ const sessionKey = asString(params.sessionKey || '').trim();
1865
+ const mediaUrl = asString(params.mediaUrl || '').trim();
1866
+ if (!sessionKey || !mediaUrl) return;
1867
+
1868
+ const createdAt = typeof params.createdAt === 'number' ? params.createdAt : now();
1869
+ this.pruneMediaDedupeCache(sessionKey, createdAt);
1870
+ let sessionCache = this.recentMediaDedupeBySession.get(sessionKey);
1871
+ if (!sessionCache) {
1872
+ sessionCache = new Map<string, MediaDedupeCacheEntry>();
1873
+ this.recentMediaDedupeBySession.set(sessionKey, sessionCache);
1874
+ }
1875
+ sessionCache.set(mediaUrl, {
1876
+ mediaUrl,
1877
+ text: normalizeMessageText(params.text),
1878
+ replyToId: normalizeReplyToId(params.replyToId),
1879
+ createdAt,
1880
+ });
1881
+ }
1882
+
1883
+ private tryBuildMediaDedupeFallback(params: {
1884
+ sessionKey: string;
1885
+ mediaUrl: string;
1886
+ text: string;
1887
+ replyToId: string;
1888
+ currentTime?: number;
1889
+ }): { text: string; reason: 'same-text-sent-checkmark' | 'text-changed-downgrade' } | null {
1890
+ const sessionKey = asString(params.sessionKey || '').trim();
1891
+ const mediaUrl = asString(params.mediaUrl || '').trim();
1892
+ if (!sessionKey || !mediaUrl) return null;
1893
+
1894
+ const currentTime = typeof params.currentTime === 'number' ? params.currentTime : now();
1895
+ this.pruneMediaDedupeCache(sessionKey, currentTime);
1896
+ const sessionCache = this.recentMediaDedupeBySession.get(sessionKey);
1897
+ const previous = sessionCache?.get(mediaUrl);
1898
+ if (!previous) return null;
1899
+ if (currentTime - previous.createdAt > 10_000) return null;
1900
+
1901
+ return buildMediaTextFallback({
1902
+ currentText: normalizeMessageText(params.text),
1903
+ previousText: previous.text,
1904
+ currentReplyToId: normalizeReplyToId(params.replyToId),
1905
+ previousReplyToId: previous.replyToId,
1906
+ });
1907
+ }
1908
+
1909
+ private buildTextOutboxEntry(params: {
1910
+ accountId: string;
1911
+ sessionKey: string;
1912
+ route: BncrRoute;
1913
+ text: string;
1914
+ kind?: 'tool' | 'block' | 'final';
1915
+ replyToId?: string;
1916
+ }): OutboxEntry {
1917
+ const messageId = randomUUID();
1918
+ const frame = {
1919
+ type: 'message.outbound',
1920
+ messageId,
1921
+ idempotencyKey: messageId,
1922
+ sessionKey: params.sessionKey,
1923
+ replyToId: normalizeReplyToId(params.replyToId) || undefined,
1924
+ message: {
1925
+ platform: params.route.platform,
1926
+ groupId: params.route.groupId,
1927
+ userId: params.route.userId,
1928
+ type: 'text',
1929
+ kind: params.kind,
1930
+ msg: params.text,
1931
+ path: '',
1932
+ base64: '',
1933
+ fileName: '',
1934
+ },
1935
+ ts: now(),
1936
+ };
1937
+
1938
+ return {
1939
+ messageId,
1940
+ accountId: normalizeAccountId(params.accountId),
1941
+ sessionKey: params.sessionKey,
1942
+ route: params.route,
1943
+ payload: frame,
1944
+ createdAt: now(),
1945
+ retryCount: 0,
1946
+ nextAttemptAt: now(),
1947
+ };
1948
+ }
1949
+
1950
+ private async tryPushEntry(entry: OutboxEntry): Promise<boolean> {
1951
+ const meta = isPlainObject(entry.payload?._meta) ? entry.payload._meta : null;
1952
+ if (meta?.kind === 'file-transfer') {
1953
+ const ctx = this.gatewayContext;
1954
+ if (!ctx) {
1955
+ entry.lastError = 'gateway context unavailable';
1956
+ this.outbox.set(entry.messageId, entry);
1957
+ this.logInfo(
1958
+ 'outbox',
1959
+ `push-skip ${JSON.stringify({
1960
+ messageId: entry.messageId,
1961
+ accountId: entry.accountId,
1962
+ kind: 'file-transfer',
1963
+ reason: 'no-gateway-context',
1964
+ })}`,
1965
+ { debugOnly: true },
1966
+ );
1967
+ return false;
1968
+ }
1969
+
1970
+ const attemptedConnIds = new Set(
1971
+ Array.isArray(entry.routeAttemptConnIds)
1972
+ ? entry.routeAttemptConnIds.filter((v): v is string => typeof v === 'string' && !!v)
1973
+ : [],
1974
+ );
1975
+ const routeCandidates = Array.from(this.resolvePushConnIds(entry.accountId));
1976
+ const filteredCandidates = routeCandidates.filter(
1977
+ (connId) =>
1978
+ !attemptedConnIds.has(connId) || this.isRevalidatedAttemptedConn(entry, connId),
1979
+ );
1980
+ const owner = this.resolveOutboxPushOwner(entry.accountId);
1981
+ const ownerConnId = owner?.connId && !attemptedConnIds.has(owner.connId) ? owner.connId : undefined;
1982
+ let connIds = ownerConnId
1983
+ ? new Set([ownerConnId])
1984
+ : new Set(filteredCandidates.length ? filteredCandidates : routeCandidates);
1985
+ const recentInboundReachable = this.hasRecentInboundReachability(entry.accountId);
1986
+ const routeReason = ownerConnId
1987
+ ? 'owner'
1988
+ : connIds.size > 0
1989
+ ? filteredCandidates.length > 0
1990
+ ? 'active-connections'
1991
+ : 'active-connections-reused'
1992
+ : recentInboundReachable
1993
+ ? 'recent-inbound-fallback'
1994
+ : 'none';
1995
+ if (!connIds.size && recentInboundReachable) {
1996
+ const recentInboundConnIds = Array.from(this.resolveRecentInboundConnIds(entry.accountId));
1997
+ const filteredRecentInboundConnIds = recentInboundConnIds.filter(
1998
+ (connId) => !attemptedConnIds.has(connId),
1999
+ );
2000
+ connIds = new Set(
2001
+ filteredRecentInboundConnIds.length > 0 ? filteredRecentInboundConnIds : recentInboundConnIds,
2002
+ );
2003
+ }
2004
+ if (!connIds.size) {
2005
+ entry.lastError = 'no active bncr client for file chunk transfer';
2006
+ this.outbox.set(entry.messageId, entry);
2007
+ this.logInfo(
2008
+ 'outbox',
2009
+ `push-skip ${JSON.stringify({
2010
+ messageId: entry.messageId,
2011
+ accountId: entry.accountId,
2012
+ kind: 'file-transfer',
2013
+ reason: 'no-active-connection',
2014
+ recentInboundReachable,
2015
+ })}`,
2016
+ { debugOnly: true },
2017
+ );
2018
+ return false;
2019
+ }
2020
+
2021
+ const mediaUrl = asString(meta.mediaUrl || '').trim();
2022
+ if (!mediaUrl) {
2023
+ entry.lastError = 'file transfer mediaUrl missing';
2024
+ this.outbox.set(entry.messageId, entry);
2025
+ this.logInfo(
2026
+ 'outbox',
2027
+ `push-fail ${JSON.stringify({
2028
+ messageId: entry.messageId,
2029
+ accountId: entry.accountId,
2030
+ kind: 'file-transfer',
2031
+ error: entry.lastError,
2032
+ })}`,
2033
+ { debugOnly: true },
2034
+ );
2035
+ return false;
2036
+ }
2037
+
2038
+ try {
2039
+ const media = await this.transferMediaToBncrClient({
2040
+ accountId: entry.accountId,
2041
+ sessionKey: entry.sessionKey,
2042
+ route: entry.route,
2043
+ mediaUrl,
2044
+ mediaLocalRoots: Array.isArray(meta.mediaLocalRoots)
2045
+ ? meta.mediaLocalRoots.filter((v): v is string => typeof v === 'string')
2046
+ : undefined,
2047
+ });
2048
+ const wantsVoice = meta.asVoice === true || meta.audioAsVoice === true;
2049
+ const frame = buildBncrMediaOutboundFrame({
2050
+ messageId: entry.messageId,
2051
+ sessionKey: entry.sessionKey,
2052
+ route: entry.route,
2053
+ media,
2054
+ mediaUrl,
2055
+ mediaMsg: asString(meta.text || ''),
2056
+ fileName: resolveOutboundFileName({
2057
+ mediaUrl,
2058
+ fileName: media.fileName,
2059
+ mimeType: media.mimeType,
2060
+ }),
2061
+ hintedType: wantsVoice ? 'voice' : undefined,
2062
+ kind:
2063
+ meta.messageKind === 'tool' ||
2064
+ meta.messageKind === 'block' ||
2065
+ meta.messageKind === 'final'
2066
+ ? meta.messageKind
2067
+ : undefined,
2068
+ replyToId: asString(meta.replyToId || '').trim() || undefined,
2069
+ now: now(),
2070
+ });
2071
+
2072
+ ctx.broadcastToConnIds(
2073
+ BNCR_PUSH_EVENT,
2074
+ {
2075
+ ...frame,
2076
+ idempotencyKey: entry.messageId,
2077
+ },
2078
+ connIds,
2079
+ );
2080
+ this.logInfo(
2081
+ 'outbox',
2082
+ `route-select ${JSON.stringify({
2083
+ messageId: entry.messageId,
2084
+ accountId: entry.accountId,
2085
+ kind: 'file-transfer',
2086
+ routeReason,
2087
+ connIds: Array.from(connIds),
2088
+ ownerConnId: owner?.connId || '',
2089
+ ownerClientId: owner?.clientId || '',
2090
+ recentInboundReachable,
2091
+ event: BNCR_PUSH_EVENT,
2092
+ })}`,
2093
+ { debugOnly: true },
2094
+ );
2095
+ entry.lastPushAt = now();
2096
+ entry.lastPushConnId =
2097
+ owner?.connId || (connIds.size === 1 ? Array.from(connIds)[0] : undefined);
2098
+ entry.lastPushClientId = owner?.clientId;
2099
+ if (!Array.isArray(entry.routeAttemptConnIds)) entry.routeAttemptConnIds = [];
2100
+ if (entry.lastPushConnId && !entry.routeAttemptConnIds.includes(entry.lastPushConnId)) {
2101
+ entry.routeAttemptConnIds.push(entry.lastPushConnId);
2102
+ }
2103
+ entry.lastError = undefined;
2104
+ this.outbox.set(entry.messageId, entry);
2105
+ this.lastOutboundByAccount.set(entry.accountId, entry.lastPushAt);
2106
+ this.markActivity(entry.accountId, entry.lastPushAt);
2107
+ this.scheduleSave();
2108
+ this.logInfo('outbox push ok', `mid=${entry.messageId}|q=${this.outbox.size}`);
2109
+ this.logInfo(
2110
+ 'outbox',
2111
+ `push-ok ${JSON.stringify({
2112
+ messageId: entry.messageId,
2113
+ accountId: entry.accountId,
2114
+ kind: 'file-transfer',
2115
+ connIds: Array.from(connIds),
2116
+ ownerConnId: entry.lastPushConnId || '',
2117
+ ownerClientId: entry.lastPushClientId || '',
2118
+ recentInboundReachable,
2119
+ event: BNCR_PUSH_EVENT,
2120
+ })}`,
2121
+ { debugOnly: true },
2122
+ );
2123
+ return true;
2124
+ } catch (error) {
2125
+ entry.lastError = asString((error as any)?.message || error || 'file-transfer-error');
2126
+ this.outbox.set(entry.messageId, entry);
2127
+ this.scheduleSave();
2128
+ this.logInfo(
2129
+ 'outbox push fail',
2130
+ `mid=${entry.messageId}|q=${this.outbox.size}|err=${entry.lastError}`,
2131
+ );
2132
+ this.logInfo(
2133
+ 'outbox',
2134
+ `push-fail ${JSON.stringify({
2135
+ messageId: entry.messageId,
2136
+ accountId: entry.accountId,
2137
+ kind: 'file-transfer',
2138
+ retryable: this.isRetryableFileTransferError(error),
2139
+ error: entry.lastError,
2140
+ })}`,
2141
+ { debugOnly: true },
2142
+ );
2143
+ if (!this.isRetryableFileTransferError(error)) {
2144
+ this.moveToDeadLetter(entry, entry.lastError || 'file-transfer-failed');
2145
+ }
2146
+ return false;
2147
+ }
2148
+ }
2149
+
1323
2150
  const ctx = this.gatewayContext;
1324
2151
  if (!ctx) {
1325
2152
  this.logInfo(
@@ -1334,13 +2161,42 @@ class BncrBridgeRuntime {
1334
2161
  return false;
1335
2162
  }
1336
2163
 
2164
+ const attemptedConnIds = new Set(
2165
+ Array.isArray(entry.routeAttemptConnIds)
2166
+ ? entry.routeAttemptConnIds.filter((v): v is string => typeof v === 'string' && !!v)
2167
+ : [],
2168
+ );
2169
+ const routeCandidates = Array.from(this.resolvePushConnIds(entry.accountId));
2170
+ const unattemptedCandidates = routeCandidates.filter((connId) => !attemptedConnIds.has(connId));
2171
+ const revalidatedCandidates = routeCandidates.filter(
2172
+ (connId) => attemptedConnIds.has(connId) && this.isRevalidatedAttemptedConn(entry, connId),
2173
+ );
2174
+ const preferredCandidates = unattemptedCandidates.length > 0 ? unattemptedCandidates : routeCandidates;
1337
2175
  const owner = this.resolveOutboxPushOwner(entry.accountId);
1338
- let connIds = owner?.connId
1339
- ? new Set([owner.connId])
1340
- : this.resolvePushConnIds(entry.accountId);
2176
+ const ownerConnId = owner?.connId && preferredCandidates.includes(owner.connId) ? owner.connId : undefined;
2177
+ let connIds = ownerConnId ? new Set([ownerConnId]) : new Set(preferredCandidates);
1341
2178
  const recentInboundReachable = this.hasRecentInboundReachability(entry.accountId);
2179
+ const routeReason = ownerConnId
2180
+ ? 'owner'
2181
+ : connIds.size > 0
2182
+ ? unattemptedCandidates.length > 0
2183
+ ? 'active-connections-unattempted-first'
2184
+ : revalidatedCandidates.length > 0
2185
+ ? 'active-connections-revalidated'
2186
+ : 'active-connections-all-visible'
2187
+ : recentInboundReachable
2188
+ ? 'recent-inbound-fallback'
2189
+ : 'none';
1342
2190
  if (!connIds.size && recentInboundReachable) {
1343
- connIds = this.resolveRecentInboundConnIds(entry.accountId);
2191
+ const recentInboundConnIds = Array.from(this.resolveRecentInboundConnIds(entry.accountId));
2192
+ const unattemptedRecentInboundConnIds = recentInboundConnIds.filter(
2193
+ (connId) => !attemptedConnIds.has(connId),
2194
+ );
2195
+ connIds = new Set(
2196
+ unattemptedRecentInboundConnIds.length > 0
2197
+ ? unattemptedRecentInboundConnIds
2198
+ : recentInboundConnIds,
2199
+ );
1344
2200
  }
1345
2201
  if (!connIds.size) {
1346
2202
  this.logInfo(
@@ -1363,11 +2219,30 @@ class BncrBridgeRuntime {
1363
2219
  };
1364
2220
 
1365
2221
  ctx.broadcastToConnIds(BNCR_PUSH_EVENT, payload, connIds);
2222
+ this.logInfo(
2223
+ 'outbox',
2224
+ `route-select ${JSON.stringify({
2225
+ messageId: entry.messageId,
2226
+ accountId: entry.accountId,
2227
+ routeReason,
2228
+ connIds: Array.from(connIds),
2229
+ ownerConnId: owner?.connId || '',
2230
+ ownerClientId: owner?.clientId || '',
2231
+ recentInboundReachable,
2232
+ event: BNCR_PUSH_EVENT,
2233
+ })}`,
2234
+ { debugOnly: true },
2235
+ );
1366
2236
  entry.lastPushAt = now();
1367
2237
  entry.lastPushConnId =
1368
- owner?.connId || (connIds.size === 1 ? Array.from(connIds)[0] : undefined);
1369
- entry.lastPushClientId = owner?.clientId;
2238
+ ownerConnId || (connIds.size === 1 ? Array.from(connIds)[0] : undefined);
2239
+ entry.lastPushClientId = ownerConnId ? owner?.clientId : undefined;
2240
+ if (!Array.isArray(entry.routeAttemptConnIds)) entry.routeAttemptConnIds = [];
2241
+ if (entry.lastPushConnId && !entry.routeAttemptConnIds.includes(entry.lastPushConnId)) {
2242
+ entry.routeAttemptConnIds.push(entry.lastPushConnId);
2243
+ }
1370
2244
  this.outbox.set(entry.messageId, entry);
2245
+ this.logInfo('outbox push ok', `mid=${entry.messageId}|q=${this.outbox.size}`);
1371
2246
  this.logInfo(
1372
2247
  'outbox',
1373
2248
  `push-ok ${JSON.stringify({
@@ -1388,6 +2263,7 @@ class BncrBridgeRuntime {
1388
2263
  } catch (error) {
1389
2264
  entry.lastError = asString((error as any)?.message || error || 'push-error');
1390
2265
  this.outbox.set(entry.messageId, entry);
2266
+ this.logInfo('outbox push fail', `mid=${entry.messageId}|q=${this.outbox.size}|err=${entry.lastError}`);
1391
2267
  this.logInfo(
1392
2268
  'outbox',
1393
2269
  `push-fail ${JSON.stringify({
@@ -1406,7 +2282,7 @@ class BncrBridgeRuntime {
1406
2282
  const delay = Math.max(0, Math.min(Number(delayMs || 0), 30_000));
1407
2283
  this.pushTimer = setTimeout(() => {
1408
2284
  this.pushTimer = null;
1409
- void this.flushPushQueue();
2285
+ void this.flushPushQueue({ trigger: 'timer', reason: 'scheduled-drain' });
1410
2286
  }, delay);
1411
2287
  }
1412
2288
 
@@ -1446,8 +2322,14 @@ class BncrBridgeRuntime {
1446
2322
  };
1447
2323
  }
1448
2324
 
1449
- private async flushPushQueue(accountId?: string): Promise<void> {
1450
- const filterAcc = accountId ? normalizeAccountId(accountId) : null;
2325
+ private async flushPushQueue(args?: {
2326
+ accountId?: string;
2327
+ trigger?: string;
2328
+ reason?: string;
2329
+ }): Promise<void> {
2330
+ const filterAcc = args?.accountId ? normalizeAccountId(args.accountId) : null;
2331
+ const trigger = asString(args?.trigger || '').trim() || 'manual';
2332
+ const reason = asString(args?.reason || '').trim() || undefined;
1451
2333
  const targetAccounts = filterAcc
1452
2334
  ? [filterAcc]
1453
2335
  : Array.from(
@@ -1462,6 +2344,8 @@ class BncrBridgeRuntime {
1462
2344
  accountId: filterAcc,
1463
2345
  targetAccounts,
1464
2346
  outboxSize: this.outbox.size,
2347
+ trigger,
2348
+ reason,
1465
2349
  })}`,
1466
2350
  { debugOnly: true },
1467
2351
  );
@@ -1507,8 +2391,9 @@ class BncrBridgeRuntime {
1507
2391
  break;
1508
2392
  }
1509
2393
 
1510
- const onlineNow = this.isOnline(acc) || this.hasRecentInboundReachability(acc);
1511
- const pushed = this.tryPushEntry(entry);
2394
+ const onlineNow = this.isOnline(acc);
2395
+ const recentInboundReachable = this.hasRecentInboundReachability(acc);
2396
+ const pushed = await this.tryPushEntry(entry);
1512
2397
  if (pushed) {
1513
2398
  const requireAck = this.isOutboundAckRequired(acc);
1514
2399
  let ackResult: 'acked' | 'timeout' = requireAck ? 'timeout' : 'acked';
@@ -1524,6 +2409,7 @@ class BncrBridgeRuntime {
1524
2409
  requireAck,
1525
2410
  ackResult,
1526
2411
  onlineNow,
2412
+ recentInboundReachable,
1527
2413
  })}`,
1528
2414
  { debugOnly: true },
1529
2415
  );
@@ -1538,19 +2424,72 @@ class BncrBridgeRuntime {
1538
2424
  continue;
1539
2425
  }
1540
2426
 
2427
+ if (entry.lastPushConnId || entry.lastPushClientId) {
2428
+ this.degradeOutboundCapability({
2429
+ accountId: acc,
2430
+ connId: entry.lastPushConnId || undefined,
2431
+ clientId: entry.lastPushClientId || undefined,
2432
+ reason: requireAck ? 'ack-timeout' : 'push-unconfirmed',
2433
+ });
2434
+ }
2435
+
2436
+ const attemptedConnIds = Array.isArray(entry.routeAttemptConnIds)
2437
+ ? entry.routeAttemptConnIds.filter((v): v is string => typeof v === 'string' && !!v)
2438
+ : [];
2439
+ const currentConnId = asString(entry.lastPushConnId || '').trim();
2440
+ if (currentConnId && !attemptedConnIds.includes(currentConnId)) attemptedConnIds.push(currentConnId);
2441
+ const availableConnIds = Array.from(this.resolvePushConnIds(acc));
2442
+ const revalidatedConnIds = attemptedConnIds.filter((connId) =>
2443
+ this.isRevalidatedAttemptedConn(entry, connId),
2444
+ );
2445
+ const hasUntriedAlternative = availableConnIds.some((connId) => !attemptedConnIds.includes(connId));
2446
+ const shouldFastReroute = requireAck && entry.fastReroutePending !== true && hasUntriedAlternative;
2447
+
2448
+ entry.routeAttemptConnIds = attemptedConnIds;
2449
+ if (shouldFastReroute) entry.fastReroutePending = true;
2450
+
1541
2451
  entry.retryCount += 1;
1542
2452
  entry.lastAttemptAt = now();
1543
2453
  if (entry.retryCount > MAX_RETRY) {
2454
+ this.logInfo(
2455
+ 'outbox ack fatal',
2456
+ `mid=${entry.messageId}|q=${this.outbox.size}|err=${entry.lastError || (requireAck ? 'push-ack-timeout' : 'push-delivery-unconfirmed')}`,
2457
+ );
1544
2458
  this.moveToDeadLetter(
1545
2459
  entry,
1546
2460
  entry.lastError || (requireAck ? 'push-ack-timeout' : 'push-delivery-unconfirmed'),
1547
2461
  );
1548
2462
  continue;
1549
2463
  }
1550
- entry.nextAttemptAt = now() + backoffMs(entry.retryCount);
2464
+ entry.nextAttemptAt = shouldFastReroute ? now() + 1_000 : now() + backoffMs(entry.retryCount);
1551
2465
  entry.lastError = requireAck ? 'push-ack-timeout' : 'push-delivery-unconfirmed';
2466
+ if (!hasUntriedAlternative) {
2467
+ entry.routeAttemptConnIds = [];
2468
+ entry.routeAttemptRound = Number(entry.routeAttemptRound || 0) + 1;
2469
+ entry.fastReroutePending = false;
2470
+ }
1552
2471
  this.outbox.set(entry.messageId, entry);
1553
2472
  this.scheduleSave();
2473
+ this.logInfo(
2474
+ requireAck ? 'outbox ack timeout' : 'outbox ack retry',
2475
+ `mid=${entry.messageId}|q=${this.outbox.size}${requireAck ? '' : `|err=${entry.lastError}`}`,
2476
+ );
2477
+ this.logInfo(
2478
+ 'outbox',
2479
+ `retry-reroute ${JSON.stringify({
2480
+ messageId: entry.messageId,
2481
+ accountId: acc,
2482
+ currentConnId,
2483
+ attemptedConnIds,
2484
+ availableConnIds,
2485
+ revalidatedConnIds,
2486
+ hasUntriedAlternative,
2487
+ shouldFastReroute,
2488
+ routeAttemptRound: entry.routeAttemptRound || 0,
2489
+ nextAttemptAt: entry.nextAttemptAt,
2490
+ })}`,
2491
+ { debugOnly: true },
2492
+ );
1554
2493
 
1555
2494
  const wait = Math.max(0, entry.nextAttemptAt - now());
1556
2495
  localNextDelay = localNextDelay == null ? wait : Math.min(localNextDelay, wait);
@@ -1558,6 +2497,11 @@ class BncrBridgeRuntime {
1558
2497
  break;
1559
2498
  }
1560
2499
 
2500
+ if (!this.outbox.has(entry.messageId)) {
2501
+ await this.sleepMs(PUSH_DRAIN_INTERVAL_MS);
2502
+ continue;
2503
+ }
2504
+
1561
2505
  const nextAttempt = entry.retryCount + 1;
1562
2506
  if (nextAttempt > MAX_RETRY) {
1563
2507
  this.moveToDeadLetter(entry, entry.lastError || 'push-retry-limit');
@@ -1650,6 +2594,9 @@ class BncrBridgeRuntime {
1650
2594
  for (const [id, st] of this.fileRecvTransfers.entries()) {
1651
2595
  if (t - st.startedAt > FILE_TRANSFER_KEEP_MS) this.fileRecvTransfers.delete(id);
1652
2596
  }
2597
+ for (const [key, ack] of this.earlyFileAcks.entries()) {
2598
+ if (t - ack.at > FILE_TRANSFER_ACK_TTL_MS) this.earlyFileAcks.delete(key);
2599
+ }
1653
2600
  }
1654
2601
 
1655
2602
  private markSeen(accountId: string, connId: string, clientId?: string) {
@@ -1659,43 +2606,283 @@ class BncrBridgeRuntime {
1659
2606
  const key = this.connectionKey(acc, clientId);
1660
2607
  const t = now();
1661
2608
  const prev = this.connections.get(key);
2609
+ const previousActiveKey = this.activeConnectionByAccount.get(acc) || null;
2610
+ const previousActiveConn = previousActiveKey ? this.connections.get(previousActiveKey) || null : null;
1662
2611
 
1663
- const nextConn: BncrConnection = {
2612
+ const nextConn = {
1664
2613
  accountId: acc,
1665
2614
  connId,
1666
2615
  clientId: asString(clientId || '').trim() || undefined,
1667
2616
  connectedAt: prev?.connectedAt || t,
1668
2617
  lastSeenAt: t,
2618
+ outboundReadyUntil: (prev as any)?.outboundReadyUntil,
2619
+ preferredForOutboundUntil: (prev as any)?.preferredForOutboundUntil,
2620
+ inboundOnly: (prev as any)?.inboundOnly,
2621
+ } as BncrConnection & {
2622
+ outboundReadyUntil?: number;
2623
+ preferredForOutboundUntil?: number;
2624
+ inboundOnly?: boolean;
2625
+ };
2626
+
2627
+ this.connections.set(key, nextConn as BncrConnection);
2628
+ const connectionSeenPayload = {
2629
+ bridge: this.bridgeId,
2630
+ accountId: acc,
2631
+ connId,
2632
+ clientId: nextConn.clientId,
2633
+ connectedAt: nextConn.connectedAt,
2634
+ lastSeenAt: nextConn.lastSeenAt,
2635
+ outboundReadyUntil: nextConn.outboundReadyUntil || null,
2636
+ preferredForOutboundUntil: nextConn.preferredForOutboundUntil || null,
2637
+ inboundOnly: nextConn.inboundOnly === true,
2638
+ };
2639
+ const connectionSeenSig = JSON.stringify({
2640
+ bridge: this.bridgeId,
2641
+ accountId: acc,
2642
+ connId,
2643
+ clientId: nextConn.clientId || null,
2644
+ inboundOnly: nextConn.inboundOnly === true,
2645
+ outboundReadyActive: Number(nextConn.outboundReadyUntil || 0) > t,
2646
+ preferredForOutboundActive: Number(nextConn.preferredForOutboundUntil || 0) > t,
2647
+ });
2648
+ this.logInfoDedupJson('connection', 'seen', connectionSeenPayload, {
2649
+ key: `connection-seen:${acc}:${nextConn.clientId || connId}`,
2650
+ sig: connectionSeenSig,
2651
+ debugOnly: true,
2652
+ });
2653
+
2654
+ const current = this.activeConnectionByAccount.get(acc);
2655
+ if (!current) {
2656
+ this.activeConnectionByAccount.set(acc, key);
2657
+ this.logInfo(
2658
+ 'connection',
2659
+ `seen:promote ${JSON.stringify({
2660
+ bridge: this.bridgeId,
2661
+ accountId: acc,
2662
+ reason: 'no-current-active',
2663
+ previousActiveKey,
2664
+ previousActiveConn,
2665
+ nextActiveKey: key,
2666
+ nextActiveConn: nextConn,
2667
+ activeConnections: Array.from(this.connections.values())
2668
+ .filter((c) => c.accountId === acc)
2669
+ .map((c) => ({
2670
+ connId: c.connId,
2671
+ clientId: c.clientId,
2672
+ connectedAt: c.connectedAt,
2673
+ lastSeenAt: c.lastSeenAt,
2674
+ outboundReadyUntil: (c as any).outboundReadyUntil || null,
2675
+ preferredForOutboundUntil: (c as any).preferredForOutboundUntil || null,
2676
+ inboundOnly: (c as any).inboundOnly === true,
2677
+ })),
2678
+ })}`,
2679
+ { debugOnly: true },
2680
+ );
2681
+ return;
2682
+ }
2683
+
2684
+ const curConn = this.connections.get(current);
2685
+ if (!curConn || t - curConn.lastSeenAt > CONNECT_TTL_MS) {
2686
+ this.activeConnectionByAccount.set(acc, key);
2687
+ this.logInfo(
2688
+ 'connection',
2689
+ `seen:promote ${JSON.stringify({
2690
+ bridge: this.bridgeId,
2691
+ accountId: acc,
2692
+ reason: !curConn ? 'current-missing' : 'current-stale',
2693
+ previousActiveKey,
2694
+ previousActiveConn,
2695
+ nextActiveKey: key,
2696
+ nextActiveConn: nextConn,
2697
+ activeConnections: Array.from(this.connections.values())
2698
+ .filter((c) => c.accountId === acc)
2699
+ .map((c) => ({
2700
+ connId: c.connId,
2701
+ clientId: c.clientId,
2702
+ connectedAt: c.connectedAt,
2703
+ lastSeenAt: c.lastSeenAt,
2704
+ outboundReadyUntil: (c as any).outboundReadyUntil || null,
2705
+ preferredForOutboundUntil: (c as any).preferredForOutboundUntil || null,
2706
+ inboundOnly: (c as any).inboundOnly === true,
2707
+ })),
2708
+ })}`,
2709
+ { debugOnly: true },
2710
+ );
2711
+ }
2712
+ }
2713
+
2714
+ private markOutboundCapability(args: {
2715
+ accountId: string;
2716
+ connId: string;
2717
+ clientId?: string;
2718
+ outboundReady?: boolean;
2719
+ preferredForOutbound?: boolean;
2720
+ inboundOnly?: boolean;
2721
+ at?: number;
2722
+ }) {
2723
+ const acc = normalizeAccountId(args.accountId);
2724
+ const key = this.connectionKey(acc, args.clientId);
2725
+ const t = Number(args.at || now());
2726
+ const current = this.connections.get(key) as (BncrConnection & {
2727
+ outboundReadyUntil?: number;
2728
+ preferredForOutboundUntil?: number;
2729
+ inboundOnly?: boolean;
2730
+ }) | undefined;
2731
+ if (!current || current.connId !== args.connId) return;
2732
+
2733
+ if (args.inboundOnly === true) {
2734
+ current.inboundOnly = true;
2735
+ current.outboundReadyUntil = undefined;
2736
+ current.preferredForOutboundUntil = undefined;
2737
+ } else {
2738
+ if (typeof args.inboundOnly === 'boolean') current.inboundOnly = false;
2739
+ if (args.outboundReady === true || args.preferredForOutbound === true) {
2740
+ current.outboundReadyUntil = t + OUTBOUND_READY_TTL_MS;
2741
+ }
2742
+ if (args.preferredForOutbound === true) {
2743
+ current.preferredForOutboundUntil = t + PREFERRED_OUTBOUND_TTL_MS;
2744
+ }
2745
+ }
2746
+
2747
+ this.connections.set(key, current as BncrConnection);
2748
+ const connectionCapabilityPayload = {
2749
+ bridge: this.bridgeId,
2750
+ accountId: acc,
2751
+ connId: current.connId,
2752
+ clientId: current.clientId,
2753
+ outboundReady: args.outboundReady === true,
2754
+ preferredForOutbound: args.preferredForOutbound === true,
2755
+ inboundOnly: current.inboundOnly === true,
2756
+ outboundReadyUntil: current.outboundReadyUntil || null,
2757
+ preferredForOutboundUntil: current.preferredForOutboundUntil || null,
2758
+ };
2759
+ const connectionCapabilitySig = JSON.stringify({
2760
+ bridge: this.bridgeId,
2761
+ accountId: acc,
2762
+ connId: current.connId,
2763
+ clientId: current.clientId || null,
2764
+ outboundReady: args.outboundReady === true,
2765
+ preferredForOutbound: args.preferredForOutbound === true,
2766
+ inboundOnly: current.inboundOnly === true,
2767
+ outboundReadyActive: Number(current.outboundReadyUntil || 0) > t,
2768
+ preferredForOutboundActive: Number(current.preferredForOutboundUntil || 0) > t,
2769
+ });
2770
+ this.logInfoDedupJson('connection', 'capability', connectionCapabilityPayload, {
2771
+ key: `connection-capability:${acc}:${current.clientId || current.connId}`,
2772
+ sig: connectionCapabilitySig,
2773
+ debugOnly: true,
2774
+ });
2775
+ }
2776
+
2777
+ private hasAlternativeLiveConnection(
2778
+ accountId: string,
2779
+ currentConnId?: string,
2780
+ currentClientId?: string,
2781
+ ): boolean {
2782
+ const acc = normalizeAccountId(accountId);
2783
+ const t = now();
2784
+ const currentConn = asString(currentConnId || '').trim();
2785
+ const currentClient = asString(currentClientId || '').trim() || undefined;
2786
+
2787
+ for (const conn of this.connections.values()) {
2788
+ if (conn.accountId !== acc) continue;
2789
+ if (!conn.connId) continue;
2790
+ if (t - conn.lastSeenAt > CONNECT_TTL_MS) continue;
2791
+ const sameConn = !!currentConn && conn.connId === currentConn;
2792
+ const sameClient = !currentConn && !!currentClient && conn.clientId === currentClient;
2793
+ if (sameConn || sameClient) continue;
2794
+ return true;
2795
+ }
2796
+ return false;
2797
+ }
2798
+
2799
+ private degradeOutboundCapability(args: {
2800
+ accountId: string;
2801
+ connId?: string;
2802
+ clientId?: string;
2803
+ reason: string;
2804
+ at?: number;
2805
+ }) {
2806
+ const acc = normalizeAccountId(args.accountId);
2807
+ const t = Number(args.at || now());
2808
+ const hasAlternativeLiveConnection = this.hasAlternativeLiveConnection(
2809
+ acc,
2810
+ args.connId,
2811
+ args.clientId,
2812
+ );
2813
+ const currentKey = this.activeConnectionByAccount.get(acc) || null;
2814
+ let matchedKey: string | null = null;
2815
+ let matchedConn: (BncrConnection & {
2816
+ outboundReadyUntil?: number;
2817
+ preferredForOutboundUntil?: number;
2818
+ inboundOnly?: boolean;
2819
+ }) | null = null;
2820
+
2821
+ for (const [key, conn] of this.connections.entries()) {
2822
+ if (conn.accountId !== acc) continue;
2823
+ if (args.connId && conn.connId !== args.connId) continue;
2824
+ if (args.clientId && conn.clientId !== args.clientId) continue;
2825
+ matchedKey = key;
2826
+ matchedConn = conn as BncrConnection & {
2827
+ outboundReadyUntil?: number;
2828
+ preferredForOutboundUntil?: number;
2829
+ inboundOnly?: boolean;
2830
+ };
2831
+ break;
2832
+ }
2833
+
2834
+ if (!matchedKey || !matchedConn) return;
2835
+
2836
+ const before = {
2837
+ outboundReadyUntil: matchedConn.outboundReadyUntil || null,
2838
+ preferredForOutboundUntil: matchedConn.preferredForOutboundUntil || null,
2839
+ inboundOnly: matchedConn.inboundOnly === true,
1669
2840
  };
1670
2841
 
1671
- this.connections.set(key, nextConn);
2842
+ if (!hasAlternativeLiveConnection) {
2843
+ this.logInfo(
2844
+ 'connection',
2845
+ `outbound-degrade skip ${JSON.stringify({
2846
+ bridge: this.bridgeId,
2847
+ accountId: acc,
2848
+ connId: matchedConn.connId,
2849
+ clientId: matchedConn.clientId,
2850
+ reason: args.reason,
2851
+ at: t,
2852
+ currentActiveKey: currentKey,
2853
+ degradedKey: matchedKey,
2854
+ skipReason: 'no-alternative-live-connection',
2855
+ before,
2856
+ })}`,
2857
+ { debugOnly: true },
2858
+ );
2859
+ return;
2860
+ }
2861
+
2862
+ matchedConn.outboundReadyUntil = undefined;
2863
+ matchedConn.preferredForOutboundUntil = undefined;
2864
+ this.connections.set(matchedKey, matchedConn as BncrConnection);
2865
+
1672
2866
  this.logInfo(
1673
2867
  'connection',
1674
- `seen ${JSON.stringify({
2868
+ `outbound-degrade ${JSON.stringify({
1675
2869
  bridge: this.bridgeId,
1676
2870
  accountId: acc,
1677
- connId,
1678
- clientId: nextConn.clientId,
1679
- connectedAt: nextConn.connectedAt,
1680
- lastSeenAt: nextConn.lastSeenAt,
2871
+ connId: matchedConn.connId,
2872
+ clientId: matchedConn.clientId,
2873
+ reason: args.reason,
2874
+ at: t,
2875
+ currentActiveKey: currentKey,
2876
+ degradedKey: matchedKey,
2877
+ before,
2878
+ after: {
2879
+ outboundReadyUntil: matchedConn.outboundReadyUntil || null,
2880
+ preferredForOutboundUntil: matchedConn.preferredForOutboundUntil || null,
2881
+ inboundOnly: matchedConn.inboundOnly === true,
2882
+ },
1681
2883
  })}`,
1682
2884
  { debugOnly: true },
1683
2885
  );
1684
-
1685
- const current = this.activeConnectionByAccount.get(acc);
1686
- if (!current) {
1687
- this.activeConnectionByAccount.set(acc, key);
1688
- return;
1689
- }
1690
-
1691
- const curConn = this.connections.get(current);
1692
- if (
1693
- !curConn ||
1694
- t - curConn.lastSeenAt > CONNECT_TTL_MS ||
1695
- nextConn.connectedAt >= curConn.connectedAt
1696
- ) {
1697
- this.activeConnectionByAccount.set(acc, key);
1698
- }
1699
2886
  }
1700
2887
 
1701
2888
  private isOnline(accountId: string): boolean {
@@ -1858,9 +3045,61 @@ class BncrBridgeRuntime {
1858
3045
  Math.min(Number(params.timeoutMs || FILE_ACK_TIMEOUT_MS), 120_000),
1859
3046
  );
1860
3047
 
3048
+ const cached = this.earlyFileAcks.get(key);
3049
+ if (cached) {
3050
+ this.earlyFileAcks.delete(key);
3051
+ this.logInfo(
3052
+ 'file-ack-cache-hit',
3053
+ JSON.stringify({
3054
+ bridge: this.bridgeId,
3055
+ transferId,
3056
+ stage,
3057
+ chunkIndex:
3058
+ Number.isFinite(Number(params.chunkIndex)) ? Number(params.chunkIndex) : undefined,
3059
+ key,
3060
+ ok: cached.ok,
3061
+ payload: cached.payload,
3062
+ }),
3063
+ { debugOnly: true },
3064
+ );
3065
+ if (cached.ok) return Promise.resolve(cached.payload);
3066
+ return Promise.reject(
3067
+ new Error(
3068
+ asString(cached.payload?.errorMessage || cached.payload?.error || 'file ack failed'),
3069
+ ),
3070
+ );
3071
+ }
3072
+
3073
+ this.logInfo(
3074
+ 'file-ack-wait',
3075
+ JSON.stringify({
3076
+ bridge: this.bridgeId,
3077
+ transferId,
3078
+ stage,
3079
+ chunkIndex:
3080
+ Number.isFinite(Number(params.chunkIndex)) ? Number(params.chunkIndex) : undefined,
3081
+ key,
3082
+ timeoutMs,
3083
+ }),
3084
+ { debugOnly: true },
3085
+ );
3086
+
1861
3087
  return new Promise<Record<string, unknown>>((resolve, reject) => {
1862
3088
  const timer = setTimeout(() => {
1863
3089
  this.fileAckWaiters.delete(key);
3090
+ this.logWarn(
3091
+ 'file-ack-timeout',
3092
+ JSON.stringify({
3093
+ bridge: this.bridgeId,
3094
+ transferId,
3095
+ stage,
3096
+ chunkIndex:
3097
+ Number.isFinite(Number(params.chunkIndex)) ? Number(params.chunkIndex) : undefined,
3098
+ key,
3099
+ timeoutMs,
3100
+ }),
3101
+ { debugOnly: true },
3102
+ );
1864
3103
  reject(new Error(`file ack timeout: ${key}`));
1865
3104
  }, timeoutMs);
1866
3105
  this.fileAckWaiters.set(key, { resolve, reject, timer });
@@ -1878,9 +3117,45 @@ class BncrBridgeRuntime {
1878
3117
  const stage = asString(params.stage).trim();
1879
3118
  const key = this.fileAckKey(transferId, stage, params.chunkIndex);
1880
3119
  const waiter = this.fileAckWaiters.get(key);
1881
- if (!waiter) return false;
3120
+ if (!waiter) {
3121
+ this.earlyFileAcks.set(key, {
3122
+ payload: params.payload,
3123
+ ok: params.ok,
3124
+ at: now(),
3125
+ });
3126
+ this.logInfo(
3127
+ 'file-ack-early-cache',
3128
+ JSON.stringify({
3129
+ bridge: this.bridgeId,
3130
+ transferId,
3131
+ stage,
3132
+ chunkIndex:
3133
+ Number.isFinite(Number(params.chunkIndex)) ? Number(params.chunkIndex) : undefined,
3134
+ key,
3135
+ ok: params.ok,
3136
+ payload: params.payload,
3137
+ cached: true,
3138
+ }),
3139
+ { debugOnly: true },
3140
+ );
3141
+ return false;
3142
+ }
1882
3143
  this.fileAckWaiters.delete(key);
1883
3144
  clearTimeout(waiter.timer);
3145
+ this.logInfo(
3146
+ 'file-ack-resolve',
3147
+ JSON.stringify({
3148
+ bridge: this.bridgeId,
3149
+ transferId,
3150
+ stage,
3151
+ chunkIndex:
3152
+ Number.isFinite(Number(params.chunkIndex)) ? Number(params.chunkIndex) : undefined,
3153
+ key,
3154
+ ok: params.ok,
3155
+ payload: params.payload,
3156
+ }),
3157
+ { debugOnly: true },
3158
+ );
1884
3159
  if (params.ok) waiter.resolve(params.payload);
1885
3160
  else
1886
3161
  waiter.reject(
@@ -2254,9 +3529,46 @@ class BncrBridgeRuntime {
2254
3529
  }
2255
3530
 
2256
3531
  const ctx = this.gatewayContext;
3532
+ const owner = this.resolveOutboxPushOwner(params.accountId);
3533
+ const recentInboundReachable = this.hasRecentInboundReachability(params.accountId);
3534
+ const directConnIds = this.resolvePushConnIds(params.accountId);
3535
+ const recentConnIds = recentInboundReachable
3536
+ ? this.resolveRecentInboundConnIds(params.accountId)
3537
+ : new Set<string>();
3538
+ const accountId = normalizeAccountId(params.accountId);
3539
+ const activeConnectionKey = this.activeConnectionByAccount.get(accountId) || null;
3540
+ const accountConnections = Array.from(this.connections.values())
3541
+ .filter((c) => c.accountId === accountId)
3542
+ .map((c) => ({
3543
+ connId: c.connId,
3544
+ clientId: c.clientId,
3545
+ connectedAt: c.connectedAt,
3546
+ lastSeenAt: c.lastSeenAt,
3547
+ }));
3548
+ this.logInfo(
3549
+ 'file-chunk-diag',
3550
+ JSON.stringify({
3551
+ bridge: this.bridgeId,
3552
+ accountId,
3553
+ sessionKey: params.sessionKey,
3554
+ mediaUrl: params.mediaUrl,
3555
+ hasGatewayContext: Boolean(ctx),
3556
+ activeConnectionKey,
3557
+ ownerConnId: owner?.connId || null,
3558
+ ownerClientId: owner?.clientId || null,
3559
+ directConnIds: Array.from(directConnIds),
3560
+ recentInboundReachable,
3561
+ recentConnIds: Array.from(recentConnIds),
3562
+ accountConnections,
3563
+ }),
3564
+ { debugOnly: true },
3565
+ );
2257
3566
  if (!ctx) throw new Error('gateway context unavailable');
2258
3567
 
2259
- const connIds = this.resolvePushConnIds(params.accountId);
3568
+ let connIds = directConnIds;
3569
+ if (!connIds.size && recentInboundReachable) {
3570
+ connIds = recentConnIds;
3571
+ }
2260
3572
  if (!connIds.size) throw new Error('no active bncr client for file chunk transfer');
2261
3573
 
2262
3574
  const transferId = randomUUID();
@@ -2264,7 +3576,26 @@ class BncrBridgeRuntime {
2264
3576
  const totalChunks = Math.ceil(size / chunkSize);
2265
3577
  const fileSha256 = createHash('sha256').update(loaded.buffer).digest('hex');
2266
3578
 
2267
- const owner = this.resolveOutboxPushOwner(params.accountId);
3579
+ this.logInfo(
3580
+ 'file-transfer-start',
3581
+ JSON.stringify({
3582
+ bridge: this.bridgeId,
3583
+ transferId,
3584
+ accountId,
3585
+ sessionKey: params.sessionKey,
3586
+ mediaUrl: params.mediaUrl,
3587
+ fileName,
3588
+ mimeType,
3589
+ fileSize: size,
3590
+ chunkSize,
3591
+ totalChunks,
3592
+ connIds: Array.from(connIds),
3593
+ ownerConnId: owner?.connId || null,
3594
+ ownerClientId: owner?.clientId || null,
3595
+ }),
3596
+ { debugOnly: true },
3597
+ );
3598
+
2268
3599
  const st: FileSendTransferState = {
2269
3600
  transferId,
2270
3601
  accountId: normalizeAccountId(params.accountId),
@@ -2329,16 +3660,54 @@ class BncrBridgeRuntime {
2329
3660
  connIds,
2330
3661
  );
2331
3662
 
3663
+ this.logInfo(
3664
+ 'file-transfer-chunk-send',
3665
+ JSON.stringify({
3666
+ bridge: this.bridgeId,
3667
+ transferId,
3668
+ accountId,
3669
+ chunkIndex: idx,
3670
+ attempt,
3671
+ offset: start,
3672
+ size: slice.byteLength,
3673
+ connIds: Array.from(connIds),
3674
+ }),
3675
+ { debugOnly: true },
3676
+ );
3677
+
2332
3678
  try {
2333
3679
  await this.waitChunkAck({
2334
3680
  transferId,
2335
3681
  chunkIndex: idx,
2336
3682
  timeoutMs: FILE_TRANSFER_ACK_TTL_MS,
2337
3683
  });
3684
+ this.logInfo(
3685
+ 'file-transfer-chunk-ack',
3686
+ JSON.stringify({
3687
+ bridge: this.bridgeId,
3688
+ transferId,
3689
+ accountId,
3690
+ chunkIndex: idx,
3691
+ attempt,
3692
+ }),
3693
+ { debugOnly: true },
3694
+ );
2338
3695
  ok = true;
2339
3696
  break;
2340
3697
  } catch (err) {
2341
3698
  lastErr = err;
3699
+ this.logWarn(
3700
+ 'file-transfer-chunk-ack-fail',
3701
+ JSON.stringify({
3702
+ bridge: this.bridgeId,
3703
+ transferId,
3704
+ accountId,
3705
+ chunkIndex: idx,
3706
+ attempt,
3707
+ error: asString((err as Error)?.message || err),
3708
+ }),
3709
+ { debugOnly: true },
3710
+ );
2342
3711
  await this.sleepMs(150 * attempt);
2343
3712
  }
2344
3713
  }
@@ -2369,8 +3738,30 @@ class BncrBridgeRuntime {
2369
3738
  connIds,
2370
3739
  );
2371
3740
 
3741
+ this.logInfo(
3742
+ 'file-transfer-complete-send',
3743
+ JSON.stringify({
3744
+ bridge: this.bridgeId,
3745
+ transferId,
3746
+ accountId,
3747
+ connIds: Array.from(connIds),
3748
+ }),
3749
+ { debugOnly: true },
3750
+ );
3751
+
2372
3752
  const done = await this.waitCompleteAck({ transferId, timeoutMs: 60_000 });
2373
3753
 
3754
+ this.logInfo(
3755
+ 'file-transfer-complete-ack',
3756
+ JSON.stringify({
3757
+ bridge: this.bridgeId,
3758
+ transferId,
3759
+ accountId,
3760
+ payload: done,
3761
+ }),
3762
+ { debugOnly: true },
3763
+ );
3764
+
2374
3765
  return {
2375
3766
  mode: 'chunk',
2376
3767
  mimeType,
@@ -2396,6 +3787,29 @@ class BncrBridgeRuntime {
2396
3787
  }) {
2397
3788
  const { accountId, sessionKey, route, payload, mediaLocalRoots } = params;
2398
3789
 
3790
+ this.logInfo(
3791
+ 'outbound',
3792
+ `enqueue-from-reply ${JSON.stringify({
3793
+ accountId,
3794
+ sessionKey,
3795
+ route: {
3796
+ platform: route?.platform,
3797
+ groupId: route?.groupId,
3798
+ userId: route?.userId,
3799
+ },
3800
+ payload: {
3801
+ text: asString(payload?.text || ''),
3802
+ mediaUrl: asString(payload?.mediaUrl || ''),
3803
+ mediaUrls: Array.isArray(payload?.mediaUrls) ? payload.mediaUrls : undefined,
3804
+ asVoice: payload?.asVoice === true,
3805
+ audioAsVoice: payload?.audioAsVoice === true,
3806
+ kind: payload?.kind,
3807
+ replyToId: asString(payload?.replyToId || ''),
3808
+ },
3809
+ })}`,
3810
+ { debugOnly: true },
3811
+ );
3812
+
2399
3813
  const mediaList = payload.mediaUrls?.length
2400
3814
  ? payload.mediaUrls
2401
3815
  : payload.mediaUrl
@@ -2404,44 +3818,67 @@ class BncrBridgeRuntime {
2404
3818
 
2405
3819
  if (mediaList.length > 0) {
2406
3820
  let first = true;
3821
+ const currentTime = now();
2407
3822
  for (const mediaUrl of mediaList) {
2408
- const media = await this.transferMediaToBncrClient({
2409
- accountId,
2410
- sessionKey,
2411
- route,
2412
- mediaUrl,
2413
- mediaLocalRoots,
2414
- });
2415
- const messageId = randomUUID();
2416
- const mediaMsg = first ? asString(payload.text || '') : '';
2417
- const wantsVoice = payload.asVoice === true || payload.audioAsVoice === true;
2418
- const frame = buildBncrMediaOutboundFrame({
2419
- messageId,
3823
+ const normalizedMediaUrl = asString(mediaUrl || '').trim();
3824
+ if (!normalizedMediaUrl) continue;
3825
+
3826
+ const normalizedText = normalizeMessageText(first ? payload.text : '');
3827
+ const normalizedReplyToId = normalizeReplyToId(payload.replyToId);
3828
+ const fallback = this.tryBuildMediaDedupeFallback({
2420
3829
  sessionKey,
2421
- route,
2422
- media,
2423
- mediaUrl,
2424
- mediaMsg,
2425
- fileName: resolveOutboundFileName({
2426
- mediaUrl,
2427
- fileName: media.fileName,
2428
- mimeType: media.mimeType,
2429
- }),
2430
- hintedType: wantsVoice ? 'voice' : undefined,
2431
- kind: payload.kind,
2432
- replyToId: asString(payload.replyToId || '').trim() || undefined,
2433
- now: now(),
3830
+ mediaUrl: normalizedMediaUrl,
3831
+ text: normalizedText,
3832
+ replyToId: normalizedReplyToId,
3833
+ currentTime,
2434
3834
  });
2435
3835
 
2436
- this.enqueueOutbound({
2437
- messageId,
2438
- accountId: normalizeAccountId(accountId),
3836
+ if (fallback !== null) {
3837
+ this.logInfo(
3838
+ 'outbound',
3839
+ `media-dedupe-hit ${JSON.stringify({
3840
+ sessionKey,
3841
+ mediaUrl: normalizedMediaUrl,
3842
+ replyToId: normalizedReplyToId || undefined,
3843
+ fallbackText: fallback.text,
3844
+ reason: fallback.reason,
3845
+ })}`,
3846
+ { debugOnly: true },
3847
+ );
3848
+ this.enqueueOutbound(
3849
+ this.buildTextOutboxEntry({
3850
+ accountId,
3851
+ sessionKey,
3852
+ route,
3853
+ text: fallback.text,
3854
+ kind: payload.kind,
3855
+ replyToId: normalizedReplyToId || undefined,
3856
+ }),
3857
+ );
3858
+ first = false;
3859
+ continue;
3860
+ }
3861
+
3862
+ this.enqueueOutbound(
3863
+ this.buildFileTransferOutboxEntry({
3864
+ accountId,
3865
+ sessionKey,
3866
+ route,
3867
+ mediaUrl: normalizedMediaUrl,
3868
+ mediaLocalRoots,
3869
+ text: first ? asString(payload.text || '') : '',
3870
+ asVoice: payload.asVoice,
3871
+ audioAsVoice: payload.audioAsVoice,
3872
+ kind: payload.kind,
3873
+ replyToId: normalizedReplyToId || undefined,
3874
+ }),
3875
+ );
3876
+ this.rememberRecentMediaSend({
2439
3877
  sessionKey,
2440
- route,
2441
- payload: frame,
2442
- createdAt: now(),
2443
- retryCount: 0,
2444
- nextAttemptAt: now(),
3878
+ mediaUrl: normalizedMediaUrl,
3879
+ text: normalizedText,
3880
+ replyToId: normalizedReplyToId,
3881
+ createdAt: currentTime,
2445
3882
  });
2446
3883
  first = false;
2447
3884
  }
@@ -2451,37 +3888,16 @@ class BncrBridgeRuntime {
2451
3888
  const text = asString(payload.text || '').trim();
2452
3889
  if (!text) return;
2453
3890
 
2454
- const messageId = randomUUID();
2455
- const frame = {
2456
- type: 'message.outbound',
2457
- messageId,
2458
- idempotencyKey: messageId,
2459
- sessionKey,
2460
- replyToId: asString(payload.replyToId || '').trim() || undefined,
2461
- message: {
2462
- platform: route.platform,
2463
- groupId: route.groupId,
2464
- userId: route.userId,
2465
- type: 'text',
3891
+ this.enqueueOutbound(
3892
+ this.buildTextOutboxEntry({
3893
+ accountId,
3894
+ sessionKey,
3895
+ route,
3896
+ text,
2466
3897
  kind: payload.kind,
2467
- msg: text,
2468
- path: '',
2469
- base64: '',
2470
- fileName: '',
2471
- },
2472
- ts: now(),
2473
- };
2474
-
2475
- this.enqueueOutbound({
2476
- messageId,
2477
- accountId: normalizeAccountId(accountId),
2478
- sessionKey,
2479
- route,
2480
- payload: frame,
2481
- createdAt: now(),
2482
- retryCount: 0,
2483
- nextAttemptAt: now(),
2484
- });
3898
+ replyToId: asString(payload.replyToId || '').trim() || undefined,
3899
+ }),
3900
+ );
2485
3901
  }
2486
3902
 
2487
3903
  handleConnect = async ({ params, respond, client, context }: GatewayRequestHandlerOptions) => {
@@ -2489,6 +3905,9 @@ class BncrBridgeRuntime {
2489
3905
  const accountId = normalizeAccountId(asString(params?.accountId || ''));
2490
3906
  const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
2491
3907
  const clientId = asString((params as any)?.clientId || '').trim() || undefined;
3908
+ const outboundReady = (params as any)?.outboundReady === true;
3909
+ const preferredForOutbound = (params as any)?.preferredForOutbound === true;
3910
+ const inboundOnly = (params as any)?.inboundOnly === true;
2492
3911
 
2493
3912
  this.logInfo(
2494
3913
  'connection',
@@ -2497,6 +3916,9 @@ class BncrBridgeRuntime {
2497
3916
  accountId,
2498
3917
  connId,
2499
3918
  clientId,
3919
+ outboundReady,
3920
+ preferredForOutbound,
3921
+ inboundOnly,
2500
3922
  hasContext: Boolean(context),
2501
3923
  })}`,
2502
3924
  { debugOnly: true },
@@ -2504,6 +3926,14 @@ class BncrBridgeRuntime {
2504
3926
 
2505
3927
  this.rememberGatewayContext(context);
2506
3928
  this.markSeen(accountId, connId, clientId);
3929
+ this.markOutboundCapability({
3930
+ accountId,
3931
+ connId,
3932
+ clientId,
3933
+ outboundReady,
3934
+ preferredForOutbound,
3935
+ inboundOnly,
3936
+ });
2507
3937
  this.markActivity(accountId);
2508
3938
  this.incrementCounter(this.connectEventsByAccount, accountId);
2509
3939
  const lease = this.acceptConnection();
@@ -2534,7 +3964,7 @@ class BncrBridgeRuntime {
2534
3964
  });
2535
3965
 
2536
3966
  // WS 一旦在线,立即尝试把离线期间积压队列直推出去
2537
- this.flushPushQueue(accountId);
3967
+ this.flushPushQueue({ accountId, trigger: 'connect', reason: 'ws-online' });
2538
3968
  };
2539
3969
 
2540
3970
  handleAck = async ({ params, respond, client, context }: GatewayRequestHandlerOptions) => {
@@ -2600,19 +4030,29 @@ class BncrBridgeRuntime {
2600
4030
  const fatal = params?.fatal === true;
2601
4031
 
2602
4032
  if (ok) {
4033
+ this.markOutboundCapability({
4034
+ accountId,
4035
+ connId,
4036
+ clientId,
4037
+ outboundReady: true,
4038
+ preferredForOutbound: true,
4039
+ });
2603
4040
  this.outbox.delete(messageId);
2604
4041
  this.scheduleSave();
2605
4042
  this.resolveMessageAck(messageId, 'acked');
4043
+ this.logInfo('outbox ack ok', `mid=${messageId}|q=${this.outbox.size}`);
2606
4044
  respond(
2607
4045
  true,
2608
4046
  staleObserved.stale ? { ok: true, stale: true, staleAccepted: true } : { ok: true },
2609
4047
  );
2610
- this.flushPushQueue(accountId);
4048
+ this.flushPushQueue({ accountId, trigger: 'ack-ok', reason: 'message-acked' });
2611
4049
  return;
2612
4050
  }
2613
4051
 
2614
4052
  if (fatal) {
2615
- this.moveToDeadLetter(entry, asString(params?.error || 'fatal-ack'));
4053
+ const error = asString(params?.error || 'fatal-ack');
4054
+ this.moveToDeadLetter(entry, error);
4055
+ this.logInfo('outbox ack fatal', `mid=${messageId}|q=${this.outbox.size}|err=${error}`);
2616
4056
  respond(
2617
4057
  true,
2618
4058
  staleObserved.stale
@@ -2626,6 +4066,7 @@ class BncrBridgeRuntime {
2626
4066
  entry.lastError = asString(params?.error || 'retryable-ack');
2627
4067
  this.outbox.set(messageId, entry);
2628
4068
  this.scheduleSave();
4069
+ this.logInfo('outbox ack retry', `mid=${messageId}|q=${this.outbox.size}|err=${entry.lastError}`);
2629
4070
 
2630
4071
  respond(
2631
4072
  true,
@@ -2640,6 +4081,9 @@ class BncrBridgeRuntime {
2640
4081
  const accountId = normalizeAccountId(asString(params?.accountId || ''));
2641
4082
  const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
2642
4083
  const clientId = asString((params as any)?.clientId || '').trim() || undefined;
4084
+ const outboundReady = (params as any)?.outboundReady === true;
4085
+ const preferredForOutbound = (params as any)?.preferredForOutbound === true;
4086
+ const inboundOnly = (params as any)?.inboundOnly === true;
2643
4087
  if (
2644
4088
  this.shouldIgnoreStaleEvent({
2645
4089
  kind: 'activity',
@@ -2660,12 +4104,23 @@ class BncrBridgeRuntime {
2660
4104
  accountId,
2661
4105
  connId,
2662
4106
  clientId,
4107
+ outboundReady,
4108
+ preferredForOutbound,
4109
+ inboundOnly,
2663
4110
  hasContext: Boolean(context),
2664
4111
  })}`,
2665
4112
  { debugOnly: true },
2666
4113
  );
2667
4114
  this.rememberGatewayContext(context);
2668
4115
  this.markSeen(accountId, connId, clientId);
4116
+ this.markOutboundCapability({
4117
+ accountId,
4118
+ connId,
4119
+ clientId,
4120
+ outboundReady,
4121
+ preferredForOutbound,
4122
+ inboundOnly,
4123
+ });
2669
4124
  this.markActivity(accountId);
2670
4125
  this.incrementCounter(this.activityEventsByAccount, accountId);
2671
4126
 
@@ -2679,7 +4134,7 @@ class BncrBridgeRuntime {
2679
4134
  deadLetter: this.deadLetter.filter((v) => v.accountId === accountId).length,
2680
4135
  now: now(),
2681
4136
  });
2682
- this.flushPushQueue(accountId);
4137
+ this.flushPushQueue({ accountId, trigger: 'activity', reason: 'activity-heartbeat' });
2683
4138
  };
2684
4139
 
2685
4140
  handleDiagnostics = async ({ params, respond }: GatewayRequestHandlerOptions) => {
@@ -3073,6 +4528,24 @@ class BncrBridgeRuntime {
3073
4528
  const ok = params?.ok !== false;
3074
4529
  const chunkIndex = Number(params?.chunkIndex ?? -1);
3075
4530
 
4531
+ this.logInfo(
4532
+ 'file-ack-inbound',
4533
+ JSON.stringify({
4534
+ bridge: this.bridgeId,
4535
+ accountId,
4536
+ connId,
4537
+ clientId: clientId || null,
4538
+ transferId,
4539
+ stage,
4540
+ ok,
4541
+ chunkIndex: chunkIndex >= 0 ? chunkIndex : undefined,
4542
+ errorCode: asString(params?.errorCode || ''),
4543
+ errorMessage: asString(params?.errorMessage || ''),
4544
+ path: asString(params?.path || '').trim(),
4545
+ }),
4546
+ { debugOnly: true },
4547
+ );
4548
+
3076
4549
  if (!transferId || !stage) {
3077
4550
  respond(false, { error: 'transferId/stage required' });
3078
4551
  return;
@@ -3092,7 +4565,15 @@ class BncrBridgeRuntime {
3092
4565
  const sameConn = !!st?.ownerConnId && st.ownerConnId === connId;
3093
4566
  const sameClient =
3094
4567
  !st?.ownerConnId && !!st?.ownerClientId && !!clientId && st.ownerClientId === clientId;
3095
- if (!(sameConn || sameClient)) {
4568
+ const adopted =
4569
+ !(sameConn || sameClient) &&
4570
+ this.tryAdoptTransferOwner({
4571
+ accountId,
4572
+ transfer: st,
4573
+ connId,
4574
+ clientId,
4575
+ });
4576
+ if (!(sameConn || sameClient || adopted)) {
3096
4577
  this.logWarn(
3097
4578
  'stale',
3098
4579
  `ignore kind=file.ack accountId=${accountId} connId=${connId} clientId=${clientId || '-'} transferId=${transferId} stage=${stage} reason=owner-mismatch ownerConnId=${st?.ownerConnId || '-'} ownerClientId=${st?.ownerClientId || '-'}`,
@@ -3186,6 +4667,9 @@ class BncrBridgeRuntime {
3186
4667
  } = parsed;
3187
4668
  const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
3188
4669
  const clientId = asString((params as any)?.clientId || '').trim() || undefined;
4670
+ const outboundReady = (params as any)?.outboundReady === true;
4671
+ const preferredForOutbound = (params as any)?.preferredForOutbound === true;
4672
+ const inboundOnly = (params as any)?.inboundOnly === true;
3189
4673
  if (
3190
4674
  this.shouldIgnoreStaleEvent({
3191
4675
  kind: 'inbound',
@@ -3206,7 +4690,40 @@ class BncrBridgeRuntime {
3206
4690
  }
3207
4691
  this.rememberGatewayContext(context);
3208
4692
  this.markSeen(accountId, connId, clientId);
4693
+ this.markOutboundCapability({
4694
+ accountId,
4695
+ connId,
4696
+ clientId,
4697
+ outboundReady,
4698
+ preferredForOutbound,
4699
+ inboundOnly,
4700
+ });
3209
4701
  this.markActivity(accountId);
4702
+ this.logInfo(
4703
+ 'inbound',
4704
+ `lifecycle ${JSON.stringify({
4705
+ stage: 'accepted',
4706
+ bridge: this.bridgeId,
4707
+ accountId,
4708
+ connId,
4709
+ clientId,
4710
+ outboundReady,
4711
+ preferredForOutbound,
4712
+ inboundOnly,
4713
+ onlineAfterSeen: this.isOnline(accountId),
4714
+ recentInboundReachable: this.hasRecentInboundReachability(accountId),
4715
+ activeConnectionKey: this.activeConnectionByAccount.get(accountId) || null,
4716
+ activeConnections: Array.from(this.connections.values())
4717
+ .filter((c) => c.accountId === accountId)
4718
+ .map((c) => ({
4719
+ connId: c.connId,
4720
+ clientId: c.clientId,
4721
+ connectedAt: c.connectedAt,
4722
+ lastSeenAt: c.lastSeenAt,
4723
+ })),
4724
+ })}`,
4725
+ { debugOnly: true },
4726
+ );
3210
4727
  this.lastInboundAtGlobal = now();
3211
4728
  this.incrementCounter(this.inboundEventsByAccount, accountId);
3212
4729
 
@@ -3289,7 +4806,7 @@ class BncrBridgeRuntime {
3289
4806
  msgId: msgId ?? null,
3290
4807
  taskKey: extracted.taskKey ?? null,
3291
4808
  });
3292
- this.flushPushQueue(accountId);
4809
+ this.flushPushQueue({ accountId, trigger: 'inbound', reason: 'inbound-accepted' });
3293
4810
 
3294
4811
  void dispatchBncrInbound({
3295
4812
  api: this.api,
@@ -3316,11 +4833,50 @@ class BncrBridgeRuntime {
3316
4833
 
3317
4834
  channelStartAccount = async (ctx: any) => {
3318
4835
  const accountId = normalizeAccountId(ctx.accountId);
4836
+ this.clearChannelAccountWorker(accountId, 'start-replace');
3319
4837
 
3320
4838
  const tick = () => {
3321
- const connected = this.isOnline(accountId);
3322
4839
  const previous = ctx.getStatus?.() || {};
3323
- const lastActAt = this.lastActivityByAccount.get(accountId) || previous?.lastEventAt || null;
4840
+ const onlineByConn = this.isOnline(accountId);
4841
+ const recentInboundReachable = this.hasRecentInboundReachability(accountId);
4842
+ const connected = onlineByConn || recentInboundReachable;
4843
+ const lastActAt =
4844
+ this.lastActivityByAccount.get(accountId) ||
4845
+ this.lastInboundByAccount.get(accountId) ||
4846
+ this.lastOutboundByAccount.get(accountId) ||
4847
+ previous?.lastEventAt ||
4848
+ null;
4849
+ const healthSig = JSON.stringify({
4850
+ bridge: this.bridgeId,
4851
+ accountId,
4852
+ connected,
4853
+ onlineByConn,
4854
+ recentInboundReachable,
4855
+ activeConnectionKey: this.activeConnectionByAccount.get(accountId) || null,
4856
+ activeConnections: Array.from(this.connections.values())
4857
+ .filter((c) => c.accountId === accountId)
4858
+ .map((c) => ({
4859
+ connId: c.connId,
4860
+ clientId: c.clientId,
4861
+ inboundOnly: c.inboundOnly === true,
4862
+ outboundReady: c.outboundReady === true,
4863
+ preferredForOutbound: c.preferredForOutbound === true,
4864
+ })),
4865
+ });
4866
+ const conns = Array.from(this.connections.values()).filter((c) => c.accountId === accountId).length;
4867
+ this.logInfoDedup(
4868
+ 'health',
4869
+ `status-tick ${accountId}|changed|${connected ? 'linked' : 'configured'}|onlineByConn=${onlineByConn}|recentInboundReachable=${recentInboundReachable}|conns=${conns}`,
4870
+ {
4871
+ key: `health-status-tick:${accountId}`,
4872
+ sig: healthSig,
4873
+ },
4874
+ );
4875
+ this.logInfoDedup('health', `status-tick ${healthSig}`, {
4876
+ key: `health-status-tick-debug:${accountId}`,
4877
+ sig: healthSig,
4878
+ debugOnly: true,
4879
+ });
3324
4880
 
3325
4881
  ctx.setStatus?.({
3326
4882
  ...previous,
@@ -3337,13 +4893,31 @@ class BncrBridgeRuntime {
3337
4893
 
3338
4894
  tick();
3339
4895
  const timer = setInterval(tick, 5_000);
4896
+ this.channelAccountTimers.set(accountId, timer);
3340
4897
 
3341
4898
  await new Promise<void>((resolve) => {
3342
- const onAbort = () => {
3343
- clearInterval(timer);
4899
+ let settled = false;
4900
+ const finish = (reason: string) => {
4901
+ if (settled) return;
4902
+ settled = true;
4903
+ const activeTimer = this.channelAccountTimers.get(accountId);
4904
+ if (activeTimer === timer) {
4905
+ clearInterval(timer);
4906
+ this.channelAccountTimers.delete(accountId);
4907
+ } else {
4908
+ clearInterval(timer);
4909
+ }
4910
+ this.logInfo(
4911
+ 'health',
4912
+ `status-worker finished ${JSON.stringify({ bridge: this.bridgeId, accountId, reason })}`,
4913
+ { debugOnly: true },
4914
+ );
4915
+ this.logInfo('health', `status-worker finished ${accountId}|${reason}`);
3344
4916
  resolve();
3345
4917
  };
3346
4918
 
4919
+ const onAbort = () => finish('abort');
4920
+
3347
4921
  if (ctx.abortSignal?.aborted) {
3348
4922
  onAbort();
3349
4923
  return;
@@ -3353,8 +4927,24 @@ class BncrBridgeRuntime {
3353
4927
  });
3354
4928
  };
3355
4929
 
3356
- channelStopAccount = async (_ctx: any) => {
3357
- // no-op
4930
+ channelStopAccount = async (ctx: any) => {
4931
+ const accountId = normalizeAccountId(ctx?.accountId);
4932
+ const cleared = this.clearChannelAccountWorker(accountId, 'explicit-stop');
4933
+ const previous = ctx?.getStatus?.() || {};
4934
+ ctx?.setStatus?.({
4935
+ ...previous,
4936
+ accountId,
4937
+ running: false,
4938
+ restartPending: false,
4939
+ lastStopAt: Date.now(),
4940
+ meta: this.buildStatusMeta(accountId),
4941
+ });
4942
+ this.logInfo(
4943
+ 'health',
4944
+ `status-stop ${JSON.stringify({ bridge: this.bridgeId, accountId, cleared })}`,
4945
+ { debugOnly: true },
4946
+ );
4947
+ this.logInfo('health', `status-stop ${accountId}|cleared=${cleared}`);
3358
4948
  };
3359
4949
 
3360
4950
  channelSendText = async (ctx: any) => {
@@ -3431,6 +5021,7 @@ class BncrBridgeRuntime {
3431
5021
  to,
3432
5022
  text: asString(ctx.text || ''),
3433
5023
  mediaUrl: asString(ctx.mediaUrl || ''),
5024
+ mediaUrls: Array.isArray(ctx?.mediaUrls) ? ctx.mediaUrls : undefined,
3434
5025
  asVoice,
3435
5026
  audioAsVoice,
3436
5027
  replyToId: asString(ctx?.replyToId || ctx?.replyToMessageId || '').trim() || undefined,
@@ -3448,7 +5039,7 @@ export function createBncrBridge(api: OpenClawPluginApi) {
3448
5039
  return new BncrBridgeRuntime(api);
3449
5040
  }
3450
5041
 
3451
- export function createBncrChannelPlugin(bridge: BncrBridgeRuntime) {
5042
+ export function createBncrChannelPlugin(getBridge: () => BncrBridgeRuntime) {
3452
5043
  const messageActions: ChannelMessageActionAdapter = {
3453
5044
  describeMessageTool: ({ cfg }) => {
3454
5045
  const channelCfg = cfg?.channels?.[CHANNEL_ID];
@@ -3460,9 +5051,10 @@ export function createBncrChannelPlugin(bridge: BncrBridgeRuntime) {
3460
5051
  (accountId) => resolveAccount(cfg, accountId).enabled !== false,
3461
5052
  );
3462
5053
 
5054
+ const runtimeBridge = getBridge();
3463
5055
  const hasConnectedRuntime = listAccountIds(cfg).some((accountId) => {
3464
5056
  const resolved = resolveAccount(cfg, accountId);
3465
- const runtime = bridge.getAccountRuntimeSnapshot(resolved.accountId);
5057
+ const runtime = runtimeBridge.getAccountRuntimeSnapshot(resolved.accountId);
3466
5058
  return Boolean(runtime?.connected);
3467
5059
  });
3468
5060
 
@@ -3480,49 +5072,37 @@ export function createBncrChannelPlugin(bridge: BncrBridgeRuntime) {
3480
5072
  handleAction: async ({ action, params, accountId, mediaLocalRoots }) => {
3481
5073
  if (action !== 'send')
3482
5074
  throw new Error(`Action ${action} is not supported for provider ${CHANNEL_ID}.`);
3483
- const to = readStringParam(params, 'to', { required: true });
3484
- const message = readStringParam(params, 'message', { allowEmpty: true }) ?? '';
3485
- const caption = readStringParam(params, 'caption', { allowEmpty: true }) ?? '';
3486
- const content = message || caption || '';
3487
- const mediaUrl =
3488
- readStringParam(params, 'media', { trim: false }) ??
3489
- readStringParam(params, 'path', { trim: false }) ??
3490
- readStringParam(params, 'filePath', { trim: false }) ??
3491
- readStringParam(params, 'mediaUrl', { trim: false });
3492
- const asVoice = readBooleanParam(params, 'asVoice') ?? false;
3493
- const audioAsVoice = readBooleanParam(params, 'audioAsVoice') ?? false;
3494
- const resolvedAccountId = normalizeAccountId(
3495
- readStringParam(params, 'accountId') ?? accountId,
3496
- );
5075
+ const normalized = normalizeBncrSendParams({ params, accountId });
3497
5076
 
3498
- if (!content.trim() && !mediaUrl) throw new Error('send requires text or media');
3499
-
3500
- const result = mediaUrl
5077
+ const runtimeBridge = getBridge();
5078
+ const result = normalized.mediaUrl
3501
5079
  ? await sendBncrMedia({
3502
5080
  channelId: CHANNEL_ID,
3503
- accountId: resolvedAccountId,
3504
- to,
3505
- text: content,
3506
- mediaUrl,
3507
- asVoice,
3508
- audioAsVoice,
5081
+ accountId: normalized.accountId,
5082
+ to: normalized.to,
5083
+ text: normalized.caption,
5084
+ mediaUrl: normalized.mediaUrl,
5085
+ asVoice: normalized.asVoice,
5086
+ audioAsVoice: normalized.audioAsVoice,
3509
5087
  mediaLocalRoots,
3510
- resolveVerifiedTarget: (to, accountId) => bridge.resolveVerifiedTarget(to, accountId),
5088
+ resolveVerifiedTarget: (to, accountId) =>
5089
+ runtimeBridge.resolveVerifiedTarget(to, accountId),
3511
5090
  rememberSessionRoute: (sessionKey, accountId, route) =>
3512
- bridge.rememberSessionRoute(sessionKey, accountId, route),
3513
- enqueueFromReply: (args) => bridge.enqueueFromReply(args as any),
5091
+ runtimeBridge.rememberSessionRoute(sessionKey, accountId, route),
5092
+ enqueueFromReply: (args) => runtimeBridge.enqueueFromReply(args as any),
3514
5093
  createMessageId: () => randomUUID(),
3515
5094
  })
3516
5095
  : await sendBncrText({
3517
5096
  channelId: CHANNEL_ID,
3518
- accountId: resolvedAccountId,
3519
- to,
3520
- text: content,
5097
+ accountId: normalized.accountId,
5098
+ to: normalized.to,
5099
+ text: normalized.message,
3521
5100
  mediaLocalRoots,
3522
- resolveVerifiedTarget: (to, accountId) => bridge.resolveVerifiedTarget(to, accountId),
5101
+ resolveVerifiedTarget: (to, accountId) =>
5102
+ runtimeBridge.resolveVerifiedTarget(to, accountId),
3523
5103
  rememberSessionRoute: (sessionKey, accountId, route) =>
3524
- bridge.rememberSessionRoute(sessionKey, accountId, route),
3525
- enqueueFromReply: (args) => bridge.enqueueFromReply(args as any),
5104
+ runtimeBridge.rememberSessionRoute(sessionKey, accountId, route),
5105
+ enqueueFromReply: (args) => runtimeBridge.enqueueFromReply(args as any),
3526
5106
  createMessageId: () => randomUUID(),
3527
5107
  });
3528
5108
 
@@ -3557,9 +5137,10 @@ export function createBncrChannelPlugin(bridge: BncrBridgeRuntime) {
3557
5137
  const resolvedAccountId = normalizeAccountId(
3558
5138
  asString(accountId || BNCR_DEFAULT_ACCOUNT_ID),
3559
5139
  );
5140
+ const runtimeBridge = getBridge();
3560
5141
  const canonicalAgentId =
3561
- bridge.canonicalAgentId ||
3562
- bridge.ensureCanonicalAgentId({ cfg, accountId: resolvedAccountId });
5142
+ runtimeBridge.canonicalAgentId ||
5143
+ runtimeBridge.ensureCanonicalAgentId({ cfg, accountId: resolvedAccountId });
3563
5144
  return parseExplicitTarget(asString(raw).trim(), { canonicalAgentId });
3564
5145
  },
3565
5146
  formatTargetDisplay: ({ target }: any) => {
@@ -3571,13 +5152,14 @@ export function createBncrChannelPlugin(bridge: BncrBridgeRuntime) {
3571
5152
  const resolvedAccountId = normalizeAccountId(
3572
5153
  asString(accountId || BNCR_DEFAULT_ACCOUNT_ID),
3573
5154
  );
5155
+ const runtimeBridge = getBridge();
3574
5156
  const canonicalAgentId =
3575
- bridge.canonicalAgentId ||
3576
- bridge.ensureCanonicalAgentId({ cfg, accountId: resolvedAccountId });
5157
+ runtimeBridge.canonicalAgentId ||
5158
+ runtimeBridge.ensureCanonicalAgentId({ cfg, accountId: resolvedAccountId });
3577
5159
 
3578
5160
  let parsed = parseExplicitTarget(raw, { canonicalAgentId });
3579
5161
  if (!parsed) {
3580
- const route = bridge.resolveRouteBySession(raw, resolvedAccountId);
5162
+ const route = runtimeBridge.resolveRouteBySession(raw, resolvedAccountId);
3581
5163
  if (route) {
3582
5164
  parsed = parseExplicitTarget(formatDisplayScope(route), { canonicalAgentId });
3583
5165
  }
@@ -3588,13 +5170,15 @@ export function createBncrChannelPlugin(bridge: BncrBridgeRuntime) {
3588
5170
  const accountId = normalizeAccountId(
3589
5171
  asString(params?.accountId || BNCR_DEFAULT_ACCOUNT_ID),
3590
5172
  );
5173
+ const runtimeBridge = getBridge();
3591
5174
  const canonicalAgentId =
3592
- bridge.canonicalAgentId || bridge.ensureCanonicalAgentId({ cfg: params?.cfg, accountId });
5175
+ runtimeBridge.canonicalAgentId ||
5176
+ runtimeBridge.ensureCanonicalAgentId({ cfg: params?.cfg, accountId });
3593
5177
  return resolveBncrOutboundSessionRoute({
3594
5178
  ...params,
3595
5179
  canonicalAgentId,
3596
5180
  resolveRouteBySession: (raw: string, acc: string) =>
3597
- bridge.resolveRouteBySession(raw, acc),
5181
+ runtimeBridge.resolveRouteBySession(raw, acc),
3598
5182
  });
3599
5183
  },
3600
5184
  targetResolver: {
@@ -3602,11 +5186,12 @@ export function createBncrChannelPlugin(bridge: BncrBridgeRuntime) {
3602
5186
  return looksLikeBncrExplicitTarget(asString(normalized || raw).trim());
3603
5187
  },
3604
5188
  resolveTarget: async ({ accountId, input, normalized }) => {
5189
+ const runtimeBridge = getBridge();
3605
5190
  const resolved = resolveBncrOutboundTarget({
3606
5191
  target: asString(normalized || input).trim(),
3607
5192
  accountId: normalizeAccountId(asString(accountId || BNCR_DEFAULT_ACCOUNT_ID)),
3608
5193
  resolveRouteBySession: (raw: string, acc: string) =>
3609
- this.resolveRouteBySession(raw, acc),
5194
+ runtimeBridge.resolveRouteBySession(raw, acc),
3610
5195
  });
3611
5196
  if (!resolved) return null;
3612
5197
  return {
@@ -3669,8 +5254,8 @@ export function createBncrChannelPlugin(bridge: BncrBridgeRuntime) {
3669
5254
  },
3670
5255
  outbound: {
3671
5256
  deliveryMode: 'gateway' as const,
3672
- sendText: bridge.channelSendText,
3673
- sendMedia: bridge.channelSendMedia,
5257
+ sendText: async (ctx: any) => getBridge().channelSendText(ctx),
5258
+ sendMedia: async (ctx: any) => getBridge().channelSendMedia(ctx),
3674
5259
  replyAction: async (ctx: any) =>
3675
5260
  sendBncrReplyAction({
3676
5261
  accountId: normalizeAccountId(ctx?.accountId),
@@ -3679,7 +5264,7 @@ export function createBncrChannelPlugin(bridge: BncrBridgeRuntime) {
3679
5264
  replyToMessageId:
3680
5265
  asString(ctx?.replyToId || ctx?.replyToMessageId || '').trim() || undefined,
3681
5266
  sendText: async ({ accountId, to, text }) =>
3682
- bridge.channelSendText({ accountId, to, text }),
5267
+ getBridge().channelSendText({ accountId, to, text }),
3683
5268
  }),
3684
5269
  deleteAction: async (ctx: any) =>
3685
5270
  deleteBncrMessageAction({
@@ -3704,10 +5289,11 @@ export function createBncrChannelPlugin(bridge: BncrBridgeRuntime) {
3704
5289
  mode: 'ws-offline',
3705
5290
  }),
3706
5291
  buildChannelSummary: async ({ defaultAccountId }: any) => {
3707
- return bridge.getChannelSummary(defaultAccountId || BNCR_DEFAULT_ACCOUNT_ID);
5292
+ return getBridge().getChannelSummary(defaultAccountId || BNCR_DEFAULT_ACCOUNT_ID);
3708
5293
  },
3709
5294
  buildAccountSnapshot: async ({ account, runtime }: any) => {
3710
- const rt = runtime || bridge.getAccountRuntimeSnapshot(account?.accountId);
5295
+ const runtimeBridge = getBridge();
5296
+ const rt = runtime || runtimeBridge.getAccountRuntimeSnapshot(account?.accountId);
3711
5297
  const meta = rt?.meta || {};
3712
5298
 
3713
5299
  const pending = Number(rt?.pending ?? meta.pending ?? 0);
@@ -3742,7 +5328,7 @@ export function createBncrChannelPlugin(bridge: BncrBridgeRuntime) {
3742
5328
  mode: normalizedMode,
3743
5329
  pending,
3744
5330
  deadLetter,
3745
- healthSummary: bridge.getStatusHeadline(account?.accountId),
5331
+ healthSummary: runtimeBridge.getStatusHeadline(account?.accountId),
3746
5332
  lastSessionKey,
3747
5333
  lastSessionScope,
3748
5334
  lastSessionAt,
@@ -3760,7 +5346,7 @@ export function createBncrChannelPlugin(bridge: BncrBridgeRuntime) {
3760
5346
  if (!enabled) return 'disabled';
3761
5347
  const resolved = resolveAccount(cfg, account?.accountId);
3762
5348
  if (!(resolved.enabled && configured)) return 'not configured';
3763
- const rt = runtime || bridge.getAccountRuntimeSnapshot(account?.accountId);
5349
+ const rt = runtime || getBridge().getAccountRuntimeSnapshot(account?.accountId);
3764
5350
  return rt?.connected ? 'linked' : 'configured';
3765
5351
  },
3766
5352
  },
@@ -3777,8 +5363,8 @@ export function createBncrChannelPlugin(bridge: BncrBridgeRuntime) {
3777
5363
  'bncr.file.ack',
3778
5364
  ],
3779
5365
  gateway: {
3780
- startAccount: bridge.channelStartAccount,
3781
- stopAccount: bridge.channelStopAccount,
5366
+ startAccount: async (ctx: any) => getBridge().channelStartAccount(ctx),
5367
+ stopAccount: async (ctx: any) => getBridge().channelStopAccount(ctx),
3782
5368
  },
3783
5369
  };
3784
5370