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