@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/index.ts +24 -1
- package/package.json +1 -1
- package/src/channel.ts +1025 -106
- package/src/core/status.ts +10 -10
- package/src/core/types.ts +3 -0
- package/src/messaging/outbound/send.ts +2 -0
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
|
|
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(
|
|
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
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
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
|
|
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
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1663
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
1774
|
-
|
|
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)
|
|
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
|
|
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
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
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:
|
|
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
|
-
|
|
3015
|
-
|
|
3016
|
-
|
|
3017
|
-
|
|
3018
|
-
|
|
3019
|
-
|
|
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
|
-
|
|
3028
|
-
|
|
3029
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ${
|
|
3943
|
-
|
|
3944
|
-
accountId
|
|
3945
|
-
|
|
3946
|
-
|
|
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,
|