@xmoxmo/bncr 0.2.3 → 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
@@ -246,6 +248,49 @@ function normalizeBncrSendParams(input: {
246
248
  };
247
249
  }
248
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
+
249
294
  function now() {
250
295
  return Date.now();
251
296
  }
@@ -333,6 +378,7 @@ class BncrBridgeRuntime {
333
378
  private api: OpenClawPluginApi;
334
379
  private statePath: string | null = null;
335
380
  private bridgeId = `${process.pid}-${Math.random().toString(16).slice(2, 8)}`;
381
+ private recentMediaDedupeBySession = new Map<string, Map<string, MediaDedupeCacheEntry>>();
336
382
  private gatewayPid = process.pid;
337
383
  private registerCount = 0;
338
384
  private apiGeneration = 0;
@@ -410,6 +456,7 @@ class BncrBridgeRuntime {
410
456
  private lastInboundByAccount = new Map<string, number>();
411
457
  private lastOutboundByAccount = new Map<string, number>();
412
458
  private channelAccountTimers = new Map<string, NodeJS.Timeout>();
459
+ private logDedupeState = new Map<string, { at: number; sig: string }>();
413
460
  private canonicalAgentId: string | null = null;
414
461
  private canonicalAgentSource: 'startup' | 'runtime' | 'fallback-main' | null = null;
415
462
  private canonicalAgentResolvedAt: number | null = null;
@@ -470,6 +517,102 @@ class BncrBridgeRuntime {
470
517
  emitBncrLog('error', scope, message, options, () => this.isDebugEnabled());
471
518
  }
472
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
+
473
616
  private summarizeTextPreview(raw: string, limit = 8) {
474
617
  const compact = asString(raw || '')
475
618
  .replace(/\s+/g, ' ')
@@ -790,7 +933,8 @@ class BncrBridgeRuntime {
790
933
  }
791
934
 
792
935
  private buildExtendedDiagnostics(accountId: string) {
793
- const diagnostics = this.buildIntegratedDiagnostics(accountId) as Record<string, any>;
936
+ const acc = normalizeAccountId(accountId);
937
+ const diagnostics = this.buildIntegratedDiagnostics(acc) as Record<string, any>;
794
938
  return {
795
939
  ...diagnostics,
796
940
  register: {
@@ -810,7 +954,7 @@ class BncrBridgeRuntime {
810
954
  lastDriftSnapshot: this.lastDriftSnapshot,
811
955
  },
812
956
  connection: {
813
- active: this.activeConnectionCount(accountId),
957
+ active: this.activeConnectionCount(acc),
814
958
  primaryLeaseId: this.primaryLeaseId,
815
959
  primaryEpoch: this.connectionEpoch || null,
816
960
  acceptedConnections: this.acceptedConnections,
@@ -1338,11 +1482,92 @@ class BncrBridgeRuntime {
1338
1482
  const acc = normalizeAccountId(accountId);
1339
1483
  const t = now();
1340
1484
  const primaryKey = this.activeConnectionByAccount.get(acc);
1341
- if (!primaryKey) return null;
1342
- const primary = this.connections.get(primaryKey);
1343
- if (!primary?.connId) return null;
1344
- if (t - primary.lastSeenAt > CONNECT_TTL_MS) return null;
1345
- 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;
1346
1571
  }
1347
1572
 
1348
1573
  private resolvePushConnIds(accountId: string): Set<string> {
@@ -1350,14 +1575,67 @@ class BncrBridgeRuntime {
1350
1575
  const t = now();
1351
1576
  const connIds = new Set<string>();
1352
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
+
1353
1610
  const primaryKey = this.activeConnectionByAccount.get(acc);
1354
1611
  if (primaryKey) {
1355
1612
  const primary = this.connections.get(primaryKey);
1356
- if (primary?.connId && t - primary.lastSeenAt <= CONNECT_TTL_MS) {
1613
+ if (isEligible(primary)) {
1357
1614
  connIds.add(primary.connId);
1358
1615
  }
1359
1616
  }
1360
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
+
1361
1639
  if (connIds.size > 0) return connIds;
1362
1640
 
1363
1641
  for (const c of this.connections.values()) {
@@ -1413,6 +1691,73 @@ class BncrBridgeRuntime {
1413
1691
  return true;
1414
1692
  }
1415
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
+
1416
1761
  private tryAdoptTransferOwner(args: {
1417
1762
  accountId: string;
1418
1763
  transfer:
@@ -1494,6 +1839,114 @@ class BncrBridgeRuntime {
1494
1839
  };
1495
1840
  }
1496
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
+
1497
1950
  private async tryPushEntry(entry: OutboxEntry): Promise<boolean> {
1498
1951
  const meta = isPlainObject(entry.payload?._meta) ? entry.payload._meta : null;
1499
1952
  if (meta?.kind === 'file-transfer') {
@@ -1514,13 +1967,39 @@ class BncrBridgeRuntime {
1514
1967
  return false;
1515
1968
  }
1516
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
+ );
1517
1980
  const owner = this.resolveOutboxPushOwner(entry.accountId);
1518
- let connIds = owner?.connId
1519
- ? new Set([owner.connId])
1520
- : this.resolvePushConnIds(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);
1521
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';
1522
1995
  if (!connIds.size && recentInboundReachable) {
1523
- connIds = this.resolveRecentInboundConnIds(entry.accountId);
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
+ );
1524
2003
  }
1525
2004
  if (!connIds.size) {
1526
2005
  entry.lastError = 'no active bncr client for file chunk transfer';
@@ -1598,15 +2077,35 @@ class BncrBridgeRuntime {
1598
2077
  },
1599
2078
  connIds,
1600
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
+ );
1601
2095
  entry.lastPushAt = now();
1602
2096
  entry.lastPushConnId =
1603
2097
  owner?.connId || (connIds.size === 1 ? Array.from(connIds)[0] : undefined);
1604
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
+ }
1605
2103
  entry.lastError = undefined;
1606
2104
  this.outbox.set(entry.messageId, entry);
1607
2105
  this.lastOutboundByAccount.set(entry.accountId, entry.lastPushAt);
1608
2106
  this.markActivity(entry.accountId, entry.lastPushAt);
1609
2107
  this.scheduleSave();
2108
+ this.logInfo('outbox push ok', `mid=${entry.messageId}|q=${this.outbox.size}`);
1610
2109
  this.logInfo(
1611
2110
  'outbox',
1612
2111
  `push-ok ${JSON.stringify({
@@ -1626,6 +2125,10 @@ class BncrBridgeRuntime {
1626
2125
  entry.lastError = asString((error as any)?.message || error || 'file-transfer-error');
1627
2126
  this.outbox.set(entry.messageId, entry);
1628
2127
  this.scheduleSave();
2128
+ this.logInfo(
2129
+ 'outbox push fail',
2130
+ `mid=${entry.messageId}|q=${this.outbox.size}|err=${entry.lastError}`,
2131
+ );
1629
2132
  this.logInfo(
1630
2133
  'outbox',
1631
2134
  `push-fail ${JSON.stringify({
@@ -1658,13 +2161,42 @@ class BncrBridgeRuntime {
1658
2161
  return false;
1659
2162
  }
1660
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;
1661
2175
  const owner = this.resolveOutboxPushOwner(entry.accountId);
1662
- let connIds = owner?.connId
1663
- ? new Set([owner.connId])
1664
- : 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);
1665
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';
1666
2190
  if (!connIds.size && recentInboundReachable) {
1667
- 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
+ );
1668
2200
  }
1669
2201
  if (!connIds.size) {
1670
2202
  this.logInfo(
@@ -1687,11 +2219,30 @@ class BncrBridgeRuntime {
1687
2219
  };
1688
2220
 
1689
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
+ );
1690
2236
  entry.lastPushAt = now();
1691
2237
  entry.lastPushConnId =
1692
- owner?.connId || (connIds.size === 1 ? Array.from(connIds)[0] : undefined);
1693
- 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
+ }
1694
2244
  this.outbox.set(entry.messageId, entry);
2245
+ this.logInfo('outbox push ok', `mid=${entry.messageId}|q=${this.outbox.size}`);
1695
2246
  this.logInfo(
1696
2247
  'outbox',
1697
2248
  `push-ok ${JSON.stringify({
@@ -1712,6 +2263,7 @@ class BncrBridgeRuntime {
1712
2263
  } catch (error) {
1713
2264
  entry.lastError = asString((error as any)?.message || error || 'push-error');
1714
2265
  this.outbox.set(entry.messageId, entry);
2266
+ this.logInfo('outbox push fail', `mid=${entry.messageId}|q=${this.outbox.size}|err=${entry.lastError}`);
1715
2267
  this.logInfo(
1716
2268
  'outbox',
1717
2269
  `push-fail ${JSON.stringify({
@@ -1730,7 +2282,7 @@ class BncrBridgeRuntime {
1730
2282
  const delay = Math.max(0, Math.min(Number(delayMs || 0), 30_000));
1731
2283
  this.pushTimer = setTimeout(() => {
1732
2284
  this.pushTimer = null;
1733
- void this.flushPushQueue();
2285
+ void this.flushPushQueue({ trigger: 'timer', reason: 'scheduled-drain' });
1734
2286
  }, delay);
1735
2287
  }
1736
2288
 
@@ -1770,8 +2322,14 @@ class BncrBridgeRuntime {
1770
2322
  };
1771
2323
  }
1772
2324
 
1773
- private async flushPushQueue(accountId?: string): Promise<void> {
1774
- 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;
1775
2333
  const targetAccounts = filterAcc
1776
2334
  ? [filterAcc]
1777
2335
  : Array.from(
@@ -1786,6 +2344,8 @@ class BncrBridgeRuntime {
1786
2344
  accountId: filterAcc,
1787
2345
  targetAccounts,
1788
2346
  outboxSize: this.outbox.size,
2347
+ trigger,
2348
+ reason,
1789
2349
  })}`,
1790
2350
  { debugOnly: true },
1791
2351
  );
@@ -1831,7 +2391,8 @@ class BncrBridgeRuntime {
1831
2391
  break;
1832
2392
  }
1833
2393
 
1834
- const onlineNow = this.isOnline(acc) || this.hasRecentInboundReachability(acc);
2394
+ const onlineNow = this.isOnline(acc);
2395
+ const recentInboundReachable = this.hasRecentInboundReachability(acc);
1835
2396
  const pushed = await this.tryPushEntry(entry);
1836
2397
  if (pushed) {
1837
2398
  const requireAck = this.isOutboundAckRequired(acc);
@@ -1848,6 +2409,7 @@ class BncrBridgeRuntime {
1848
2409
  requireAck,
1849
2410
  ackResult,
1850
2411
  onlineNow,
2412
+ recentInboundReachable,
1851
2413
  })}`,
1852
2414
  { debugOnly: true },
1853
2415
  );
@@ -1862,19 +2424,72 @@ class BncrBridgeRuntime {
1862
2424
  continue;
1863
2425
  }
1864
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
+
1865
2451
  entry.retryCount += 1;
1866
2452
  entry.lastAttemptAt = now();
1867
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
+ );
1868
2458
  this.moveToDeadLetter(
1869
2459
  entry,
1870
2460
  entry.lastError || (requireAck ? 'push-ack-timeout' : 'push-delivery-unconfirmed'),
1871
2461
  );
1872
2462
  continue;
1873
2463
  }
1874
- entry.nextAttemptAt = now() + backoffMs(entry.retryCount);
2464
+ entry.nextAttemptAt = shouldFastReroute ? now() + 1_000 : now() + backoffMs(entry.retryCount);
1875
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
+ }
1876
2471
  this.outbox.set(entry.messageId, entry);
1877
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
+ );
1878
2493
 
1879
2494
  const wait = Math.max(0, entry.nextAttemptAt - now());
1880
2495
  localNextDelay = localNextDelay == null ? wait : Math.min(localNextDelay, wait);
@@ -1994,27 +2609,47 @@ class BncrBridgeRuntime {
1994
2609
  const previousActiveKey = this.activeConnectionByAccount.get(acc) || null;
1995
2610
  const previousActiveConn = previousActiveKey ? this.connections.get(previousActiveKey) || null : null;
1996
2611
 
1997
- const nextConn: BncrConnection = {
2612
+ const nextConn = {
1998
2613
  accountId: acc,
1999
2614
  connId,
2000
2615
  clientId: asString(clientId || '').trim() || undefined,
2001
2616
  connectedAt: prev?.connectedAt || t,
2002
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;
2003
2625
  };
2004
2626
 
2005
- this.connections.set(key, nextConn);
2006
- this.logInfo(
2007
- 'connection',
2008
- `seen ${JSON.stringify({
2009
- bridge: this.bridgeId,
2010
- accountId: acc,
2011
- connId,
2012
- clientId: nextConn.clientId,
2013
- connectedAt: nextConn.connectedAt,
2014
- lastSeenAt: nextConn.lastSeenAt,
2015
- })}`,
2016
- { debugOnly: true },
2017
- );
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
+ });
2018
2653
 
2019
2654
  const current = this.activeConnectionByAccount.get(acc);
2020
2655
  if (!current) {
@@ -2036,6 +2671,9 @@ class BncrBridgeRuntime {
2036
2671
  clientId: c.clientId,
2037
2672
  connectedAt: c.connectedAt,
2038
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,
2039
2677
  })),
2040
2678
  })}`,
2041
2679
  { debugOnly: true },
@@ -2044,22 +2682,14 @@ class BncrBridgeRuntime {
2044
2682
  }
2045
2683
 
2046
2684
  const curConn = this.connections.get(current);
2047
- if (
2048
- !curConn ||
2049
- t - curConn.lastSeenAt > CONNECT_TTL_MS ||
2050
- nextConn.connectedAt >= curConn.connectedAt
2051
- ) {
2685
+ if (!curConn || t - curConn.lastSeenAt > CONNECT_TTL_MS) {
2052
2686
  this.activeConnectionByAccount.set(acc, key);
2053
2687
  this.logInfo(
2054
2688
  'connection',
2055
2689
  `seen:promote ${JSON.stringify({
2056
2690
  bridge: this.bridgeId,
2057
2691
  accountId: acc,
2058
- reason: !curConn
2059
- ? 'current-missing'
2060
- : t - curConn.lastSeenAt > CONNECT_TTL_MS
2061
- ? 'current-stale'
2062
- : 'newer-or-equal-connectedAt',
2692
+ reason: !curConn ? 'current-missing' : 'current-stale',
2063
2693
  previousActiveKey,
2064
2694
  previousActiveConn,
2065
2695
  nextActiveKey: key,
@@ -2071,6 +2701,9 @@ class BncrBridgeRuntime {
2071
2701
  clientId: c.clientId,
2072
2702
  connectedAt: c.connectedAt,
2073
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,
2074
2707
  })),
2075
2708
  })}`,
2076
2709
  { debugOnly: true },
@@ -2078,6 +2711,180 @@ class BncrBridgeRuntime {
2078
2711
  }
2079
2712
  }
2080
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,
2840
+ };
2841
+
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
+
2866
+ this.logInfo(
2867
+ 'connection',
2868
+ `outbound-degrade ${JSON.stringify({
2869
+ bridge: this.bridgeId,
2870
+ accountId: acc,
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
+ },
2883
+ })}`,
2884
+ { debugOnly: true },
2885
+ );
2886
+ }
2887
+
2081
2888
  private isOnline(accountId: string): boolean {
2082
2889
  const acc = normalizeAccountId(accountId);
2083
2890
  const t = now();
@@ -2980,6 +3787,29 @@ class BncrBridgeRuntime {
2980
3787
  }) {
2981
3788
  const { accountId, sessionKey, route, payload, mediaLocalRoots } = params;
2982
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
+
2983
3813
  const mediaList = payload.mediaUrls?.length
2984
3814
  ? payload.mediaUrls
2985
3815
  : payload.mediaUrl
@@ -2988,21 +3818,68 @@ class BncrBridgeRuntime {
2988
3818
 
2989
3819
  if (mediaList.length > 0) {
2990
3820
  let first = true;
3821
+ const currentTime = now();
2991
3822
  for (const mediaUrl of mediaList) {
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({
3829
+ sessionKey,
3830
+ mediaUrl: normalizedMediaUrl,
3831
+ text: normalizedText,
3832
+ replyToId: normalizedReplyToId,
3833
+ currentTime,
3834
+ });
3835
+
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
+
2992
3862
  this.enqueueOutbound(
2993
3863
  this.buildFileTransferOutboxEntry({
2994
3864
  accountId,
2995
3865
  sessionKey,
2996
3866
  route,
2997
- mediaUrl,
3867
+ mediaUrl: normalizedMediaUrl,
2998
3868
  mediaLocalRoots,
2999
3869
  text: first ? asString(payload.text || '') : '',
3000
3870
  asVoice: payload.asVoice,
3001
3871
  audioAsVoice: payload.audioAsVoice,
3002
3872
  kind: payload.kind,
3003
- replyToId: asString(payload.replyToId || '').trim() || undefined,
3873
+ replyToId: normalizedReplyToId || undefined,
3004
3874
  }),
3005
3875
  );
3876
+ this.rememberRecentMediaSend({
3877
+ sessionKey,
3878
+ mediaUrl: normalizedMediaUrl,
3879
+ text: normalizedText,
3880
+ replyToId: normalizedReplyToId,
3881
+ createdAt: currentTime,
3882
+ });
3006
3883
  first = false;
3007
3884
  }
3008
3885
  return;
@@ -3011,37 +3888,16 @@ class BncrBridgeRuntime {
3011
3888
  const text = asString(payload.text || '').trim();
3012
3889
  if (!text) return;
3013
3890
 
3014
- const messageId = randomUUID();
3015
- const frame = {
3016
- type: 'message.outbound',
3017
- messageId,
3018
- idempotencyKey: messageId,
3019
- sessionKey,
3020
- replyToId: asString(payload.replyToId || '').trim() || undefined,
3021
- message: {
3022
- platform: route.platform,
3023
- groupId: route.groupId,
3024
- userId: route.userId,
3025
- type: 'text',
3891
+ this.enqueueOutbound(
3892
+ this.buildTextOutboxEntry({
3893
+ accountId,
3894
+ sessionKey,
3895
+ route,
3896
+ text,
3026
3897
  kind: payload.kind,
3027
- msg: text,
3028
- path: '',
3029
- base64: '',
3030
- fileName: '',
3031
- },
3032
- ts: now(),
3033
- };
3034
-
3035
- this.enqueueOutbound({
3036
- messageId,
3037
- accountId: normalizeAccountId(accountId),
3038
- sessionKey,
3039
- route,
3040
- payload: frame,
3041
- createdAt: now(),
3042
- retryCount: 0,
3043
- nextAttemptAt: now(),
3044
- });
3898
+ replyToId: asString(payload.replyToId || '').trim() || undefined,
3899
+ }),
3900
+ );
3045
3901
  }
3046
3902
 
3047
3903
  handleConnect = async ({ params, respond, client, context }: GatewayRequestHandlerOptions) => {
@@ -3049,6 +3905,9 @@ class BncrBridgeRuntime {
3049
3905
  const accountId = normalizeAccountId(asString(params?.accountId || ''));
3050
3906
  const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
3051
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;
3052
3911
 
3053
3912
  this.logInfo(
3054
3913
  'connection',
@@ -3057,6 +3916,9 @@ class BncrBridgeRuntime {
3057
3916
  accountId,
3058
3917
  connId,
3059
3918
  clientId,
3919
+ outboundReady,
3920
+ preferredForOutbound,
3921
+ inboundOnly,
3060
3922
  hasContext: Boolean(context),
3061
3923
  })}`,
3062
3924
  { debugOnly: true },
@@ -3064,6 +3926,14 @@ class BncrBridgeRuntime {
3064
3926
 
3065
3927
  this.rememberGatewayContext(context);
3066
3928
  this.markSeen(accountId, connId, clientId);
3929
+ this.markOutboundCapability({
3930
+ accountId,
3931
+ connId,
3932
+ clientId,
3933
+ outboundReady,
3934
+ preferredForOutbound,
3935
+ inboundOnly,
3936
+ });
3067
3937
  this.markActivity(accountId);
3068
3938
  this.incrementCounter(this.connectEventsByAccount, accountId);
3069
3939
  const lease = this.acceptConnection();
@@ -3094,7 +3964,7 @@ class BncrBridgeRuntime {
3094
3964
  });
3095
3965
 
3096
3966
  // WS 一旦在线,立即尝试把离线期间积压队列直推出去
3097
- this.flushPushQueue(accountId);
3967
+ this.flushPushQueue({ accountId, trigger: 'connect', reason: 'ws-online' });
3098
3968
  };
3099
3969
 
3100
3970
  handleAck = async ({ params, respond, client, context }: GatewayRequestHandlerOptions) => {
@@ -3160,19 +4030,29 @@ class BncrBridgeRuntime {
3160
4030
  const fatal = params?.fatal === true;
3161
4031
 
3162
4032
  if (ok) {
4033
+ this.markOutboundCapability({
4034
+ accountId,
4035
+ connId,
4036
+ clientId,
4037
+ outboundReady: true,
4038
+ preferredForOutbound: true,
4039
+ });
3163
4040
  this.outbox.delete(messageId);
3164
4041
  this.scheduleSave();
3165
4042
  this.resolveMessageAck(messageId, 'acked');
4043
+ this.logInfo('outbox ack ok', `mid=${messageId}|q=${this.outbox.size}`);
3166
4044
  respond(
3167
4045
  true,
3168
4046
  staleObserved.stale ? { ok: true, stale: true, staleAccepted: true } : { ok: true },
3169
4047
  );
3170
- this.flushPushQueue(accountId);
4048
+ this.flushPushQueue({ accountId, trigger: 'ack-ok', reason: 'message-acked' });
3171
4049
  return;
3172
4050
  }
3173
4051
 
3174
4052
  if (fatal) {
3175
- 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}`);
3176
4056
  respond(
3177
4057
  true,
3178
4058
  staleObserved.stale
@@ -3186,6 +4066,7 @@ class BncrBridgeRuntime {
3186
4066
  entry.lastError = asString(params?.error || 'retryable-ack');
3187
4067
  this.outbox.set(messageId, entry);
3188
4068
  this.scheduleSave();
4069
+ this.logInfo('outbox ack retry', `mid=${messageId}|q=${this.outbox.size}|err=${entry.lastError}`);
3189
4070
 
3190
4071
  respond(
3191
4072
  true,
@@ -3200,6 +4081,9 @@ class BncrBridgeRuntime {
3200
4081
  const accountId = normalizeAccountId(asString(params?.accountId || ''));
3201
4082
  const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
3202
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;
3203
4087
  if (
3204
4088
  this.shouldIgnoreStaleEvent({
3205
4089
  kind: 'activity',
@@ -3220,12 +4104,23 @@ class BncrBridgeRuntime {
3220
4104
  accountId,
3221
4105
  connId,
3222
4106
  clientId,
4107
+ outboundReady,
4108
+ preferredForOutbound,
4109
+ inboundOnly,
3223
4110
  hasContext: Boolean(context),
3224
4111
  })}`,
3225
4112
  { debugOnly: true },
3226
4113
  );
3227
4114
  this.rememberGatewayContext(context);
3228
4115
  this.markSeen(accountId, connId, clientId);
4116
+ this.markOutboundCapability({
4117
+ accountId,
4118
+ connId,
4119
+ clientId,
4120
+ outboundReady,
4121
+ preferredForOutbound,
4122
+ inboundOnly,
4123
+ });
3229
4124
  this.markActivity(accountId);
3230
4125
  this.incrementCounter(this.activityEventsByAccount, accountId);
3231
4126
 
@@ -3239,7 +4134,7 @@ class BncrBridgeRuntime {
3239
4134
  deadLetter: this.deadLetter.filter((v) => v.accountId === accountId).length,
3240
4135
  now: now(),
3241
4136
  });
3242
- this.flushPushQueue(accountId);
4137
+ this.flushPushQueue({ accountId, trigger: 'activity', reason: 'activity-heartbeat' });
3243
4138
  };
3244
4139
 
3245
4140
  handleDiagnostics = async ({ params, respond }: GatewayRequestHandlerOptions) => {
@@ -3772,6 +4667,9 @@ class BncrBridgeRuntime {
3772
4667
  } = parsed;
3773
4668
  const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
3774
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;
3775
4673
  if (
3776
4674
  this.shouldIgnoreStaleEvent({
3777
4675
  kind: 'inbound',
@@ -3792,6 +4690,14 @@ class BncrBridgeRuntime {
3792
4690
  }
3793
4691
  this.rememberGatewayContext(context);
3794
4692
  this.markSeen(accountId, connId, clientId);
4693
+ this.markOutboundCapability({
4694
+ accountId,
4695
+ connId,
4696
+ clientId,
4697
+ outboundReady,
4698
+ preferredForOutbound,
4699
+ inboundOnly,
4700
+ });
3795
4701
  this.markActivity(accountId);
3796
4702
  this.logInfo(
3797
4703
  'inbound',
@@ -3801,6 +4707,9 @@ class BncrBridgeRuntime {
3801
4707
  accountId,
3802
4708
  connId,
3803
4709
  clientId,
4710
+ outboundReady,
4711
+ preferredForOutbound,
4712
+ inboundOnly,
3804
4713
  onlineAfterSeen: this.isOnline(accountId),
3805
4714
  recentInboundReachable: this.hasRecentInboundReachability(accountId),
3806
4715
  activeConnectionKey: this.activeConnectionByAccount.get(accountId) || null,
@@ -3897,7 +4806,7 @@ class BncrBridgeRuntime {
3897
4806
  msgId: msgId ?? null,
3898
4807
  taskKey: extracted.taskKey ?? null,
3899
4808
  });
3900
- this.flushPushQueue(accountId);
4809
+ this.flushPushQueue({ accountId, trigger: 'inbound', reason: 'inbound-accepted' });
3901
4810
 
3902
4811
  void dispatchBncrInbound({
3903
4812
  api: this.api,
@@ -3937,30 +4846,37 @@ class BncrBridgeRuntime {
3937
4846
  this.lastOutboundByAccount.get(accountId) ||
3938
4847
  previous?.lastEventAt ||
3939
4848
  null;
3940
- this.logInfo(
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(
3941
4868
  'health',
3942
- `status-tick ${JSON.stringify({
3943
- bridge: this.bridgeId,
3944
- accountId,
3945
- connected,
3946
- onlineByConn,
3947
- recentInboundReachable,
3948
- lastActivityAt: this.lastActivityByAccount.get(accountId) || null,
3949
- lastInboundAt: this.lastInboundByAccount.get(accountId) || null,
3950
- lastOutboundAt: this.lastOutboundByAccount.get(accountId) || null,
3951
- chosenLastEventAt: lastActAt,
3952
- activeConnectionKey: this.activeConnectionByAccount.get(accountId) || null,
3953
- activeConnections: Array.from(this.connections.values())
3954
- .filter((c) => c.accountId === accountId)
3955
- .map((c) => ({
3956
- connId: c.connId,
3957
- clientId: c.clientId,
3958
- connectedAt: c.connectedAt,
3959
- lastSeenAt: c.lastSeenAt,
3960
- })),
3961
- })}`,
3962
- { debugOnly: true },
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
+ },
3963
4874
  );
4875
+ this.logInfoDedup('health', `status-tick ${healthSig}`, {
4876
+ key: `health-status-tick-debug:${accountId}`,
4877
+ sig: healthSig,
4878
+ debugOnly: true,
4879
+ });
3964
4880
 
3965
4881
  ctx.setStatus?.({
3966
4882
  ...previous,
@@ -3996,6 +4912,7 @@ class BncrBridgeRuntime {
3996
4912
  `status-worker finished ${JSON.stringify({ bridge: this.bridgeId, accountId, reason })}`,
3997
4913
  { debugOnly: true },
3998
4914
  );
4915
+ this.logInfo('health', `status-worker finished ${accountId}|${reason}`);
3999
4916
  resolve();
4000
4917
  };
4001
4918
 
@@ -4027,6 +4944,7 @@ class BncrBridgeRuntime {
4027
4944
  `status-stop ${JSON.stringify({ bridge: this.bridgeId, accountId, cleared })}`,
4028
4945
  { debugOnly: true },
4029
4946
  );
4947
+ this.logInfo('health', `status-stop ${accountId}|cleared=${cleared}`);
4030
4948
  };
4031
4949
 
4032
4950
  channelSendText = async (ctx: any) => {
@@ -4103,6 +5021,7 @@ class BncrBridgeRuntime {
4103
5021
  to,
4104
5022
  text: asString(ctx.text || ''),
4105
5023
  mediaUrl: asString(ctx.mediaUrl || ''),
5024
+ mediaUrls: Array.isArray(ctx?.mediaUrls) ? ctx.mediaUrls : undefined,
4106
5025
  asVoice,
4107
5026
  audioAsVoice,
4108
5027
  replyToId: asString(ctx?.replyToId || ctx?.replyToMessageId || '').trim() || undefined,