@xmoxmo/bncr 0.2.4 → 0.2.5
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 +67 -4
- package/package.json +1 -1
- package/src/channel.ts +2274 -1548
- package/src/core/connection-capability.ts +70 -0
- package/src/core/connection-reachability.ts +141 -0
- package/src/core/diagnostics.ts +49 -0
- package/src/core/downlink-health.ts +56 -0
- package/src/core/extended-diagnostics.ts +65 -0
- package/src/core/lease-state.ts +94 -0
- package/src/core/outbox-enqueue.ts +22 -0
- package/src/core/outbox-entry-builders.ts +91 -0
- package/src/core/outbox-file-transfer-bookkeeping.ts +31 -0
- package/src/core/outbox-file-transfer-failure.ts +25 -0
- package/src/core/outbox-file-transfer-guards.ts +66 -0
- package/src/core/outbox-file-transfer-prep.ts +31 -0
- package/src/core/outbox-file-transfer-success.ts +34 -0
- package/src/core/outbox-push-args.ts +67 -0
- package/src/core/outbox-queue.ts +69 -0
- package/src/core/outbox-summary.ts +14 -0
- package/src/core/outbox-text-push-failure.ts +10 -0
- package/src/core/outbox-text-push-guards.ts +51 -0
- package/src/core/outbox-text-push-prep.ts +36 -0
- package/src/core/outbox-text-push-success.ts +62 -0
- package/src/core/register-trace.ts +110 -0
- package/src/core/status.ts +52 -0
- package/src/messaging/inbound/dispatch.ts +86 -48
- package/src/messaging/outbound/diagnostics.ts +246 -0
- package/src/messaging/outbound/media-dedupe.ts +51 -0
- package/src/messaging/outbound/queue-selectors.ts +186 -0
- package/src/messaging/outbound/reasons.ts +48 -0
- package/src/messaging/outbound/reply-enqueue.ts +329 -0
- package/src/messaging/outbound/retry-policy.ts +133 -0
- package/src/messaging/outbound/session-route.ts +34 -5
package/src/channel.ts
CHANGED
|
@@ -25,12 +25,66 @@ import {
|
|
|
25
25
|
resolveDefaultDisplayName,
|
|
26
26
|
} from './core/accounts.ts';
|
|
27
27
|
import { BncrConfigSchema } from './core/config-schema.ts';
|
|
28
|
+
import {
|
|
29
|
+
applyOutboundCapability,
|
|
30
|
+
buildCapabilitySnapshot,
|
|
31
|
+
clearOutboundCapability,
|
|
32
|
+
findCapabilityConnection,
|
|
33
|
+
} from './core/connection-capability.ts';
|
|
34
|
+
import {
|
|
35
|
+
buildFileTransferOutboxEntry as buildFileTransferOutboxEntryFromRuntime,
|
|
36
|
+
buildTextOutboxEntry as buildTextOutboxEntryFromRuntime,
|
|
37
|
+
} from './core/outbox-entry-builders.ts';
|
|
38
|
+
import { buildOutboxEnqueueDebugInfo } from './core/outbox-enqueue.ts';
|
|
39
|
+
import {
|
|
40
|
+
appendDeadLetter,
|
|
41
|
+
buildDeadLetterEntry,
|
|
42
|
+
collectDueOutboxEntries,
|
|
43
|
+
} from './core/outbox-queue.ts';
|
|
44
|
+
import { resolveFileTransferGuard } from './core/outbox-file-transfer-guards.ts';
|
|
45
|
+
import { prepareFileTransferRouteSelection } from './core/outbox-file-transfer-prep.ts';
|
|
46
|
+
import {
|
|
47
|
+
buildFileTransferPushOkArgs,
|
|
48
|
+
buildFileTransferPushSuccessArgs,
|
|
49
|
+
} from './core/outbox-file-transfer-bookkeeping.ts';
|
|
50
|
+
import {
|
|
51
|
+
buildFileTransferPushFailureArgs,
|
|
52
|
+
resolveFileTransferFailureState,
|
|
53
|
+
} from './core/outbox-file-transfer-failure.ts';
|
|
54
|
+
import {
|
|
55
|
+
buildFileTransferBroadcastPayload,
|
|
56
|
+
buildFileTransferRouteSelectArgs,
|
|
57
|
+
} from './core/outbox-file-transfer-success.ts';
|
|
58
|
+
import { summarizeOutboxEntry } from './core/outbox-summary.ts';
|
|
59
|
+
import { resolveTextPushGuard } from './core/outbox-text-push-guards.ts';
|
|
60
|
+
import { prepareTextPushRouteSelection } from './core/outbox-text-push-prep.ts';
|
|
61
|
+
import { buildTextPushFailureArgs } from './core/outbox-text-push-failure.ts';
|
|
62
|
+
import {
|
|
63
|
+
buildTextPushBroadcastPayload,
|
|
64
|
+
buildTextPushOkArgs,
|
|
65
|
+
buildTextPushRouteSelectArgs,
|
|
66
|
+
buildTextPushSuccessArgs,
|
|
67
|
+
} from './core/outbox-text-push-success.ts';
|
|
68
|
+
import {
|
|
69
|
+
getRevalidatedAttemptReason,
|
|
70
|
+
hasAlternativeLiveConnection as hasAlternativeLiveConnectionFromRuntime,
|
|
71
|
+
hasRecentInboundReachability as hasRecentInboundReachabilityFromRuntime,
|
|
72
|
+
isRecentlyReachableConn as isRecentlyReachableConnFromRuntime,
|
|
73
|
+
resolveRecentInboundConnIds as resolveRecentInboundConnIdsFromRuntime,
|
|
74
|
+
} from './core/connection-reachability.ts';
|
|
75
|
+
import { buildDiagnosticsPayload } from './core/diagnostics.ts';
|
|
76
|
+
import { buildDownlinkHealth as buildDownlinkHealthFromRuntime } from './core/downlink-health.ts';
|
|
77
|
+
import { buildExtendedDiagnostics as buildExtendedDiagnosticsFromRuntime } from './core/extended-diagnostics.ts';
|
|
78
|
+
import { observeLeaseState, matchesTransferOwner as matchesTransferOwnerFromRuntime } from './core/lease-state.ts';
|
|
28
79
|
import { emitBncrLog, emitBncrLogLine } from './core/logging.ts';
|
|
29
|
-
import { buildBncrPermissionSummary } from './core/permissions.ts';
|
|
30
80
|
import { resolveBncrChannelPolicy } from './core/policy.ts';
|
|
31
|
-
import {
|
|
81
|
+
import {
|
|
82
|
+
buildRegisterTraceSummary as buildRegisterTraceSummaryFromEntries,
|
|
83
|
+
classifyRegisterTrace as classifyRegisterTraceFromStack,
|
|
84
|
+
} from './core/register-trace.ts';
|
|
32
85
|
import {
|
|
33
86
|
buildAccountRuntimeSnapshot,
|
|
87
|
+
buildAccountStatusSnapshot,
|
|
34
88
|
buildIntegratedDiagnostics as buildIntegratedDiagnosticsFromRuntime,
|
|
35
89
|
buildStatusHeadlineFromRuntime,
|
|
36
90
|
buildStatusMetaFromRuntime,
|
|
@@ -66,6 +120,194 @@ import {
|
|
|
66
120
|
buildBncrMediaOutboundFrame,
|
|
67
121
|
resolveBncrOutboundMessageType,
|
|
68
122
|
} from './messaging/outbound/media.ts';
|
|
123
|
+
import {
|
|
124
|
+
buildEnqueueFromReplyDebugInfo,
|
|
125
|
+
buildFlushDebugInfo,
|
|
126
|
+
buildOutboxAckDebugInfo,
|
|
127
|
+
buildOutboxPushOkDebugInfo,
|
|
128
|
+
buildOutboxPushSkipDebugInfo,
|
|
129
|
+
buildOutboxRouteSelectDebugInfo,
|
|
130
|
+
buildOutboxScheduleDebugInfo,
|
|
131
|
+
buildPushFailureDebugInfo,
|
|
132
|
+
buildReplyMediaFallbackDebugInfo,
|
|
133
|
+
buildRetryRerouteDebugInfo,
|
|
134
|
+
} from './messaging/outbound/diagnostics.ts';
|
|
135
|
+
|
|
136
|
+
function buildInboundAcceptedLifecycleDebugInfo(args: {
|
|
137
|
+
stage: 'accepted';
|
|
138
|
+
bridge: string;
|
|
139
|
+
accountId: string;
|
|
140
|
+
connId: string;
|
|
141
|
+
clientId?: string;
|
|
142
|
+
outboundReady: boolean;
|
|
143
|
+
preferredForOutbound: boolean;
|
|
144
|
+
inboundOnly: boolean;
|
|
145
|
+
onlineAfterSeen: boolean;
|
|
146
|
+
recentInboundReachable: boolean;
|
|
147
|
+
activeConnectionKey: string | null;
|
|
148
|
+
activeConnections: Array<{
|
|
149
|
+
connId: string;
|
|
150
|
+
clientId?: string;
|
|
151
|
+
connectedAt: number;
|
|
152
|
+
lastSeenAt: number;
|
|
153
|
+
}>;
|
|
154
|
+
}) {
|
|
155
|
+
return {
|
|
156
|
+
stage: args.stage,
|
|
157
|
+
bridge: args.bridge,
|
|
158
|
+
accountId: args.accountId,
|
|
159
|
+
connId: args.connId,
|
|
160
|
+
clientId: args.clientId,
|
|
161
|
+
outboundReady: args.outboundReady,
|
|
162
|
+
preferredForOutbound: args.preferredForOutbound,
|
|
163
|
+
inboundOnly: args.inboundOnly,
|
|
164
|
+
onlineAfterSeen: args.onlineAfterSeen,
|
|
165
|
+
recentInboundReachable: args.recentInboundReachable,
|
|
166
|
+
activeConnectionKey: args.activeConnectionKey,
|
|
167
|
+
activeConnections: args.activeConnections,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function resolveInboundSessionContext(args: {
|
|
172
|
+
cfg: any;
|
|
173
|
+
accountId: string;
|
|
174
|
+
peer: { kind: string } & Record<string, unknown>;
|
|
175
|
+
route: BncrRoute;
|
|
176
|
+
sessionKeyFromRoute?: string;
|
|
177
|
+
canonicalAgentId: string;
|
|
178
|
+
taskKey?: string;
|
|
179
|
+
text: string;
|
|
180
|
+
extractedText?: string;
|
|
181
|
+
resolveAgentRoute: (params: { cfg: any; channel: string; accountId: string; peer: unknown }) => {
|
|
182
|
+
sessionKey: string;
|
|
183
|
+
};
|
|
184
|
+
}) {
|
|
185
|
+
const resolvedRoute = args.resolveAgentRoute({
|
|
186
|
+
cfg: args.cfg,
|
|
187
|
+
channel: CHANNEL_ID,
|
|
188
|
+
accountId: args.accountId,
|
|
189
|
+
peer: args.peer,
|
|
190
|
+
});
|
|
191
|
+
const baseSessionKey =
|
|
192
|
+
normalizeInboundSessionKey(args.sessionKeyFromRoute, args.route, args.canonicalAgentId) ||
|
|
193
|
+
resolvedRoute.sessionKey;
|
|
194
|
+
const taskSessionKey = withTaskSessionKey(baseSessionKey, args.taskKey);
|
|
195
|
+
return {
|
|
196
|
+
resolvedRoute,
|
|
197
|
+
baseSessionKey,
|
|
198
|
+
taskSessionKey,
|
|
199
|
+
sessionKey: taskSessionKey || baseSessionKey,
|
|
200
|
+
inboundText: asString(args.extractedText || args.text || ''),
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function buildInboundResponsePayload(
|
|
205
|
+
args:
|
|
206
|
+
| {
|
|
207
|
+
kind: 'stale-ignored';
|
|
208
|
+
accountId: string;
|
|
209
|
+
msgId?: string | null;
|
|
210
|
+
}
|
|
211
|
+
| {
|
|
212
|
+
kind: 'invalid-peer';
|
|
213
|
+
}
|
|
214
|
+
| {
|
|
215
|
+
kind: 'duplicated';
|
|
216
|
+
accountId: string;
|
|
217
|
+
msgId?: string | null;
|
|
218
|
+
}
|
|
219
|
+
| {
|
|
220
|
+
kind: 'gate-denied';
|
|
221
|
+
accountId: string;
|
|
222
|
+
msgId?: string | null;
|
|
223
|
+
reason: string;
|
|
224
|
+
}
|
|
225
|
+
| {
|
|
226
|
+
kind: 'accepted';
|
|
227
|
+
accountId: string;
|
|
228
|
+
sessionKey: string;
|
|
229
|
+
msgId?: string | null;
|
|
230
|
+
taskKey?: string | null;
|
|
231
|
+
},
|
|
232
|
+
) {
|
|
233
|
+
switch (args.kind) {
|
|
234
|
+
case 'stale-ignored':
|
|
235
|
+
return {
|
|
236
|
+
accepted: false,
|
|
237
|
+
stale: true,
|
|
238
|
+
ignored: true,
|
|
239
|
+
accountId: args.accountId,
|
|
240
|
+
msgId: args.msgId ?? null,
|
|
241
|
+
};
|
|
242
|
+
case 'invalid-peer':
|
|
243
|
+
return { error: 'platform/groupId/userId required' };
|
|
244
|
+
case 'duplicated':
|
|
245
|
+
return {
|
|
246
|
+
accepted: true,
|
|
247
|
+
duplicated: true,
|
|
248
|
+
accountId: args.accountId,
|
|
249
|
+
msgId: args.msgId ?? null,
|
|
250
|
+
};
|
|
251
|
+
case 'gate-denied':
|
|
252
|
+
return {
|
|
253
|
+
accepted: false,
|
|
254
|
+
accountId: args.accountId,
|
|
255
|
+
msgId: args.msgId ?? null,
|
|
256
|
+
reason: args.reason,
|
|
257
|
+
};
|
|
258
|
+
case 'accepted':
|
|
259
|
+
return {
|
|
260
|
+
accepted: true,
|
|
261
|
+
accountId: args.accountId,
|
|
262
|
+
sessionKey: args.sessionKey,
|
|
263
|
+
msgId: args.msgId ?? null,
|
|
264
|
+
taskKey: args.taskKey ?? null,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
import {
|
|
269
|
+
buildMediaTextFallback,
|
|
270
|
+
type MediaDedupeCacheEntry,
|
|
271
|
+
normalizeMessageText,
|
|
272
|
+
normalizeReplyToId,
|
|
273
|
+
} from './messaging/outbound/media-dedupe.ts';
|
|
274
|
+
import {
|
|
275
|
+
buildReplyTextOutboxEntry,
|
|
276
|
+
enqueueNormalizedReplyPayload,
|
|
277
|
+
enqueueReplyMediaFallbackTextEntry,
|
|
278
|
+
enqueueReplyMediaFileTransferEntry,
|
|
279
|
+
enqueueSingleReplyMediaEntry,
|
|
280
|
+
enqueueReplyTextEntry,
|
|
281
|
+
hasReplyMediaEntries,
|
|
282
|
+
normalizeReplyPayload,
|
|
283
|
+
type NormalizedReplyPayload,
|
|
284
|
+
type ReplyMediaEntriesParams,
|
|
285
|
+
type ReplyMediaFileTransferParams,
|
|
286
|
+
type ReplyPayloadInput,
|
|
287
|
+
} from './messaging/outbound/reply-enqueue.ts';
|
|
288
|
+
import {
|
|
289
|
+
OUTBOUND_DEGRADE_REASON,
|
|
290
|
+
OUTBOUND_FLUSH_REASON,
|
|
291
|
+
OUTBOUND_FLUSH_TRIGGER,
|
|
292
|
+
OUTBOUND_SCHEDULE_SOURCE,
|
|
293
|
+
OUTBOUND_TERMINAL_REASON,
|
|
294
|
+
} from './messaging/outbound/reasons.ts';
|
|
295
|
+
import {
|
|
296
|
+
buildOutboxOnlineDebugInfo,
|
|
297
|
+
clampOutboxDrainDelay,
|
|
298
|
+
computeNextOutboxDelay,
|
|
299
|
+
computeOutboxRetryWait,
|
|
300
|
+
findDueOutboxEntry,
|
|
301
|
+
listAccountOutboxEntries,
|
|
302
|
+
selectOutboxFileTransferRouteCandidates,
|
|
303
|
+
selectOutboxRouteCandidates,
|
|
304
|
+
selectOutboxTargetAccounts,
|
|
305
|
+
updateMinOutboxDelay,
|
|
306
|
+
} from './messaging/outbound/queue-selectors.ts';
|
|
307
|
+
import {
|
|
308
|
+
computePushFailureDecision,
|
|
309
|
+
computeRetryRerouteDecision,
|
|
310
|
+
} from './messaging/outbound/retry-policy.ts';
|
|
69
311
|
import { sendBncrMedia, sendBncrText } from './messaging/outbound/send.ts';
|
|
70
312
|
import { resolveBncrOutboundSessionRoute } from './messaging/outbound/session-route.ts';
|
|
71
313
|
import {
|
|
@@ -248,48 +490,6 @@ function normalizeBncrSendParams(input: {
|
|
|
248
490
|
};
|
|
249
491
|
}
|
|
250
492
|
|
|
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
493
|
|
|
294
494
|
function now() {
|
|
295
495
|
return Date.now();
|
|
@@ -455,6 +655,9 @@ class BncrBridgeRuntime {
|
|
|
455
655
|
private lastActivityByAccount = new Map<string, number>();
|
|
456
656
|
private lastInboundByAccount = new Map<string, number>();
|
|
457
657
|
private lastOutboundByAccount = new Map<string, number>();
|
|
658
|
+
private lastAckOkByAccount = new Map<string, number>();
|
|
659
|
+
private lastAckTimeoutByAccount = new Map<string, number>();
|
|
660
|
+
private ackTimeoutCountByAccount = new Map<string, number>();
|
|
458
661
|
private channelAccountTimers = new Map<string, NodeJS.Timeout>();
|
|
459
662
|
private logDedupeState = new Map<string, { at: number; sig: string }>();
|
|
460
663
|
private canonicalAgentId: string | null = null;
|
|
@@ -472,6 +675,11 @@ class BncrBridgeRuntime {
|
|
|
472
675
|
private pushTimer: NodeJS.Timeout | null = null;
|
|
473
676
|
private pushDrainRunningAccounts = new Set<string>();
|
|
474
677
|
private messageAckWaiters = new Map<
|
|
678
|
+
// Refactor boundary note (message ACK runtime):
|
|
679
|
+
// These waiters are part of the outbound message-ack lifecycle, not just a utility map.
|
|
680
|
+
// They are coupled to shutdown cleanup, resolveMessageAck, waitForMessageAck, outbox retry
|
|
681
|
+
// decisions, and diagnostics counts. Any future extraction should move lifecycle tests first,
|
|
682
|
+
// then move storage + resolver/wait APIs together rather than partially splitting the map only.
|
|
475
683
|
string,
|
|
476
684
|
{
|
|
477
685
|
resolve: (result: 'acked' | 'timeout') => void;
|
|
@@ -639,11 +847,15 @@ class BncrBridgeRuntime {
|
|
|
639
847
|
}
|
|
640
848
|
|
|
641
849
|
private logOutboundSummary(entry: OutboxEntry) {
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
850
|
+
this.logInfo(
|
|
851
|
+
'outbound',
|
|
852
|
+
summarizeOutboxEntry({
|
|
853
|
+
entry,
|
|
854
|
+
asString,
|
|
855
|
+
formatDisplayScope,
|
|
856
|
+
summarizeTextPreview: (raw, limit) => this.summarizeTextPreview(raw, limit),
|
|
857
|
+
}),
|
|
858
|
+
);
|
|
647
859
|
}
|
|
648
860
|
|
|
649
861
|
private clearChannelAccountWorker(accountId: string, reason: string) {
|
|
@@ -659,41 +871,6 @@ class BncrBridgeRuntime {
|
|
|
659
871
|
return true;
|
|
660
872
|
}
|
|
661
873
|
|
|
662
|
-
private classifyRegisterTrace(stack: string) {
|
|
663
|
-
if (
|
|
664
|
-
stack.includes('prepareSecretsRuntimeSnapshot') ||
|
|
665
|
-
stack.includes('resolveRuntimeWebTools') ||
|
|
666
|
-
stack.includes('resolvePluginWebSearchProviders')
|
|
667
|
-
) {
|
|
668
|
-
return 'runtime/webtools';
|
|
669
|
-
}
|
|
670
|
-
if (stack.includes('startGatewayServer') || stack.includes('loadGatewayPlugins')) {
|
|
671
|
-
return 'gateway/startup';
|
|
672
|
-
}
|
|
673
|
-
if (stack.includes('resolvePluginImplicitProviders')) {
|
|
674
|
-
return 'provider/discovery/implicit';
|
|
675
|
-
}
|
|
676
|
-
if (stack.includes('resolvePluginDiscoveryProviders')) {
|
|
677
|
-
return 'provider/discovery/discovery';
|
|
678
|
-
}
|
|
679
|
-
if (stack.includes('resolvePluginProviders')) {
|
|
680
|
-
return 'provider/discovery/providers';
|
|
681
|
-
}
|
|
682
|
-
return 'other';
|
|
683
|
-
}
|
|
684
|
-
|
|
685
|
-
private dominantRegisterBucket(sourceBuckets: Record<string, number>) {
|
|
686
|
-
let winner: string | null = null;
|
|
687
|
-
let winnerCount = -1;
|
|
688
|
-
for (const [bucket, count] of Object.entries(sourceBuckets)) {
|
|
689
|
-
if (count > winnerCount) {
|
|
690
|
-
winner = bucket;
|
|
691
|
-
winnerCount = count;
|
|
692
|
-
}
|
|
693
|
-
}
|
|
694
|
-
return winner;
|
|
695
|
-
}
|
|
696
|
-
|
|
697
874
|
private captureDriftSnapshot(
|
|
698
875
|
summary: ReturnType<BncrBridgeRuntime['buildRegisterTraceSummary']>,
|
|
699
876
|
) {
|
|
@@ -713,41 +890,11 @@ class BncrBridgeRuntime {
|
|
|
713
890
|
}
|
|
714
891
|
|
|
715
892
|
private buildRegisterTraceSummary() {
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
const baseline = this.firstRegisterAt;
|
|
722
|
-
|
|
723
|
-
for (const trace of this.registerTraceRecent) {
|
|
724
|
-
buckets[trace.stackBucket] = (buckets[trace.stackBucket] || 0) + 1;
|
|
725
|
-
const isWarmup = baseline != null && trace.ts - baseline <= REGISTER_WARMUP_WINDOW_MS;
|
|
726
|
-
if (isWarmup) {
|
|
727
|
-
warmupCount += 1;
|
|
728
|
-
} else {
|
|
729
|
-
postWarmupCount += 1;
|
|
730
|
-
unexpectedRegisterAfterWarmup = true;
|
|
731
|
-
lastUnexpectedRegisterAt = trace.ts;
|
|
732
|
-
}
|
|
733
|
-
}
|
|
734
|
-
|
|
735
|
-
const dominantBucket = this.dominantRegisterBucket(buckets);
|
|
736
|
-
const likelyRuntimeRegistryDrift = postWarmupCount > 0;
|
|
737
|
-
const likelyStartupFanoutOnly = warmupCount > 0 && postWarmupCount === 0;
|
|
738
|
-
|
|
739
|
-
return {
|
|
740
|
-
startupWindowMs: REGISTER_WARMUP_WINDOW_MS,
|
|
741
|
-
traceWindowSize: this.registerTraceRecent.length,
|
|
742
|
-
sourceBuckets: buckets,
|
|
743
|
-
dominantBucket,
|
|
744
|
-
warmupRegisterCount: warmupCount,
|
|
745
|
-
postWarmupRegisterCount: postWarmupCount,
|
|
746
|
-
unexpectedRegisterAfterWarmup,
|
|
747
|
-
lastUnexpectedRegisterAt,
|
|
748
|
-
likelyRuntimeRegistryDrift,
|
|
749
|
-
likelyStartupFanoutOnly,
|
|
750
|
-
};
|
|
893
|
+
return buildRegisterTraceSummaryFromEntries({
|
|
894
|
+
traceRecent: this.registerTraceRecent,
|
|
895
|
+
firstRegisterAt: this.firstRegisterAt,
|
|
896
|
+
warmupWindowMs: REGISTER_WARMUP_WINDOW_MS,
|
|
897
|
+
});
|
|
751
898
|
}
|
|
752
899
|
|
|
753
900
|
noteRegister(meta: {
|
|
@@ -778,7 +925,7 @@ class BncrBridgeRuntime {
|
|
|
778
925
|
.map((line) => line.trim())
|
|
779
926
|
.filter(Boolean)
|
|
780
927
|
.join(' <- ');
|
|
781
|
-
const stackBucket =
|
|
928
|
+
const stackBucket = classifyRegisterTraceFromStack(stack);
|
|
782
929
|
|
|
783
930
|
const trace = {
|
|
784
931
|
ts,
|
|
@@ -849,48 +996,21 @@ class BncrBridgeRuntime {
|
|
|
849
996
|
const leaseId = typeof params.leaseId === 'string' ? params.leaseId.trim() : '';
|
|
850
997
|
const connectionEpoch =
|
|
851
998
|
typeof params.connectionEpoch === 'number' ? params.connectionEpoch : undefined;
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
connectionEpoch
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
if (!stale) return
|
|
861
|
-
this.staleCounters.lastStaleAt = now();
|
|
862
|
-
switch (kind) {
|
|
863
|
-
case 'connect':
|
|
864
|
-
this.staleCounters.staleConnect += 1;
|
|
865
|
-
break;
|
|
866
|
-
case 'inbound':
|
|
867
|
-
this.staleCounters.staleInbound += 1;
|
|
868
|
-
break;
|
|
869
|
-
case 'activity':
|
|
870
|
-
this.staleCounters.staleActivity += 1;
|
|
871
|
-
break;
|
|
872
|
-
case 'ack':
|
|
873
|
-
this.staleCounters.staleAck += 1;
|
|
874
|
-
break;
|
|
875
|
-
case 'file.init':
|
|
876
|
-
this.staleCounters.staleFileInit += 1;
|
|
877
|
-
break;
|
|
878
|
-
case 'file.chunk':
|
|
879
|
-
this.staleCounters.staleFileChunk += 1;
|
|
880
|
-
break;
|
|
881
|
-
case 'file.complete':
|
|
882
|
-
this.staleCounters.staleFileComplete += 1;
|
|
883
|
-
break;
|
|
884
|
-
case 'file.abort':
|
|
885
|
-
this.staleCounters.staleFileAbort += 1;
|
|
886
|
-
break;
|
|
887
|
-
}
|
|
999
|
+
const observed = observeLeaseState({
|
|
1000
|
+
kind,
|
|
1001
|
+
params,
|
|
1002
|
+
currentLeaseId: this.primaryLeaseId,
|
|
1003
|
+
currentConnectionEpoch: this.connectionEpoch,
|
|
1004
|
+
now: now(),
|
|
1005
|
+
staleCounters: this.staleCounters,
|
|
1006
|
+
});
|
|
1007
|
+
if (!observed.stale) return observed;
|
|
888
1008
|
this.logWarn(
|
|
889
1009
|
'stale',
|
|
890
1010
|
`observed kind=${kind} lease=${leaseId || '-'} epoch=${connectionEpoch ?? '-'} currentLease=${this.primaryLeaseId || '-'} currentEpoch=${this.connectionEpoch}`,
|
|
891
1011
|
{ debugOnly: true },
|
|
892
1012
|
);
|
|
893
|
-
return
|
|
1013
|
+
return observed;
|
|
894
1014
|
}
|
|
895
1015
|
|
|
896
1016
|
private shouldIgnoreStaleEvent(params: {
|
|
@@ -923,20 +1043,14 @@ class BncrBridgeRuntime {
|
|
|
923
1043
|
connId: string;
|
|
924
1044
|
clientId?: string;
|
|
925
1045
|
}) {
|
|
926
|
-
|
|
927
|
-
const sameClient =
|
|
928
|
-
!params.ownerConnId &&
|
|
929
|
-
!!params.ownerClientId &&
|
|
930
|
-
!!params.clientId &&
|
|
931
|
-
params.ownerClientId === params.clientId;
|
|
932
|
-
return sameConn || sameClient;
|
|
1046
|
+
return matchesTransferOwnerFromRuntime(params);
|
|
933
1047
|
}
|
|
934
1048
|
|
|
935
1049
|
private buildExtendedDiagnostics(accountId: string) {
|
|
936
1050
|
const acc = normalizeAccountId(accountId);
|
|
937
1051
|
const diagnostics = this.buildIntegratedDiagnostics(acc) as Record<string, any>;
|
|
938
|
-
return {
|
|
939
|
-
|
|
1052
|
+
return buildExtendedDiagnosticsFromRuntime({
|
|
1053
|
+
diagnostics,
|
|
940
1054
|
register: {
|
|
941
1055
|
bridgeId: this.bridgeId,
|
|
942
1056
|
gatewayPid: this.gatewayPid,
|
|
@@ -949,7 +1063,7 @@ class BncrBridgeRuntime {
|
|
|
949
1063
|
lastRegisterAt: this.lastRegisterAt,
|
|
950
1064
|
lastApiRebindAt: this.lastApiRebindAt,
|
|
951
1065
|
apiGeneration: this.apiGeneration,
|
|
952
|
-
traceRecent: this.registerTraceRecent
|
|
1066
|
+
traceRecent: this.registerTraceRecent,
|
|
953
1067
|
traceSummary: this.buildRegisterTraceSummary(),
|
|
954
1068
|
lastDriftSnapshot: this.lastDriftSnapshot,
|
|
955
1069
|
},
|
|
@@ -983,8 +1097,8 @@ class BncrBridgeRuntime {
|
|
|
983
1097
|
staleRejectFile: false,
|
|
984
1098
|
},
|
|
985
1099
|
},
|
|
986
|
-
stale:
|
|
987
|
-
};
|
|
1100
|
+
stale: this.staleCounters,
|
|
1101
|
+
});
|
|
988
1102
|
}
|
|
989
1103
|
|
|
990
1104
|
isDebugEnabled(): boolean {
|
|
@@ -1183,27 +1297,22 @@ class BncrBridgeRuntime {
|
|
|
1183
1297
|
}
|
|
1184
1298
|
|
|
1185
1299
|
private buildIntegratedDiagnostics(accountId: string) {
|
|
1300
|
+
return buildIntegratedDiagnosticsFromRuntime(this.buildRuntimeStatusInput(accountId));
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
private buildDownlinkHealth(accountId: string) {
|
|
1186
1304
|
const acc = normalizeAccountId(accountId);
|
|
1187
|
-
return
|
|
1305
|
+
return buildDownlinkHealthFromRuntime({
|
|
1188
1306
|
accountId: acc,
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
activityEvents: this.getCounter(this.activityEventsByAccount, acc),
|
|
1196
|
-
ackEvents: this.getCounter(this.ackEventsByAccount, acc),
|
|
1197
|
-
startedAt: this.startedAt,
|
|
1198
|
-
lastSession: this.lastSessionByAccount.get(acc) || null,
|
|
1199
|
-
lastActivityAt: this.lastActivityByAccount.get(acc) || null,
|
|
1307
|
+
now: now(),
|
|
1308
|
+
outboxEntries: this.outbox.values(),
|
|
1309
|
+
lastAckOkAt: this.lastAckOkByAccount.get(acc) || null,
|
|
1310
|
+
lastAckTimeoutAt: this.lastAckTimeoutByAccount.get(acc) || null,
|
|
1311
|
+
recentAckTimeoutCount: this.getCounter(this.ackTimeoutCountByAccount, acc),
|
|
1312
|
+
activeConnectionCount: this.activeConnectionCount(acc),
|
|
1200
1313
|
lastInboundAt: this.lastInboundByAccount.get(acc) || null,
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
.length,
|
|
1204
|
-
invalidOutboxSessionKeys: this.countInvalidOutboxSessionKeys(acc),
|
|
1205
|
-
legacyAccountResidue: this.countLegacyAccountResidue(acc),
|
|
1206
|
-
channelRoot: path.join(process.cwd(), 'plugins', 'bncr'),
|
|
1314
|
+
lastActivityAt: this.lastActivityByAccount.get(acc) || null,
|
|
1315
|
+
onlineByConn: this.isOnline(acc),
|
|
1207
1316
|
});
|
|
1208
1317
|
}
|
|
1209
1318
|
|
|
@@ -1571,6 +1680,11 @@ class BncrBridgeRuntime {
|
|
|
1571
1680
|
}
|
|
1572
1681
|
|
|
1573
1682
|
private resolvePushConnIds(accountId: string): Set<string> {
|
|
1683
|
+
// Refactor boundary note (route selection):
|
|
1684
|
+
// This selector is not a pure lookup. It combines active-owner preference, outbound readiness,
|
|
1685
|
+
// preferred-for-outbound windows, recent inbound reachability, timeout penalties, and a final
|
|
1686
|
+
// fallback pass over live connections. If this logic is extracted later, first isolate the
|
|
1687
|
+
// candidate scoring / ordering into a pure function and keep the current fallback semantics intact.
|
|
1574
1688
|
const acc = normalizeAccountId(accountId);
|
|
1575
1689
|
const t = now();
|
|
1576
1690
|
const connIds = new Set<string>();
|
|
@@ -1650,112 +1764,62 @@ class BncrBridgeRuntime {
|
|
|
1650
1764
|
|
|
1651
1765
|
private hasRecentInboundReachability(accountId: string): boolean {
|
|
1652
1766
|
const acc = normalizeAccountId(accountId);
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1767
|
+
return hasRecentInboundReachabilityFromRuntime({
|
|
1768
|
+
now: now(),
|
|
1769
|
+
windowMs: RECENT_INBOUND_SEND_WINDOW_MS,
|
|
1770
|
+
lastInboundAt: this.lastInboundByAccount.get(acc) || 0,
|
|
1771
|
+
lastActivityAt: this.lastActivityByAccount.get(acc) || 0,
|
|
1772
|
+
});
|
|
1658
1773
|
}
|
|
1659
1774
|
|
|
1660
1775
|
private resolveRecentInboundConnIds(accountId: string): Set<string> {
|
|
1661
1776
|
const acc = normalizeAccountId(accountId);
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
if (t - c.lastSeenAt > CONNECT_TTL_MS * 2) continue;
|
|
1670
|
-
connIds.add(c.connId);
|
|
1671
|
-
}
|
|
1672
|
-
|
|
1673
|
-
return connIds;
|
|
1777
|
+
return resolveRecentInboundConnIdsFromRuntime({
|
|
1778
|
+
accountId: acc,
|
|
1779
|
+
now: now(),
|
|
1780
|
+
connectTtlMs: CONNECT_TTL_MS,
|
|
1781
|
+
recentInboundReachable: this.hasRecentInboundReachability(acc),
|
|
1782
|
+
connections: this.connections.values(),
|
|
1783
|
+
});
|
|
1674
1784
|
}
|
|
1675
1785
|
|
|
1676
1786
|
private isRecentlyReachableConn(accountId: string, connId?: string, clientId?: string): boolean {
|
|
1677
1787
|
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
1788
|
const activeKey = this.activeConnectionByAccount.get(acc);
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1789
|
+
const active = activeKey ? this.connections.get(activeKey) || null : null;
|
|
1790
|
+
return isRecentlyReachableConnFromRuntime({
|
|
1791
|
+
accountId: acc,
|
|
1792
|
+
connId,
|
|
1793
|
+
clientId,
|
|
1794
|
+
recentConnIds: this.resolveRecentInboundConnIds(acc),
|
|
1795
|
+
activeConnection: active,
|
|
1796
|
+
});
|
|
1692
1797
|
}
|
|
1693
1798
|
|
|
1694
1799
|
private isRevalidatedAttemptedConn(entry: OutboxEntry, connId: string): boolean {
|
|
1695
|
-
const targetConnId = asString(connId || '').trim();
|
|
1696
|
-
if (!targetConnId) return false;
|
|
1697
|
-
|
|
1698
1800
|
const acc = normalizeAccountId(entry.accountId);
|
|
1699
|
-
const
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
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
|
-
}
|
|
1801
|
+
const revalidated = getRevalidatedAttemptReason({
|
|
1802
|
+
entry,
|
|
1803
|
+
connId,
|
|
1804
|
+
accountId: acc,
|
|
1805
|
+
now: now(),
|
|
1806
|
+
connectTtlMs: CONNECT_TTL_MS,
|
|
1807
|
+
recentInboundReachable: this.hasRecentInboundReachability(acc),
|
|
1808
|
+
connections: this.connections.values(),
|
|
1809
|
+
});
|
|
1810
|
+
if (!revalidated) return false;
|
|
1757
1811
|
|
|
1758
|
-
|
|
1812
|
+
this.logInfo(
|
|
1813
|
+
'outbox',
|
|
1814
|
+
`revalidated-retry ${JSON.stringify({
|
|
1815
|
+
messageId: entry.messageId,
|
|
1816
|
+
accountId: acc,
|
|
1817
|
+
connId: String(connId || '').trim(),
|
|
1818
|
+
...revalidated,
|
|
1819
|
+
})}`,
|
|
1820
|
+
{ debugOnly: true },
|
|
1821
|
+
);
|
|
1822
|
+
return true;
|
|
1759
1823
|
}
|
|
1760
1824
|
|
|
1761
1825
|
private tryAdoptTransferOwner(args: {
|
|
@@ -1800,6 +1864,174 @@ class BncrBridgeRuntime {
|
|
|
1800
1864
|
return retryableMarkers.some((marker) => msg.includes(marker));
|
|
1801
1865
|
}
|
|
1802
1866
|
|
|
1867
|
+
private async pushFileTransferSuccessPath(args: {
|
|
1868
|
+
entry: OutboxEntry;
|
|
1869
|
+
meta: Record<string, unknown>;
|
|
1870
|
+
owner: ReturnType<BncrBridgeRuntime['resolveOutboxPushOwner']>;
|
|
1871
|
+
connIds: Iterable<string>;
|
|
1872
|
+
recentInboundReachable: boolean;
|
|
1873
|
+
routeReason: string;
|
|
1874
|
+
mediaUrl: string;
|
|
1875
|
+
}): Promise<void> {
|
|
1876
|
+
const media = await this.transferMediaToBncrClient({
|
|
1877
|
+
accountId: args.entry.accountId,
|
|
1878
|
+
sessionKey: args.entry.sessionKey,
|
|
1879
|
+
route: args.entry.route,
|
|
1880
|
+
mediaUrl: args.mediaUrl,
|
|
1881
|
+
mediaLocalRoots: Array.isArray(args.meta.mediaLocalRoots)
|
|
1882
|
+
? args.meta.mediaLocalRoots.filter((v): v is string => typeof v === 'string')
|
|
1883
|
+
: undefined,
|
|
1884
|
+
});
|
|
1885
|
+
const frame = this.buildFileTransferOutboundFrame({
|
|
1886
|
+
entry: args.entry,
|
|
1887
|
+
meta: args.meta,
|
|
1888
|
+
media,
|
|
1889
|
+
mediaUrl: args.mediaUrl,
|
|
1890
|
+
});
|
|
1891
|
+
|
|
1892
|
+
this.gatewayContext!.broadcastToConnIds(
|
|
1893
|
+
BNCR_PUSH_EVENT,
|
|
1894
|
+
buildFileTransferBroadcastPayload({
|
|
1895
|
+
frame,
|
|
1896
|
+
messageId: args.entry.messageId,
|
|
1897
|
+
}),
|
|
1898
|
+
args.connIds,
|
|
1899
|
+
);
|
|
1900
|
+
this.logOutboxRouteSelect(
|
|
1901
|
+
buildFileTransferRouteSelectArgs({
|
|
1902
|
+
entry: args.entry,
|
|
1903
|
+
connIds: args.connIds,
|
|
1904
|
+
routeReason: args.routeReason,
|
|
1905
|
+
recentInboundReachable: args.recentInboundReachable,
|
|
1906
|
+
owner: args.owner,
|
|
1907
|
+
event: BNCR_PUSH_EVENT,
|
|
1908
|
+
}),
|
|
1909
|
+
);
|
|
1910
|
+
this.recordOutboxPushSuccess(
|
|
1911
|
+
buildFileTransferPushSuccessArgs({
|
|
1912
|
+
entry: args.entry,
|
|
1913
|
+
connIds: args.connIds,
|
|
1914
|
+
owner: args.owner,
|
|
1915
|
+
}),
|
|
1916
|
+
);
|
|
1917
|
+
this.logOutboxPushOkSummary(args.entry.messageId);
|
|
1918
|
+
this.logOutboxPushOk(
|
|
1919
|
+
buildFileTransferPushOkArgs({
|
|
1920
|
+
entry: args.entry,
|
|
1921
|
+
connIds: args.connIds,
|
|
1922
|
+
recentInboundReachable: args.recentInboundReachable,
|
|
1923
|
+
event: BNCR_PUSH_EVENT,
|
|
1924
|
+
}),
|
|
1925
|
+
);
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
private handleFileTransferPushFailure(args: {
|
|
1929
|
+
entry: OutboxEntry;
|
|
1930
|
+
error: unknown;
|
|
1931
|
+
}) {
|
|
1932
|
+
this.recordOutboxPushFailure({
|
|
1933
|
+
entry: args.entry,
|
|
1934
|
+
error: args.error,
|
|
1935
|
+
fallbackError: 'file-transfer-error',
|
|
1936
|
+
persist: true,
|
|
1937
|
+
});
|
|
1938
|
+
const failure = resolveFileTransferFailureState({
|
|
1939
|
+
entry: args.entry,
|
|
1940
|
+
error: args.error,
|
|
1941
|
+
isRetryableFileTransferError: (value) => this.isRetryableFileTransferError(value),
|
|
1942
|
+
});
|
|
1943
|
+
this.logOutboxPushFailureSummary(args.entry.messageId, args.entry.lastError);
|
|
1944
|
+
this.logOutboxPushFailure(
|
|
1945
|
+
buildFileTransferPushFailureArgs({
|
|
1946
|
+
entry: args.entry,
|
|
1947
|
+
retryable: failure.retryable,
|
|
1948
|
+
}),
|
|
1949
|
+
);
|
|
1950
|
+
if (!failure.retryable) {
|
|
1951
|
+
this.moveToDeadLetter(args.entry, failure.deadLetterReason);
|
|
1952
|
+
}
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1955
|
+
private handleFileTransferPushGuardFailure(args: {
|
|
1956
|
+
entry: OutboxEntry;
|
|
1957
|
+
guard: Exclude<ReturnType<typeof resolveFileTransferGuard>, { ok: true }>;
|
|
1958
|
+
}) {
|
|
1959
|
+
this.recordOutboxPrePushFailure({
|
|
1960
|
+
entry: args.entry,
|
|
1961
|
+
lastError: args.guard.lastError,
|
|
1962
|
+
});
|
|
1963
|
+
if (args.guard.reason === 'media-url-missing') {
|
|
1964
|
+
this.logOutboxPushFailure({
|
|
1965
|
+
messageId: args.entry.messageId,
|
|
1966
|
+
accountId: args.entry.accountId,
|
|
1967
|
+
retryCount: args.entry.retryCount,
|
|
1968
|
+
kind: 'file-transfer',
|
|
1969
|
+
lastError: args.entry.lastError,
|
|
1970
|
+
});
|
|
1971
|
+
return;
|
|
1972
|
+
}
|
|
1973
|
+
this.logOutboxPushSkip({
|
|
1974
|
+
messageId: args.entry.messageId,
|
|
1975
|
+
accountId: args.entry.accountId,
|
|
1976
|
+
kind: 'file-transfer',
|
|
1977
|
+
reason: args.guard.reason === 'no-gateway-context' ? 'no-gateway-context' : 'no-active-connection',
|
|
1978
|
+
recentInboundReachable:
|
|
1979
|
+
args.guard.reason === 'no-active-connection' ? args.guard.recentInboundReachable : undefined,
|
|
1980
|
+
});
|
|
1981
|
+
}
|
|
1982
|
+
|
|
1983
|
+
private async tryPushFileTransferEntry(
|
|
1984
|
+
entry: OutboxEntry,
|
|
1985
|
+
meta: Record<string, unknown>,
|
|
1986
|
+
): Promise<boolean> {
|
|
1987
|
+
const ctx = this.gatewayContext;
|
|
1988
|
+
const owner = this.resolveOutboxPushOwner(entry.accountId);
|
|
1989
|
+
const selection = prepareFileTransferRouteSelection({
|
|
1990
|
+
entry,
|
|
1991
|
+
owner,
|
|
1992
|
+
resolvePushConnIds: (accountId) => this.resolvePushConnIds(accountId),
|
|
1993
|
+
resolveRecentInboundConnIds: (accountId) => this.resolveRecentInboundConnIds(accountId),
|
|
1994
|
+
hasRecentInboundReachability: (accountId) => this.hasRecentInboundReachability(accountId),
|
|
1995
|
+
isRevalidatedAttemptedConn: (connId) => this.isRevalidatedAttemptedConn(entry, connId),
|
|
1996
|
+
selectOutboxFileTransferRouteCandidates,
|
|
1997
|
+
});
|
|
1998
|
+
const guard = resolveFileTransferGuard({
|
|
1999
|
+
gatewayContext: ctx,
|
|
2000
|
+
entry,
|
|
2001
|
+
owner,
|
|
2002
|
+
routeSelection: selection,
|
|
2003
|
+
mediaUrl: asString(meta.mediaUrl || '').trim(),
|
|
2004
|
+
});
|
|
2005
|
+
if (!guard.ok) {
|
|
2006
|
+
this.handleFileTransferPushGuardFailure({
|
|
2007
|
+
entry,
|
|
2008
|
+
guard,
|
|
2009
|
+
});
|
|
2010
|
+
return false;
|
|
2011
|
+
}
|
|
2012
|
+
|
|
2013
|
+
const { connIds, recentInboundReachable, routeReason, mediaUrl } = guard;
|
|
2014
|
+
|
|
2015
|
+
try {
|
|
2016
|
+
await this.pushFileTransferSuccessPath({
|
|
2017
|
+
entry,
|
|
2018
|
+
meta,
|
|
2019
|
+
owner,
|
|
2020
|
+
connIds,
|
|
2021
|
+
recentInboundReachable,
|
|
2022
|
+
routeReason,
|
|
2023
|
+
mediaUrl,
|
|
2024
|
+
});
|
|
2025
|
+
return true;
|
|
2026
|
+
} catch (error) {
|
|
2027
|
+
this.handleFileTransferPushFailure({
|
|
2028
|
+
entry,
|
|
2029
|
+
error,
|
|
2030
|
+
});
|
|
2031
|
+
return false;
|
|
2032
|
+
}
|
|
2033
|
+
}
|
|
2034
|
+
|
|
1803
2035
|
private buildFileTransferOutboxEntry(params: {
|
|
1804
2036
|
accountId: string;
|
|
1805
2037
|
sessionKey: string;
|
|
@@ -1812,31 +2044,22 @@ class BncrBridgeRuntime {
|
|
|
1812
2044
|
kind?: 'tool' | 'block' | 'final';
|
|
1813
2045
|
replyToId?: string;
|
|
1814
2046
|
}): OutboxEntry {
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
2047
|
+
return buildFileTransferOutboxEntryFromRuntime({
|
|
2048
|
+
createMessageId: () => randomUUID(),
|
|
2049
|
+
now,
|
|
2050
|
+
normalizeAccountId,
|
|
2051
|
+
pushEvent: BNCR_PUSH_EVENT,
|
|
2052
|
+
accountId: params.accountId,
|
|
1819
2053
|
sessionKey: params.sessionKey,
|
|
1820
2054
|
route: params.route,
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
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
|
-
};
|
|
2055
|
+
mediaUrl: params.mediaUrl,
|
|
2056
|
+
mediaLocalRoots: params.mediaLocalRoots,
|
|
2057
|
+
text: asString(params.text || ''),
|
|
2058
|
+
asVoice: params.asVoice,
|
|
2059
|
+
audioAsVoice: params.audioAsVoice,
|
|
2060
|
+
kind: params.kind,
|
|
2061
|
+
replyToId: asString(params.replyToId || '').trim() || undefined,
|
|
2062
|
+
});
|
|
1840
2063
|
}
|
|
1841
2064
|
|
|
1842
2065
|
private pruneMediaDedupeCache(sessionKey: string, currentTime = now()) {
|
|
@@ -1906,6 +2129,39 @@ class BncrBridgeRuntime {
|
|
|
1906
2129
|
});
|
|
1907
2130
|
}
|
|
1908
2131
|
|
|
2132
|
+
private buildFileTransferOutboundFrame(params: {
|
|
2133
|
+
entry: OutboxEntry;
|
|
2134
|
+
meta: Record<string, unknown>;
|
|
2135
|
+
media: { fileName?: string; mimeType?: string; path?: string; base64?: string; type?: string };
|
|
2136
|
+
mediaUrl: string;
|
|
2137
|
+
}) {
|
|
2138
|
+
const wantsVoice = params.meta.asVoice === true || params.meta.audioAsVoice === true;
|
|
2139
|
+
const messageKind =
|
|
2140
|
+
params.meta.messageKind === 'tool' ||
|
|
2141
|
+
params.meta.messageKind === 'block' ||
|
|
2142
|
+
params.meta.messageKind === 'final'
|
|
2143
|
+
? params.meta.messageKind
|
|
2144
|
+
: undefined;
|
|
2145
|
+
|
|
2146
|
+
return buildBncrMediaOutboundFrame({
|
|
2147
|
+
messageId: params.entry.messageId,
|
|
2148
|
+
sessionKey: params.entry.sessionKey,
|
|
2149
|
+
route: params.entry.route,
|
|
2150
|
+
media: params.media,
|
|
2151
|
+
mediaUrl: params.mediaUrl,
|
|
2152
|
+
mediaMsg: asString(params.meta.text || ''),
|
|
2153
|
+
fileName: resolveOutboundFileName({
|
|
2154
|
+
mediaUrl: params.mediaUrl,
|
|
2155
|
+
fileName: params.media.fileName,
|
|
2156
|
+
mimeType: params.media.mimeType,
|
|
2157
|
+
}),
|
|
2158
|
+
hintedType: wantsVoice ? 'voice' : undefined,
|
|
2159
|
+
kind: messageKind,
|
|
2160
|
+
replyToId: normalizeReplyToId(params.meta.replyToId) || undefined,
|
|
2161
|
+
now: now(),
|
|
2162
|
+
});
|
|
2163
|
+
}
|
|
2164
|
+
|
|
1909
2165
|
private buildTextOutboxEntry(params: {
|
|
1910
2166
|
accountId: string;
|
|
1911
2167
|
sessionKey: string;
|
|
@@ -1914,375 +2170,717 @@ class BncrBridgeRuntime {
|
|
|
1914
2170
|
kind?: 'tool' | 'block' | 'final';
|
|
1915
2171
|
replyToId?: string;
|
|
1916
2172
|
}): OutboxEntry {
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
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),
|
|
2173
|
+
return buildTextOutboxEntryFromRuntime({
|
|
2174
|
+
createMessageId: () => randomUUID(),
|
|
2175
|
+
now,
|
|
2176
|
+
normalizeAccountId,
|
|
2177
|
+
normalizeReplyToId,
|
|
2178
|
+
accountId: params.accountId,
|
|
1941
2179
|
sessionKey: params.sessionKey,
|
|
1942
2180
|
route: params.route,
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
};
|
|
2181
|
+
text: params.text,
|
|
2182
|
+
kind: params.kind,
|
|
2183
|
+
replyToId: params.replyToId,
|
|
2184
|
+
});
|
|
1948
2185
|
}
|
|
1949
2186
|
|
|
1950
2187
|
private async tryPushEntry(entry: OutboxEntry): Promise<boolean> {
|
|
1951
2188
|
const meta = isPlainObject(entry.payload?._meta) ? entry.payload._meta : null;
|
|
1952
2189
|
if (meta?.kind === 'file-transfer') {
|
|
1953
|
-
|
|
1954
|
-
|
|
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
|
-
}
|
|
2190
|
+
return this.tryPushFileTransferEntry(entry, meta);
|
|
2191
|
+
}
|
|
1969
2192
|
|
|
1970
|
-
|
|
1971
|
-
|
|
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
|
-
}
|
|
2193
|
+
return this.tryPushTextEntry(entry);
|
|
2194
|
+
}
|
|
2020
2195
|
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2196
|
+
private pushTextSuccessPath(args: {
|
|
2197
|
+
entry: OutboxEntry;
|
|
2198
|
+
owner: ReturnType<BncrBridgeRuntime['resolveOutboxPushOwner']>;
|
|
2199
|
+
connIds: Iterable<string>;
|
|
2200
|
+
recentInboundReachable: boolean;
|
|
2201
|
+
routeReason: string;
|
|
2202
|
+
ownerConnId?: string;
|
|
2203
|
+
}) {
|
|
2204
|
+
this.gatewayContext!.broadcastToConnIds(
|
|
2205
|
+
BNCR_PUSH_EVENT,
|
|
2206
|
+
buildTextPushBroadcastPayload({
|
|
2207
|
+
payload: args.entry.payload,
|
|
2208
|
+
messageId: args.entry.messageId,
|
|
2209
|
+
}),
|
|
2210
|
+
args.connIds,
|
|
2211
|
+
);
|
|
2212
|
+
this.logOutboxRouteSelect(
|
|
2213
|
+
buildTextPushRouteSelectArgs({
|
|
2214
|
+
entry: args.entry,
|
|
2215
|
+
connIds: args.connIds,
|
|
2216
|
+
routeReason: args.routeReason,
|
|
2217
|
+
recentInboundReachable: args.recentInboundReachable,
|
|
2218
|
+
owner: args.owner,
|
|
2219
|
+
event: BNCR_PUSH_EVENT,
|
|
2220
|
+
}),
|
|
2221
|
+
);
|
|
2222
|
+
this.recordOutboxPushSuccess(
|
|
2223
|
+
buildTextPushSuccessArgs({
|
|
2224
|
+
entry: args.entry,
|
|
2225
|
+
connIds: args.connIds,
|
|
2226
|
+
ownerConnId: args.ownerConnId,
|
|
2227
|
+
ownerClientId: args.ownerConnId ? args.owner?.clientId : undefined,
|
|
2228
|
+
}),
|
|
2229
|
+
);
|
|
2230
|
+
this.logOutboxPushOkSummary(args.entry.messageId);
|
|
2231
|
+
this.logOutboxPushOk(
|
|
2232
|
+
buildTextPushOkArgs({
|
|
2233
|
+
entry: args.entry,
|
|
2234
|
+
connIds: args.connIds,
|
|
2235
|
+
recentInboundReachable: args.recentInboundReachable,
|
|
2236
|
+
event: BNCR_PUSH_EVENT,
|
|
2237
|
+
}),
|
|
2238
|
+
);
|
|
2239
|
+
}
|
|
2037
2240
|
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2241
|
+
private handleTextPushFailure(args: {
|
|
2242
|
+
entry: OutboxEntry;
|
|
2243
|
+
error: unknown;
|
|
2244
|
+
}) {
|
|
2245
|
+
this.recordOutboxPushFailure({
|
|
2246
|
+
entry: args.entry,
|
|
2247
|
+
error: args.error,
|
|
2248
|
+
fallbackError: 'push-error',
|
|
2249
|
+
});
|
|
2250
|
+
this.logOutboxPushFailureSummary(args.entry.messageId, args.entry.lastError);
|
|
2251
|
+
this.logOutboxPushFailure(buildTextPushFailureArgs({ entry: args.entry }));
|
|
2252
|
+
}
|
|
2253
|
+
|
|
2254
|
+
private async tryPushTextEntry(entry: OutboxEntry): Promise<boolean> {
|
|
2255
|
+
const ctx = this.gatewayContext;
|
|
2256
|
+
const owner = this.resolveOutboxPushOwner(entry.accountId);
|
|
2257
|
+
const selection = prepareTextPushRouteSelection({
|
|
2258
|
+
entry,
|
|
2259
|
+
owner,
|
|
2260
|
+
resolvePushConnIds: (accountId) => this.resolvePushConnIds(accountId),
|
|
2261
|
+
resolveRecentInboundConnIds: (accountId) => this.resolveRecentInboundConnIds(accountId),
|
|
2262
|
+
hasRecentInboundReachability: (accountId) => this.hasRecentInboundReachability(accountId),
|
|
2263
|
+
isRevalidatedAttemptedConn: (connId) => this.isRevalidatedAttemptedConn(entry, connId),
|
|
2264
|
+
selectOutboxRouteCandidates,
|
|
2265
|
+
});
|
|
2266
|
+
const guard = resolveTextPushGuard({
|
|
2267
|
+
gatewayContext: ctx,
|
|
2268
|
+
entry,
|
|
2269
|
+
routeSelection: selection,
|
|
2270
|
+
});
|
|
2271
|
+
if (!guard.ok) {
|
|
2272
|
+
this.logOutboxPushSkip({
|
|
2273
|
+
messageId: entry.messageId,
|
|
2274
|
+
accountId: entry.accountId,
|
|
2275
|
+
reason: guard.reason,
|
|
2276
|
+
recentInboundReachable:
|
|
2277
|
+
guard.reason === 'no-active-connection' ? guard.recentInboundReachable : undefined,
|
|
2278
|
+
});
|
|
2279
|
+
return false;
|
|
2280
|
+
}
|
|
2281
|
+
|
|
2282
|
+
const { connIds, recentInboundReachable, routeReason, ownerConnId } = guard;
|
|
2283
|
+
|
|
2284
|
+
try {
|
|
2285
|
+
this.pushTextSuccessPath({
|
|
2286
|
+
entry,
|
|
2287
|
+
owner,
|
|
2288
|
+
connIds,
|
|
2289
|
+
recentInboundReachable,
|
|
2290
|
+
routeReason,
|
|
2291
|
+
ownerConnId,
|
|
2292
|
+
});
|
|
2293
|
+
return true;
|
|
2294
|
+
} catch (error) {
|
|
2295
|
+
this.handleTextPushFailure({
|
|
2296
|
+
entry,
|
|
2297
|
+
error,
|
|
2298
|
+
});
|
|
2299
|
+
return false;
|
|
2300
|
+
}
|
|
2301
|
+
}
|
|
2302
|
+
|
|
2303
|
+
private logOutboxPushSkip(args: {
|
|
2304
|
+
messageId: string;
|
|
2305
|
+
accountId: string;
|
|
2306
|
+
kind?: 'file-transfer';
|
|
2307
|
+
reason: string;
|
|
2308
|
+
recentInboundReachable?: boolean;
|
|
2309
|
+
}) {
|
|
2310
|
+
this.logInfo(
|
|
2311
|
+
'outbox',
|
|
2312
|
+
`push-skip ${JSON.stringify(buildOutboxPushSkipDebugInfo(args))}`,
|
|
2313
|
+
{ debugOnly: true },
|
|
2314
|
+
);
|
|
2315
|
+
}
|
|
2316
|
+
|
|
2317
|
+
private logOutboxRouteSelect(args: {
|
|
2318
|
+
messageId: string;
|
|
2319
|
+
accountId: string;
|
|
2320
|
+
kind?: 'file-transfer';
|
|
2321
|
+
routeReason: string;
|
|
2322
|
+
connIds: Iterable<string>;
|
|
2323
|
+
ownerConnId: string;
|
|
2324
|
+
ownerClientId: string;
|
|
2325
|
+
recentInboundReachable: boolean;
|
|
2326
|
+
event: string;
|
|
2327
|
+
}) {
|
|
2328
|
+
this.logInfo(
|
|
2329
|
+
'outbox',
|
|
2330
|
+
`route-select ${JSON.stringify(buildOutboxRouteSelectDebugInfo(args))}`,
|
|
2331
|
+
{ debugOnly: true },
|
|
2332
|
+
);
|
|
2333
|
+
}
|
|
2334
|
+
|
|
2335
|
+
private logOutboxPushFailure(args: {
|
|
2336
|
+
messageId: string;
|
|
2337
|
+
accountId: string;
|
|
2338
|
+
retryCount: number;
|
|
2339
|
+
kind?: 'file-transfer';
|
|
2340
|
+
retryable?: boolean;
|
|
2341
|
+
lastError?: string;
|
|
2342
|
+
}) {
|
|
2343
|
+
this.logInfo(
|
|
2344
|
+
'outbox',
|
|
2345
|
+
`push-fail ${JSON.stringify(buildPushFailureDebugInfo(args))}`,
|
|
2346
|
+
{ debugOnly: true },
|
|
2347
|
+
);
|
|
2348
|
+
}
|
|
2349
|
+
|
|
2350
|
+
private logOutboxPushOkSummary(messageId: string) {
|
|
2351
|
+
this.logInfo('outbox push', `mid=${messageId}|q=${this.outbox.size}`);
|
|
2352
|
+
}
|
|
2353
|
+
|
|
2354
|
+
private logOutboxPushFailureSummary(messageId: string, lastError?: string) {
|
|
2355
|
+
this.logInfo('outbox push fail', `mid=${messageId}|q=${this.outbox.size}|err=${lastError}`);
|
|
2356
|
+
}
|
|
2357
|
+
|
|
2358
|
+
private logOutboxAckSummary(
|
|
2359
|
+
scope: 'outbox ack ok' | 'outbox ack retry' | 'outbox ack timeout' | 'outbox ack fatal',
|
|
2360
|
+
args: {
|
|
2361
|
+
messageId: string;
|
|
2362
|
+
connId?: string;
|
|
2363
|
+
clientId?: string;
|
|
2364
|
+
err?: string;
|
|
2365
|
+
},
|
|
2366
|
+
) {
|
|
2367
|
+
const parts = [`mid=${args.messageId}`, `q=${this.outbox.size}`];
|
|
2368
|
+
if (args.connId) parts.push(`conn=${args.connId}`);
|
|
2369
|
+
if (args.clientId) parts.push(`client=${args.clientId}`);
|
|
2370
|
+
if (args.err) parts.push(`err=${args.err}`);
|
|
2371
|
+
this.logInfo(scope, parts.join('|'));
|
|
2372
|
+
}
|
|
2373
|
+
|
|
2374
|
+
private logOutboxAckWait(args: {
|
|
2375
|
+
entry: OutboxEntry;
|
|
2376
|
+
requireAck: boolean;
|
|
2377
|
+
ackResult: 'acked' | 'timeout';
|
|
2378
|
+
onlineNow: boolean;
|
|
2379
|
+
recentInboundReachable: boolean;
|
|
2380
|
+
}) {
|
|
2381
|
+
this.logInfo(
|
|
2382
|
+
'outbox',
|
|
2383
|
+
`ack ${JSON.stringify(
|
|
2384
|
+
buildOutboxAckDebugInfo({
|
|
2385
|
+
messageId: args.entry.messageId,
|
|
2386
|
+
accountId: args.entry.accountId,
|
|
2062
2387
|
kind:
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
meta.messageKind === 'final'
|
|
2066
|
-
? meta.messageKind
|
|
2388
|
+
isPlainObject(args.entry.payload?._meta) && args.entry.payload?._meta?.kind === 'file-transfer'
|
|
2389
|
+
? 'file-transfer'
|
|
2067
2390
|
: undefined,
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2391
|
+
requireAck: args.requireAck,
|
|
2392
|
+
ackResult: args.ackResult,
|
|
2393
|
+
onlineNow: args.onlineNow,
|
|
2394
|
+
recentInboundReachable: args.recentInboundReachable,
|
|
2395
|
+
connIds: args.entry.lastPushConnId ? [args.entry.lastPushConnId] : [],
|
|
2396
|
+
ownerConnId: args.entry.lastPushConnId,
|
|
2397
|
+
ownerClientId: args.entry.lastPushClientId,
|
|
2398
|
+
event: BNCR_PUSH_EVENT,
|
|
2399
|
+
}),
|
|
2400
|
+
)}`,
|
|
2401
|
+
{ debugOnly: true },
|
|
2402
|
+
);
|
|
2403
|
+
}
|
|
2071
2404
|
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2405
|
+
private logOutboxAckReroute(args: {
|
|
2406
|
+
accountId: string;
|
|
2407
|
+
entry: OutboxEntry;
|
|
2408
|
+
requireAck: boolean;
|
|
2409
|
+
currentConnId: string;
|
|
2410
|
+
availableConnIds: string[];
|
|
2411
|
+
decision: ReturnType<typeof computeRetryRerouteDecision>;
|
|
2412
|
+
localNextDelay: number | null;
|
|
2413
|
+
}) {
|
|
2414
|
+
this.logOutboxAckSummary(
|
|
2415
|
+
args.requireAck ? 'outbox ack timeout' : 'outbox ack retry',
|
|
2416
|
+
{
|
|
2417
|
+
messageId: args.entry.messageId,
|
|
2418
|
+
connId: args.entry.lastPushConnId,
|
|
2419
|
+
clientId: args.entry.lastPushClientId,
|
|
2420
|
+
err: args.requireAck ? undefined : args.entry.lastError,
|
|
2421
|
+
},
|
|
2422
|
+
);
|
|
2423
|
+
this.logInfo(
|
|
2424
|
+
'outbox',
|
|
2425
|
+
`retry-reroute ${JSON.stringify(
|
|
2426
|
+
buildRetryRerouteDebugInfo({
|
|
2427
|
+
messageId: args.entry.messageId,
|
|
2428
|
+
accountId: args.accountId,
|
|
2429
|
+
currentConnId: args.currentConnId,
|
|
2430
|
+
decision: args.decision,
|
|
2431
|
+
availableConnIds: args.availableConnIds,
|
|
2432
|
+
}),
|
|
2433
|
+
)}`,
|
|
2434
|
+
{ debugOnly: true },
|
|
2435
|
+
);
|
|
2436
|
+
|
|
2437
|
+
this.logInfo(
|
|
2438
|
+
'outbox',
|
|
2439
|
+
`schedule ${JSON.stringify(
|
|
2440
|
+
buildOutboxScheduleDebugInfo({
|
|
2441
|
+
bridgeId: this.bridgeId,
|
|
2442
|
+
accountId: args.accountId,
|
|
2443
|
+
messageId: args.entry.messageId,
|
|
2444
|
+
source: OUTBOUND_SCHEDULE_SOURCE.RETRY_REROUTE_WAIT,
|
|
2445
|
+
wait: computeOutboxRetryWait(args.decision.nextAttemptAt, now()),
|
|
2446
|
+
localNextDelay: args.localNextDelay,
|
|
2447
|
+
}),
|
|
2448
|
+
)}`,
|
|
2449
|
+
{ debugOnly: true },
|
|
2450
|
+
);
|
|
2451
|
+
}
|
|
2452
|
+
|
|
2453
|
+
private respondAckResult(
|
|
2454
|
+
respond: GatewayRequestHandlerOptions['respond'],
|
|
2455
|
+
stale: boolean,
|
|
2456
|
+
result: { ok: true; movedToDeadLetter?: true; willRetry?: true },
|
|
2457
|
+
) {
|
|
2458
|
+
respond(
|
|
2459
|
+
true,
|
|
2460
|
+
stale
|
|
2461
|
+
? { ...result, stale: true, staleAccepted: true }
|
|
2462
|
+
: result,
|
|
2463
|
+
);
|
|
2464
|
+
}
|
|
2465
|
+
|
|
2466
|
+
private prepareAckHandling(args: {
|
|
2467
|
+
params: any;
|
|
2468
|
+
respond: GatewayRequestHandlerOptions['respond'];
|
|
2469
|
+
client: GatewayRequestHandlerOptions['client'];
|
|
2470
|
+
context: GatewayRequestHandlerOptions['context'];
|
|
2471
|
+
}): {
|
|
2472
|
+
accountId: string;
|
|
2473
|
+
connId: string;
|
|
2474
|
+
clientId?: string;
|
|
2475
|
+
messageId: string;
|
|
2476
|
+
entry: OutboxEntry;
|
|
2477
|
+
staleObserved: { stale: boolean };
|
|
2478
|
+
} | null {
|
|
2479
|
+
const { params, respond, client, context } = args;
|
|
2480
|
+
const accountId = normalizeAccountId(asString(params?.accountId || ''));
|
|
2481
|
+
const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
|
|
2482
|
+
const clientId = asString((params as any)?.clientId || '').trim() || undefined;
|
|
2483
|
+
const messageId = asString(params?.messageId || '').trim();
|
|
2484
|
+
const staleObserved = this.observeLease('ack', params ?? {});
|
|
2485
|
+
|
|
2486
|
+
this.logInfo(
|
|
2487
|
+
'outbox',
|
|
2488
|
+
`ack ${JSON.stringify({
|
|
2489
|
+
accountId,
|
|
2490
|
+
messageId,
|
|
2491
|
+
ok: params?.ok !== false,
|
|
2492
|
+
fatal: params?.fatal === true,
|
|
2493
|
+
error: asString(params?.error || ''),
|
|
2494
|
+
stale: staleObserved.stale,
|
|
2495
|
+
})}`,
|
|
2496
|
+
{ debugOnly: true },
|
|
2497
|
+
);
|
|
2498
|
+
if (!messageId) {
|
|
2499
|
+
respond(false, { error: 'messageId required' });
|
|
2500
|
+
return null;
|
|
2501
|
+
}
|
|
2502
|
+
|
|
2503
|
+
const entry = this.outbox.get(messageId);
|
|
2504
|
+
if (!entry) {
|
|
2505
|
+
respond(true, { ok: true, message: 'already-acked-or-missing', stale: staleObserved.stale });
|
|
2506
|
+
return null;
|
|
2507
|
+
}
|
|
2508
|
+
|
|
2509
|
+
if (entry.accountId !== accountId) {
|
|
2510
|
+
respond(false, { error: 'account mismatch' });
|
|
2511
|
+
return null;
|
|
2512
|
+
}
|
|
2513
|
+
|
|
2514
|
+
if (staleObserved.stale) {
|
|
2515
|
+
const sameConn = !!entry.lastPushConnId && entry.lastPushConnId === connId;
|
|
2516
|
+
const sameClient =
|
|
2517
|
+
!entry.lastPushConnId &&
|
|
2518
|
+
!!entry.lastPushClientId &&
|
|
2519
|
+
!!clientId &&
|
|
2520
|
+
entry.lastPushClientId === clientId;
|
|
2521
|
+
if (!(sameConn || sameClient)) {
|
|
2522
|
+
this.logWarn(
|
|
2523
|
+
'stale',
|
|
2524
|
+
`ignore kind=ack accountId=${accountId} connId=${connId} clientId=${clientId || '-'} messageId=${messageId} reason=owner-mismatch lastPushConnId=${entry.lastPushConnId || '-'} lastPushClientId=${entry.lastPushClientId || '-'}`,
|
|
2141
2525
|
{ debugOnly: true },
|
|
2142
2526
|
);
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2527
|
+
respond(true, { ok: true, stale: true, ignored: true });
|
|
2528
|
+
return null;
|
|
2529
|
+
}
|
|
2530
|
+
} else {
|
|
2531
|
+
this.rememberGatewayContext(context);
|
|
2532
|
+
this.markSeen(accountId, connId, clientId);
|
|
2533
|
+
}
|
|
2534
|
+
|
|
2535
|
+
return {
|
|
2536
|
+
accountId,
|
|
2537
|
+
connId,
|
|
2538
|
+
clientId,
|
|
2539
|
+
messageId,
|
|
2540
|
+
entry,
|
|
2541
|
+
staleObserved,
|
|
2542
|
+
};
|
|
2543
|
+
}
|
|
2544
|
+
|
|
2545
|
+
private handleAckOk(args: {
|
|
2546
|
+
accountId: string;
|
|
2547
|
+
messageId: string;
|
|
2548
|
+
connId: string;
|
|
2549
|
+
clientId?: string;
|
|
2550
|
+
stale: boolean;
|
|
2551
|
+
}) {
|
|
2552
|
+
this.markOutboundCapability({
|
|
2553
|
+
accountId: args.accountId,
|
|
2554
|
+
connId: args.connId,
|
|
2555
|
+
clientId: args.clientId,
|
|
2556
|
+
outboundReady: true,
|
|
2557
|
+
preferredForOutbound: true,
|
|
2558
|
+
});
|
|
2559
|
+
this.lastAckOkByAccount.set(args.accountId, now());
|
|
2560
|
+
this.outbox.delete(args.messageId);
|
|
2561
|
+
this.scheduleSave();
|
|
2562
|
+
this.resolveMessageAck(args.messageId, 'acked');
|
|
2563
|
+
this.logOutboxAckSummary('outbox ack ok', {
|
|
2564
|
+
messageId: args.messageId,
|
|
2565
|
+
connId: args.connId,
|
|
2566
|
+
clientId: args.clientId,
|
|
2567
|
+
});
|
|
2568
|
+
}
|
|
2569
|
+
|
|
2570
|
+
private handleAckFatal(args: {
|
|
2571
|
+
entry: OutboxEntry;
|
|
2572
|
+
messageId: string;
|
|
2573
|
+
connId: string;
|
|
2574
|
+
clientId?: string;
|
|
2575
|
+
error: string;
|
|
2576
|
+
}) {
|
|
2577
|
+
this.moveToDeadLetter(args.entry, args.error);
|
|
2578
|
+
this.logOutboxAckSummary('outbox ack fatal', {
|
|
2579
|
+
messageId: args.messageId,
|
|
2580
|
+
connId: args.connId,
|
|
2581
|
+
clientId: args.clientId,
|
|
2582
|
+
err: args.error,
|
|
2583
|
+
});
|
|
2584
|
+
}
|
|
2585
|
+
|
|
2586
|
+
private handleAckRetry(args: {
|
|
2587
|
+
entry: OutboxEntry;
|
|
2588
|
+
messageId: string;
|
|
2589
|
+
connId: string;
|
|
2590
|
+
clientId?: string;
|
|
2591
|
+
error: string;
|
|
2592
|
+
}) {
|
|
2593
|
+
args.entry.nextAttemptAt = now() + 1_000;
|
|
2594
|
+
args.entry.lastError = args.error;
|
|
2595
|
+
this.outbox.set(args.messageId, args.entry);
|
|
2596
|
+
this.scheduleSave();
|
|
2597
|
+
this.logOutboxAckSummary('outbox ack retry', {
|
|
2598
|
+
messageId: args.messageId,
|
|
2599
|
+
connId: args.connId,
|
|
2600
|
+
clientId: args.clientId,
|
|
2601
|
+
err: args.entry.lastError,
|
|
2602
|
+
});
|
|
2603
|
+
}
|
|
2604
|
+
|
|
2605
|
+
private handleAckOutcome(args: {
|
|
2606
|
+
params: any;
|
|
2607
|
+
respond: GatewayRequestHandlerOptions['respond'];
|
|
2608
|
+
accountId: string;
|
|
2609
|
+
connId: string;
|
|
2610
|
+
clientId?: string;
|
|
2611
|
+
messageId: string;
|
|
2612
|
+
entry: OutboxEntry;
|
|
2613
|
+
staleObserved: { stale: boolean };
|
|
2614
|
+
}) {
|
|
2615
|
+
const { params, respond, accountId, connId, clientId, messageId, entry, staleObserved } = args;
|
|
2616
|
+
const ok = params?.ok !== false;
|
|
2617
|
+
const fatal = params?.fatal === true;
|
|
2618
|
+
|
|
2619
|
+
if (ok) {
|
|
2620
|
+
this.handleAckOk({
|
|
2621
|
+
accountId,
|
|
2622
|
+
messageId,
|
|
2623
|
+
connId,
|
|
2624
|
+
clientId,
|
|
2625
|
+
stale: staleObserved.stale,
|
|
2626
|
+
});
|
|
2627
|
+
this.respondAckResult(respond, staleObserved.stale, { ok: true });
|
|
2628
|
+
this.flushPushQueue({
|
|
2629
|
+
accountId,
|
|
2630
|
+
trigger: OUTBOUND_FLUSH_TRIGGER.ACK_OK,
|
|
2631
|
+
reason: OUTBOUND_FLUSH_REASON.MESSAGE_ACKED,
|
|
2632
|
+
});
|
|
2633
|
+
return;
|
|
2634
|
+
}
|
|
2635
|
+
|
|
2636
|
+
if (fatal) {
|
|
2637
|
+
const error = asString(params?.error || 'fatal-ack');
|
|
2638
|
+
this.handleAckFatal({
|
|
2639
|
+
entry,
|
|
2640
|
+
messageId,
|
|
2641
|
+
connId,
|
|
2642
|
+
clientId,
|
|
2643
|
+
error,
|
|
2644
|
+
});
|
|
2645
|
+
this.respondAckResult(respond, staleObserved.stale, {
|
|
2646
|
+
ok: true,
|
|
2647
|
+
movedToDeadLetter: true,
|
|
2648
|
+
});
|
|
2649
|
+
return;
|
|
2650
|
+
}
|
|
2651
|
+
|
|
2652
|
+
this.handleAckRetry({
|
|
2653
|
+
entry,
|
|
2654
|
+
messageId,
|
|
2655
|
+
connId,
|
|
2656
|
+
clientId,
|
|
2657
|
+
error: asString(params?.error || 'retryable-ack'),
|
|
2658
|
+
});
|
|
2659
|
+
|
|
2660
|
+
this.respondAckResult(respond, staleObserved.stale, {
|
|
2661
|
+
ok: true,
|
|
2662
|
+
willRetry: true,
|
|
2663
|
+
});
|
|
2664
|
+
}
|
|
2665
|
+
|
|
2666
|
+
private prepareInboundAcceptance(args: {
|
|
2667
|
+
parsed: ReturnType<typeof parseBncrInboundParams>;
|
|
2668
|
+
canonicalAgentId: string;
|
|
2669
|
+
}):
|
|
2670
|
+
| {
|
|
2671
|
+
ok: true;
|
|
2672
|
+
accountId: string;
|
|
2673
|
+
sessionKey: string;
|
|
2674
|
+
inboundText: string;
|
|
2675
|
+
hasMedia: boolean;
|
|
2147
2676
|
}
|
|
2677
|
+
| {
|
|
2678
|
+
ok: false;
|
|
2679
|
+
status: boolean;
|
|
2680
|
+
payload: ReturnType<typeof buildInboundResponsePayload>;
|
|
2681
|
+
} {
|
|
2682
|
+
const { parsed, canonicalAgentId } = args;
|
|
2683
|
+
const {
|
|
2684
|
+
accountId,
|
|
2685
|
+
platform,
|
|
2686
|
+
groupId,
|
|
2687
|
+
userId,
|
|
2688
|
+
sessionKeyfromroute,
|
|
2689
|
+
route,
|
|
2690
|
+
text,
|
|
2691
|
+
mediaBase64,
|
|
2692
|
+
mediaPathFromTransfer,
|
|
2693
|
+
msgId,
|
|
2694
|
+
peer,
|
|
2695
|
+
extracted,
|
|
2696
|
+
dedupKey,
|
|
2697
|
+
} = parsed;
|
|
2698
|
+
|
|
2699
|
+
if (!platform || (!userId && !groupId)) {
|
|
2700
|
+
return {
|
|
2701
|
+
ok: false,
|
|
2702
|
+
status: false,
|
|
2703
|
+
payload: buildInboundResponsePayload({ kind: 'invalid-peer' }),
|
|
2704
|
+
};
|
|
2705
|
+
}
|
|
2706
|
+
if (this.markInboundDedupSeen(dedupKey)) {
|
|
2707
|
+
return {
|
|
2708
|
+
ok: false,
|
|
2709
|
+
status: true,
|
|
2710
|
+
payload: buildInboundResponsePayload({
|
|
2711
|
+
kind: 'duplicated',
|
|
2712
|
+
accountId,
|
|
2713
|
+
msgId: msgId ?? null,
|
|
2714
|
+
}),
|
|
2715
|
+
};
|
|
2148
2716
|
}
|
|
2149
2717
|
|
|
2150
|
-
const
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2718
|
+
const cfg = this.api.runtime.config.current();
|
|
2719
|
+
const gate = checkBncrMessageGate({
|
|
2720
|
+
parsed,
|
|
2721
|
+
cfg,
|
|
2722
|
+
account: resolveAccount(cfg, accountId),
|
|
2723
|
+
});
|
|
2724
|
+
if (!gate.allowed) {
|
|
2725
|
+
return {
|
|
2726
|
+
ok: false,
|
|
2727
|
+
status: true,
|
|
2728
|
+
payload: buildInboundResponsePayload({
|
|
2729
|
+
kind: 'gate-denied',
|
|
2730
|
+
accountId,
|
|
2731
|
+
msgId: msgId ?? null,
|
|
2732
|
+
reason: gate.reason,
|
|
2733
|
+
}),
|
|
2734
|
+
};
|
|
2162
2735
|
}
|
|
2163
2736
|
|
|
2164
|
-
const
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2737
|
+
const { sessionKey, inboundText } = resolveInboundSessionContext({
|
|
2738
|
+
cfg,
|
|
2739
|
+
accountId,
|
|
2740
|
+
peer,
|
|
2741
|
+
route,
|
|
2742
|
+
sessionKeyFromRoute: sessionKeyfromroute,
|
|
2743
|
+
canonicalAgentId,
|
|
2744
|
+
taskKey: extracted.taskKey,
|
|
2745
|
+
text,
|
|
2746
|
+
extractedText: extracted.text,
|
|
2747
|
+
resolveAgentRoute: (params) => this.api.runtime.channel.routing.resolveAgentRoute(params),
|
|
2748
|
+
});
|
|
2749
|
+
|
|
2750
|
+
return {
|
|
2751
|
+
ok: true,
|
|
2752
|
+
accountId,
|
|
2753
|
+
sessionKey,
|
|
2754
|
+
inboundText,
|
|
2755
|
+
hasMedia: Boolean(mediaBase64 || mediaPathFromTransfer),
|
|
2756
|
+
};
|
|
2757
|
+
}
|
|
2758
|
+
|
|
2759
|
+
private refreshLiveConnectionState(args: {
|
|
2760
|
+
accountId: string;
|
|
2761
|
+
connId: string;
|
|
2762
|
+
clientId?: string;
|
|
2763
|
+
outboundReady: boolean;
|
|
2764
|
+
preferredForOutbound: boolean;
|
|
2765
|
+
inboundOnly: boolean;
|
|
2766
|
+
context: GatewayRequestHandlerOptions['context'];
|
|
2767
|
+
}) {
|
|
2768
|
+
const { accountId, connId, clientId, outboundReady, preferredForOutbound, inboundOnly, context } = args;
|
|
2769
|
+
this.refreshAcceptedFileTransferLiveState({
|
|
2770
|
+
accountId,
|
|
2771
|
+
connId,
|
|
2772
|
+
clientId,
|
|
2773
|
+
context,
|
|
2774
|
+
});
|
|
2775
|
+
this.markOutboundCapability({
|
|
2776
|
+
accountId,
|
|
2777
|
+
connId,
|
|
2778
|
+
clientId,
|
|
2779
|
+
outboundReady,
|
|
2780
|
+
preferredForOutbound,
|
|
2781
|
+
inboundOnly,
|
|
2782
|
+
});
|
|
2783
|
+
}
|
|
2784
|
+
|
|
2785
|
+
private refreshAcceptedFileTransferLiveState(args: {
|
|
2786
|
+
accountId: string;
|
|
2787
|
+
connId: string;
|
|
2788
|
+
clientId?: string;
|
|
2789
|
+
context: GatewayRequestHandlerOptions['context'];
|
|
2790
|
+
}) {
|
|
2791
|
+
const { accountId, connId, clientId, context } = args;
|
|
2792
|
+
this.rememberGatewayContext(context);
|
|
2793
|
+
this.markSeen(accountId, connId, clientId);
|
|
2794
|
+
this.markActivity(accountId);
|
|
2795
|
+
}
|
|
2796
|
+
|
|
2797
|
+
private logOutboxPushOk(args: {
|
|
2798
|
+
messageId: string;
|
|
2799
|
+
accountId: string;
|
|
2800
|
+
kind?: 'file-transfer';
|
|
2801
|
+
connIds: Iterable<string>;
|
|
2802
|
+
ownerConnId: string;
|
|
2803
|
+
ownerClientId: string;
|
|
2804
|
+
recentInboundReachable: boolean;
|
|
2805
|
+
event: string;
|
|
2806
|
+
}) {
|
|
2807
|
+
this.logInfo(
|
|
2808
|
+
'outbox',
|
|
2809
|
+
`push ${JSON.stringify(buildOutboxPushOkDebugInfo(args))}`,
|
|
2810
|
+
{ debugOnly: true },
|
|
2173
2811
|
);
|
|
2174
|
-
|
|
2175
|
-
const owner = this.resolveOutboxPushOwner(entry.accountId);
|
|
2176
|
-
const ownerConnId = owner?.connId && preferredCandidates.includes(owner.connId) ? owner.connId : undefined;
|
|
2177
|
-
let connIds = ownerConnId ? new Set([ownerConnId]) : new Set(preferredCandidates);
|
|
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';
|
|
2190
|
-
if (!connIds.size && recentInboundReachable) {
|
|
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
|
-
);
|
|
2200
|
-
}
|
|
2201
|
-
if (!connIds.size) {
|
|
2202
|
-
this.logInfo(
|
|
2203
|
-
'outbox',
|
|
2204
|
-
`push-skip ${JSON.stringify({
|
|
2205
|
-
messageId: entry.messageId,
|
|
2206
|
-
accountId: entry.accountId,
|
|
2207
|
-
reason: 'no-active-connection',
|
|
2208
|
-
recentInboundReachable,
|
|
2209
|
-
})}`,
|
|
2210
|
-
{ debugOnly: true },
|
|
2211
|
-
);
|
|
2212
|
-
return false;
|
|
2213
|
-
}
|
|
2812
|
+
}
|
|
2214
2813
|
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2814
|
+
private recordOutboxPrePushFailure(args: {
|
|
2815
|
+
entry: OutboxEntry;
|
|
2816
|
+
lastError: string;
|
|
2817
|
+
}) {
|
|
2818
|
+
args.entry.lastError = args.lastError;
|
|
2819
|
+
this.outbox.set(args.entry.messageId, args.entry);
|
|
2820
|
+
}
|
|
2220
2821
|
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
accountId: entry.accountId,
|
|
2251
|
-
connIds: Array.from(connIds),
|
|
2252
|
-
ownerConnId: entry.lastPushConnId || '',
|
|
2253
|
-
ownerClientId: entry.lastPushClientId || '',
|
|
2254
|
-
recentInboundReachable,
|
|
2255
|
-
event: BNCR_PUSH_EVENT,
|
|
2256
|
-
})}`,
|
|
2257
|
-
{ debugOnly: true },
|
|
2258
|
-
);
|
|
2259
|
-
this.lastOutboundByAccount.set(entry.accountId, entry.lastPushAt);
|
|
2260
|
-
this.markActivity(entry.accountId, entry.lastPushAt);
|
|
2261
|
-
this.scheduleSave();
|
|
2262
|
-
return true;
|
|
2263
|
-
} catch (error) {
|
|
2264
|
-
entry.lastError = asString((error as any)?.message || error || 'push-error');
|
|
2265
|
-
this.outbox.set(entry.messageId, entry);
|
|
2266
|
-
this.logInfo('outbox push fail', `mid=${entry.messageId}|q=${this.outbox.size}|err=${entry.lastError}`);
|
|
2267
|
-
this.logInfo(
|
|
2268
|
-
'outbox',
|
|
2269
|
-
`push-fail ${JSON.stringify({
|
|
2270
|
-
messageId: entry.messageId,
|
|
2271
|
-
accountId: entry.accountId,
|
|
2272
|
-
error: entry.lastError,
|
|
2273
|
-
})}`,
|
|
2274
|
-
{ debugOnly: true },
|
|
2275
|
-
);
|
|
2276
|
-
return false;
|
|
2822
|
+
private recordOutboxPushFailure(args: {
|
|
2823
|
+
entry: OutboxEntry;
|
|
2824
|
+
error: unknown;
|
|
2825
|
+
fallbackError: string;
|
|
2826
|
+
persist?: boolean;
|
|
2827
|
+
}) {
|
|
2828
|
+
args.entry.lastError = asString((args.error as any)?.message || args.error || args.fallbackError);
|
|
2829
|
+
this.outbox.set(args.entry.messageId, args.entry);
|
|
2830
|
+
if (args.persist) this.scheduleSave();
|
|
2831
|
+
}
|
|
2832
|
+
|
|
2833
|
+
private recordOutboxPushSuccess(args: {
|
|
2834
|
+
entry: OutboxEntry;
|
|
2835
|
+
connIds: Iterable<string>;
|
|
2836
|
+
ownerConnId?: string;
|
|
2837
|
+
ownerClientId?: string;
|
|
2838
|
+
clearLastError?: boolean;
|
|
2839
|
+
}) {
|
|
2840
|
+
const connIds = Array.from(args.connIds);
|
|
2841
|
+
args.entry.lastPushAt = now();
|
|
2842
|
+
args.entry.lastPushConnId =
|
|
2843
|
+
args.ownerConnId || (connIds.length === 1 ? connIds[0] : undefined);
|
|
2844
|
+
args.entry.lastPushClientId = args.ownerClientId;
|
|
2845
|
+
if (!Array.isArray(args.entry.routeAttemptConnIds)) args.entry.routeAttemptConnIds = [];
|
|
2846
|
+
if (
|
|
2847
|
+
args.entry.lastPushConnId &&
|
|
2848
|
+
!args.entry.routeAttemptConnIds.includes(args.entry.lastPushConnId)
|
|
2849
|
+
) {
|
|
2850
|
+
args.entry.routeAttemptConnIds.push(args.entry.lastPushConnId);
|
|
2277
2851
|
}
|
|
2852
|
+
if (args.clearLastError) args.entry.lastError = undefined;
|
|
2853
|
+
this.outbox.set(args.entry.messageId, args.entry);
|
|
2854
|
+
this.lastOutboundByAccount.set(args.entry.accountId, args.entry.lastPushAt);
|
|
2855
|
+
this.markActivity(args.entry.accountId, args.entry.lastPushAt);
|
|
2856
|
+
this.scheduleSave();
|
|
2278
2857
|
}
|
|
2279
2858
|
|
|
2280
2859
|
private schedulePushDrain(delayMs = 0) {
|
|
2860
|
+
// Structure note (drain scheduler):
|
|
2861
|
+
// This is the single-timer gate for outbound retry scheduling. It intentionally coalesces
|
|
2862
|
+
// multiple nudges into one pending timer and delegates all actual decision-making to
|
|
2863
|
+
// flushPushQueue. If extracted later, preserve the current "one pending timer per bridge"
|
|
2864
|
+
// behavior so retry cadence and burst control do not change accidentally.
|
|
2281
2865
|
if (this.pushTimer) return;
|
|
2282
|
-
const delay =
|
|
2866
|
+
const delay = clampOutboxDrainDelay(delayMs);
|
|
2867
|
+
this.logInfo(
|
|
2868
|
+
'outbox',
|
|
2869
|
+
`schedule ${JSON.stringify(
|
|
2870
|
+
buildOutboxScheduleDebugInfo({
|
|
2871
|
+
bridgeId: this.bridgeId,
|
|
2872
|
+
source: OUTBOUND_SCHEDULE_SOURCE.SCHEDULE_PUSH_DRAIN,
|
|
2873
|
+
wait: delay,
|
|
2874
|
+
}),
|
|
2875
|
+
)}`,
|
|
2876
|
+
{ debugOnly: true },
|
|
2877
|
+
);
|
|
2283
2878
|
this.pushTimer = setTimeout(() => {
|
|
2284
2879
|
this.pushTimer = null;
|
|
2285
|
-
void this.flushPushQueue({
|
|
2880
|
+
void this.flushPushQueue({
|
|
2881
|
+
trigger: OUTBOUND_FLUSH_TRIGGER.TIMER,
|
|
2882
|
+
reason: OUTBOUND_FLUSH_REASON.SCHEDULED_DRAIN,
|
|
2883
|
+
});
|
|
2286
2884
|
}, delay);
|
|
2287
2885
|
}
|
|
2288
2886
|
|
|
@@ -2327,26 +2925,51 @@ class BncrBridgeRuntime {
|
|
|
2327
2925
|
trigger?: string;
|
|
2328
2926
|
reason?: string;
|
|
2329
2927
|
}): Promise<void> {
|
|
2928
|
+
// Structure guide for future safe extraction:
|
|
2929
|
+
// - pre-check: choose target accounts, skip accounts already draining, emit flush context logs
|
|
2930
|
+
// - tryPush: pick one due entry per account and attempt actual outbound delivery
|
|
2931
|
+
// - ack wait: wait for message ack when policy requires it, then decide whether queue can advance
|
|
2932
|
+
// - degrade: mark timed-out / unconfirmed outbound capability on the attempted owner connection
|
|
2933
|
+
// - reroute: avoid the timed-out route, optionally revalidate a previously-attempted conn, then retry once
|
|
2934
|
+
// - retry scheduling: keep the entry in outbox, compute backoff / nextAttemptAt, and schedule next drain
|
|
2935
|
+
// - dead letter: after max retries, move the entry out of the active outbox into deadLetter
|
|
2936
|
+
//
|
|
2937
|
+
// Wake-source note:
|
|
2938
|
+
// flushPushQueue is entered from several distinct wake sources with different meanings:
|
|
2939
|
+
// - enqueue/manual: a new outbound entry was added and may be due immediately
|
|
2940
|
+
// - timer/scheduled-drain: retry scheduling says a previously-deferred entry is now worth retrying
|
|
2941
|
+
// - connect/ws-online: a transport became available again
|
|
2942
|
+
// - ack-ok/message-acked: one completed message may let the queue advance to the next
|
|
2943
|
+
// - activity/activity-heartbeat: capability/liveness was refreshed
|
|
2944
|
+
// - inbound/inbound-accepted: inbound traffic provided a fresh reachability signal
|
|
2945
|
+
// Keep these wake reasons explicit in future refactors; they are observability and behavior boundaries,
|
|
2946
|
+
// not just log decoration.
|
|
2947
|
+
//
|
|
2948
|
+
// Refactor boundary note:
|
|
2949
|
+
// flushPushQueue is the core outbound state machine. It currently couples queue selection,
|
|
2950
|
+
// route choice, ack policy, degrade/failover, retry timing, and dead-letter transitions.
|
|
2951
|
+
// Future extraction should preserve these semantics first; do not split behavior and routing in
|
|
2952
|
+
// the same change unless tests already lock the full lifecycle.
|
|
2330
2953
|
const filterAcc = args?.accountId ? normalizeAccountId(args.accountId) : null;
|
|
2331
2954
|
const trigger = asString(args?.trigger || '').trim() || 'manual';
|
|
2332
2955
|
const reason = asString(args?.reason || '').trim() || undefined;
|
|
2333
|
-
const targetAccounts =
|
|
2334
|
-
|
|
2335
|
-
:
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
),
|
|
2339
|
-
);
|
|
2956
|
+
const targetAccounts = selectOutboxTargetAccounts({
|
|
2957
|
+
accountId: filterAcc,
|
|
2958
|
+
outboxEntries: this.outbox.values(),
|
|
2959
|
+
normalizeAccountId,
|
|
2960
|
+
});
|
|
2340
2961
|
this.logInfo(
|
|
2341
2962
|
'outbox',
|
|
2342
|
-
`flush ${JSON.stringify(
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2963
|
+
`flush ${JSON.stringify(
|
|
2964
|
+
buildFlushDebugInfo({
|
|
2965
|
+
bridgeId: this.bridgeId,
|
|
2966
|
+
accountId: filterAcc,
|
|
2967
|
+
targetAccounts,
|
|
2968
|
+
outboxSize: this.outbox.size,
|
|
2969
|
+
trigger,
|
|
2970
|
+
reason,
|
|
2971
|
+
}),
|
|
2972
|
+
)}`,
|
|
2350
2973
|
{ debugOnly: true },
|
|
2351
2974
|
);
|
|
2352
2975
|
|
|
@@ -2358,18 +2981,15 @@ class BncrBridgeRuntime {
|
|
|
2358
2981
|
const recentInboundReachable = this.hasRecentInboundReachability(acc);
|
|
2359
2982
|
this.logInfo(
|
|
2360
2983
|
'outbox',
|
|
2361
|
-
`online ${JSON.stringify(
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
lastSeenAt: c.lastSeenAt,
|
|
2371
|
-
})),
|
|
2372
|
-
})}`,
|
|
2984
|
+
`online ${JSON.stringify(
|
|
2985
|
+
buildOutboxOnlineDebugInfo({
|
|
2986
|
+
bridgeId: this.bridgeId,
|
|
2987
|
+
accountId: acc,
|
|
2988
|
+
online,
|
|
2989
|
+
recentInboundReachable,
|
|
2990
|
+
connections: this.connections.values(),
|
|
2991
|
+
}),
|
|
2992
|
+
)}`,
|
|
2373
2993
|
{ debugOnly: true },
|
|
2374
2994
|
);
|
|
2375
2995
|
this.pushDrainRunningAccounts.add(acc);
|
|
@@ -2378,16 +2998,33 @@ class BncrBridgeRuntime {
|
|
|
2378
2998
|
|
|
2379
2999
|
while (true) {
|
|
2380
3000
|
const t = now();
|
|
2381
|
-
const entries =
|
|
2382
|
-
|
|
2383
|
-
|
|
3001
|
+
const entries = listAccountOutboxEntries({
|
|
3002
|
+
accountId: acc,
|
|
3003
|
+
outboxEntries: this.outbox.values(),
|
|
3004
|
+
normalizeAccountId,
|
|
3005
|
+
});
|
|
2384
3006
|
|
|
2385
3007
|
if (!entries.length) break;
|
|
2386
3008
|
|
|
2387
|
-
const entry = entries
|
|
3009
|
+
const entry = findDueOutboxEntry(entries, t);
|
|
2388
3010
|
if (!entry) {
|
|
2389
|
-
const wait =
|
|
2390
|
-
|
|
3011
|
+
const wait = computeNextOutboxDelay(entries, t);
|
|
3012
|
+
if (wait != null) {
|
|
3013
|
+
localNextDelay = updateMinOutboxDelay(localNextDelay, wait);
|
|
3014
|
+
this.logInfo(
|
|
3015
|
+
'outbox',
|
|
3016
|
+
`schedule ${JSON.stringify(
|
|
3017
|
+
buildOutboxScheduleDebugInfo({
|
|
3018
|
+
bridgeId: this.bridgeId,
|
|
3019
|
+
accountId: acc,
|
|
3020
|
+
source: OUTBOUND_SCHEDULE_SOURCE.ACCOUNT_NO_DUE_ENTRY,
|
|
3021
|
+
wait,
|
|
3022
|
+
localNextDelay,
|
|
3023
|
+
}),
|
|
3024
|
+
)}`,
|
|
3025
|
+
{ debugOnly: true },
|
|
3026
|
+
);
|
|
3027
|
+
}
|
|
2391
3028
|
break;
|
|
2392
3029
|
}
|
|
2393
3030
|
|
|
@@ -2401,18 +3038,13 @@ class BncrBridgeRuntime {
|
|
|
2401
3038
|
ackResult = await this.waitForMessageAck(entry.messageId, PUSH_ACK_TIMEOUT_MS);
|
|
2402
3039
|
}
|
|
2403
3040
|
|
|
2404
|
-
this.
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
onlineNow,
|
|
2412
|
-
recentInboundReachable,
|
|
2413
|
-
})}`,
|
|
2414
|
-
{ debugOnly: true },
|
|
2415
|
-
);
|
|
3041
|
+
this.logOutboxAckWait({
|
|
3042
|
+
entry,
|
|
3043
|
+
requireAck,
|
|
3044
|
+
ackResult,
|
|
3045
|
+
onlineNow,
|
|
3046
|
+
recentInboundReachable,
|
|
3047
|
+
});
|
|
2416
3048
|
|
|
2417
3049
|
if (!this.outbox.has(entry.messageId)) {
|
|
2418
3050
|
await this.sleepMs(PUSH_DRAIN_INTERVAL_MS);
|
|
@@ -2429,7 +3061,9 @@ class BncrBridgeRuntime {
|
|
|
2429
3061
|
accountId: acc,
|
|
2430
3062
|
connId: entry.lastPushConnId || undefined,
|
|
2431
3063
|
clientId: entry.lastPushClientId || undefined,
|
|
2432
|
-
reason: requireAck
|
|
3064
|
+
reason: requireAck
|
|
3065
|
+
? OUTBOUND_DEGRADE_REASON.ACK_TIMEOUT
|
|
3066
|
+
: OUTBOUND_DEGRADE_REASON.PUSH_UNCONFIRMED,
|
|
2433
3067
|
});
|
|
2434
3068
|
}
|
|
2435
3069
|
|
|
@@ -2437,62 +3071,59 @@ class BncrBridgeRuntime {
|
|
|
2437
3071
|
? entry.routeAttemptConnIds.filter((v): v is string => typeof v === 'string' && !!v)
|
|
2438
3072
|
: [];
|
|
2439
3073
|
const currentConnId = asString(entry.lastPushConnId || '').trim();
|
|
2440
|
-
if (currentConnId && !attemptedConnIds.includes(currentConnId)) attemptedConnIds.push(currentConnId);
|
|
2441
3074
|
const availableConnIds = Array.from(this.resolvePushConnIds(acc));
|
|
2442
|
-
const
|
|
2443
|
-
|
|
3075
|
+
const decision = computeRetryRerouteDecision(
|
|
3076
|
+
{
|
|
3077
|
+
nowMs: now(),
|
|
3078
|
+
maxRetry: MAX_RETRY,
|
|
3079
|
+
requireAck,
|
|
3080
|
+
currentRetryCount: entry.retryCount,
|
|
3081
|
+
currentRouteAttemptRound: Number(entry.routeAttemptRound || 0),
|
|
3082
|
+
currentFastReroutePending: entry.fastReroutePending === true,
|
|
3083
|
+
lastError: entry.lastError,
|
|
3084
|
+
currentConnId: currentConnId || undefined,
|
|
3085
|
+
attemptedConnIds,
|
|
3086
|
+
availableConnIds,
|
|
3087
|
+
},
|
|
3088
|
+
{ backoffMs },
|
|
2444
3089
|
);
|
|
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
3090
|
|
|
2451
|
-
|
|
2452
|
-
entry.lastAttemptAt = now();
|
|
2453
|
-
if (entry.retryCount > MAX_RETRY) {
|
|
3091
|
+
if (decision.kind === 'dead-letter') {
|
|
2454
3092
|
this.logInfo(
|
|
2455
3093
|
'outbox ack fatal',
|
|
2456
|
-
`mid=${entry.messageId}|q=${this.outbox.size}|err=${
|
|
2457
|
-
);
|
|
2458
|
-
this.moveToDeadLetter(
|
|
2459
|
-
entry,
|
|
2460
|
-
entry.lastError || (requireAck ? 'push-ack-timeout' : 'push-delivery-unconfirmed'),
|
|
3094
|
+
`mid=${entry.messageId}|q=${this.outbox.size}|err=${decision.terminalReason}`,
|
|
2461
3095
|
);
|
|
3096
|
+
this.moveToDeadLetter(entry, decision.terminalReason);
|
|
2462
3097
|
continue;
|
|
2463
3098
|
}
|
|
2464
|
-
|
|
2465
|
-
entry.
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
3099
|
+
|
|
3100
|
+
entry.routeAttemptConnIds = decision.attemptedConnIds;
|
|
3101
|
+
entry.fastReroutePending = decision.fastReroutePending;
|
|
3102
|
+
entry.retryCount = decision.nextRetryCount;
|
|
3103
|
+
entry.lastAttemptAt = decision.lastAttemptAt;
|
|
3104
|
+
entry.nextAttemptAt = decision.nextAttemptAt;
|
|
3105
|
+
entry.lastError = decision.lastError;
|
|
3106
|
+
entry.routeAttemptRound = decision.routeAttemptRound;
|
|
2471
3107
|
this.outbox.set(entry.messageId, entry);
|
|
2472
3108
|
this.scheduleSave();
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
{ debugOnly: true },
|
|
2492
|
-
);
|
|
2493
|
-
|
|
2494
|
-
const wait = Math.max(0, entry.nextAttemptAt - now());
|
|
2495
|
-
localNextDelay = localNextDelay == null ? wait : Math.min(localNextDelay, wait);
|
|
3109
|
+
if (requireAck) {
|
|
3110
|
+
this.lastAckTimeoutByAccount.set(acc, now());
|
|
3111
|
+
this.ackTimeoutCountByAccount.set(
|
|
3112
|
+
acc,
|
|
3113
|
+
this.getCounter(this.ackTimeoutCountByAccount, acc) + 1,
|
|
3114
|
+
);
|
|
3115
|
+
}
|
|
3116
|
+
const wait = computeOutboxRetryWait(decision.nextAttemptAt, now());
|
|
3117
|
+
localNextDelay = updateMinOutboxDelay(localNextDelay, wait);
|
|
3118
|
+
this.logOutboxAckReroute({
|
|
3119
|
+
accountId: acc,
|
|
3120
|
+
entry,
|
|
3121
|
+
requireAck,
|
|
3122
|
+
currentConnId,
|
|
3123
|
+
availableConnIds,
|
|
3124
|
+
decision,
|
|
3125
|
+
localNextDelay,
|
|
3126
|
+
});
|
|
2496
3127
|
await this.sleepMs(PUSH_DRAIN_INTERVAL_MS);
|
|
2497
3128
|
break;
|
|
2498
3129
|
}
|
|
@@ -2502,34 +3133,82 @@ class BncrBridgeRuntime {
|
|
|
2502
3133
|
continue;
|
|
2503
3134
|
}
|
|
2504
3135
|
|
|
2505
|
-
const
|
|
2506
|
-
|
|
2507
|
-
|
|
3136
|
+
const decision = computePushFailureDecision(
|
|
3137
|
+
{
|
|
3138
|
+
nowMs: t,
|
|
3139
|
+
maxRetry: MAX_RETRY,
|
|
3140
|
+
currentRetryCount: entry.retryCount,
|
|
3141
|
+
lastError: entry.lastError,
|
|
3142
|
+
},
|
|
3143
|
+
{ backoffMs },
|
|
3144
|
+
);
|
|
3145
|
+
if (decision.kind === 'dead-letter') {
|
|
3146
|
+
this.moveToDeadLetter(entry, decision.terminalReason);
|
|
2508
3147
|
continue;
|
|
2509
3148
|
}
|
|
2510
3149
|
|
|
2511
|
-
entry.retryCount =
|
|
2512
|
-
entry.lastAttemptAt =
|
|
2513
|
-
entry.nextAttemptAt =
|
|
2514
|
-
entry.lastError =
|
|
3150
|
+
entry.retryCount = decision.nextRetryCount;
|
|
3151
|
+
entry.lastAttemptAt = decision.lastAttemptAt;
|
|
3152
|
+
entry.nextAttemptAt = decision.nextAttemptAt;
|
|
3153
|
+
entry.lastError = decision.lastError;
|
|
2515
3154
|
this.outbox.set(entry.messageId, entry);
|
|
2516
3155
|
this.scheduleSave();
|
|
2517
3156
|
|
|
2518
|
-
const wait =
|
|
2519
|
-
localNextDelay =
|
|
3157
|
+
const wait = computeOutboxRetryWait(decision.nextAttemptAt, t);
|
|
3158
|
+
localNextDelay = updateMinOutboxDelay(localNextDelay, wait);
|
|
3159
|
+
this.logInfo(
|
|
3160
|
+
'outbox',
|
|
3161
|
+
`schedule ${JSON.stringify(
|
|
3162
|
+
buildOutboxScheduleDebugInfo({
|
|
3163
|
+
bridgeId: this.bridgeId,
|
|
3164
|
+
accountId: acc,
|
|
3165
|
+
messageId: entry.messageId,
|
|
3166
|
+
source: OUTBOUND_SCHEDULE_SOURCE.PUSH_FAIL_WAIT,
|
|
3167
|
+
wait,
|
|
3168
|
+
localNextDelay,
|
|
3169
|
+
}),
|
|
3170
|
+
)}`,
|
|
3171
|
+
{ debugOnly: true },
|
|
3172
|
+
);
|
|
2520
3173
|
break;
|
|
2521
3174
|
}
|
|
2522
3175
|
|
|
2523
3176
|
if (localNextDelay != null) {
|
|
2524
|
-
globalNextDelay =
|
|
2525
|
-
|
|
3177
|
+
globalNextDelay = updateMinOutboxDelay(globalNextDelay, localNextDelay);
|
|
3178
|
+
this.logInfo(
|
|
3179
|
+
'outbox',
|
|
3180
|
+
`schedule ${JSON.stringify(
|
|
3181
|
+
buildOutboxScheduleDebugInfo({
|
|
3182
|
+
bridgeId: this.bridgeId,
|
|
3183
|
+
accountId: acc,
|
|
3184
|
+
source: OUTBOUND_SCHEDULE_SOURCE.ACCOUNT_NEXT_DELAY_MERGE,
|
|
3185
|
+
localNextDelay,
|
|
3186
|
+
globalNextDelay,
|
|
3187
|
+
}),
|
|
3188
|
+
)}`,
|
|
3189
|
+
{ debugOnly: true },
|
|
3190
|
+
);
|
|
2526
3191
|
}
|
|
2527
3192
|
} finally {
|
|
2528
3193
|
this.pushDrainRunningAccounts.delete(acc);
|
|
2529
3194
|
}
|
|
2530
3195
|
}
|
|
2531
3196
|
|
|
2532
|
-
if (globalNextDelay != null)
|
|
3197
|
+
if (globalNextDelay != null) {
|
|
3198
|
+
this.logInfo(
|
|
3199
|
+
'outbox',
|
|
3200
|
+
`schedule ${JSON.stringify(
|
|
3201
|
+
buildOutboxScheduleDebugInfo({
|
|
3202
|
+
bridgeId: this.bridgeId,
|
|
3203
|
+
source: OUTBOUND_SCHEDULE_SOURCE.FLUSH_NEXT_DRAIN,
|
|
3204
|
+
globalNextDelay,
|
|
3205
|
+
wait: globalNextDelay,
|
|
3206
|
+
}),
|
|
3207
|
+
)}`,
|
|
3208
|
+
{ debugOnly: true },
|
|
3209
|
+
);
|
|
3210
|
+
this.schedulePushDrain(globalNextDelay);
|
|
3211
|
+
}
|
|
2533
3212
|
}
|
|
2534
3213
|
|
|
2535
3214
|
private async waitForMessageAck(messageId: string, waitMs: number): Promise<'acked' | 'timeout'> {
|
|
@@ -2723,52 +3402,45 @@ class BncrBridgeRuntime {
|
|
|
2723
3402
|
const acc = normalizeAccountId(args.accountId);
|
|
2724
3403
|
const key = this.connectionKey(acc, args.clientId);
|
|
2725
3404
|
const t = Number(args.at || now());
|
|
2726
|
-
const current = this.connections.get(key) as
|
|
2727
|
-
outboundReadyUntil?: number;
|
|
2728
|
-
preferredForOutboundUntil?: number;
|
|
2729
|
-
inboundOnly?: boolean;
|
|
2730
|
-
}) | undefined;
|
|
3405
|
+
const current = this.connections.get(key) as BncrConnection | undefined;
|
|
2731
3406
|
if (!current || current.connId !== args.connId) return;
|
|
2732
3407
|
|
|
2733
|
-
|
|
2734
|
-
current
|
|
2735
|
-
|
|
2736
|
-
|
|
2737
|
-
|
|
2738
|
-
|
|
2739
|
-
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
if (args.preferredForOutbound === true) {
|
|
2743
|
-
current.preferredForOutboundUntil = t + PREFERRED_OUTBOUND_TTL_MS;
|
|
2744
|
-
}
|
|
2745
|
-
}
|
|
3408
|
+
const next = applyOutboundCapability({
|
|
3409
|
+
connection: current,
|
|
3410
|
+
at: t,
|
|
3411
|
+
outboundReadyTtlMs: OUTBOUND_READY_TTL_MS,
|
|
3412
|
+
preferredOutboundTtlMs: PREFERRED_OUTBOUND_TTL_MS,
|
|
3413
|
+
outboundReady: args.outboundReady,
|
|
3414
|
+
preferredForOutbound: args.preferredForOutbound,
|
|
3415
|
+
inboundOnly: args.inboundOnly,
|
|
3416
|
+
});
|
|
2746
3417
|
|
|
2747
|
-
this.connections.set(key,
|
|
3418
|
+
this.connections.set(key, next as BncrConnection);
|
|
3419
|
+
const snapshot = buildCapabilitySnapshot(next);
|
|
2748
3420
|
const connectionCapabilityPayload = {
|
|
2749
3421
|
bridge: this.bridgeId,
|
|
2750
3422
|
accountId: acc,
|
|
2751
|
-
connId:
|
|
2752
|
-
clientId:
|
|
3423
|
+
connId: next.connId,
|
|
3424
|
+
clientId: next.clientId,
|
|
2753
3425
|
outboundReady: args.outboundReady === true,
|
|
2754
3426
|
preferredForOutbound: args.preferredForOutbound === true,
|
|
2755
|
-
inboundOnly:
|
|
2756
|
-
outboundReadyUntil:
|
|
2757
|
-
preferredForOutboundUntil:
|
|
3427
|
+
inboundOnly: snapshot.inboundOnly,
|
|
3428
|
+
outboundReadyUntil: snapshot.outboundReadyUntil,
|
|
3429
|
+
preferredForOutboundUntil: snapshot.preferredForOutboundUntil,
|
|
2758
3430
|
};
|
|
2759
3431
|
const connectionCapabilitySig = JSON.stringify({
|
|
2760
3432
|
bridge: this.bridgeId,
|
|
2761
3433
|
accountId: acc,
|
|
2762
|
-
connId:
|
|
2763
|
-
clientId:
|
|
3434
|
+
connId: next.connId,
|
|
3435
|
+
clientId: next.clientId || null,
|
|
2764
3436
|
outboundReady: args.outboundReady === true,
|
|
2765
3437
|
preferredForOutbound: args.preferredForOutbound === true,
|
|
2766
|
-
inboundOnly:
|
|
2767
|
-
outboundReadyActive: Number(
|
|
2768
|
-
preferredForOutboundActive: Number(
|
|
3438
|
+
inboundOnly: snapshot.inboundOnly,
|
|
3439
|
+
outboundReadyActive: Number(snapshot.outboundReadyUntil || 0) > t,
|
|
3440
|
+
preferredForOutboundActive: Number(snapshot.preferredForOutboundUntil || 0) > t,
|
|
2769
3441
|
});
|
|
2770
3442
|
this.logInfoDedupJson('connection', 'capability', connectionCapabilityPayload, {
|
|
2771
|
-
key: `connection-capability:${acc}:${
|
|
3443
|
+
key: `connection-capability:${acc}:${next.clientId || next.connId}`,
|
|
2772
3444
|
sig: connectionCapabilitySig,
|
|
2773
3445
|
debugOnly: true,
|
|
2774
3446
|
});
|
|
@@ -2780,20 +3452,14 @@ class BncrBridgeRuntime {
|
|
|
2780
3452
|
currentClientId?: string,
|
|
2781
3453
|
): boolean {
|
|
2782
3454
|
const acc = normalizeAccountId(accountId);
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
|
|
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;
|
|
3455
|
+
return hasAlternativeLiveConnectionFromRuntime({
|
|
3456
|
+
accountId: acc,
|
|
3457
|
+
now: now(),
|
|
3458
|
+
connectTtlMs: CONNECT_TTL_MS,
|
|
3459
|
+
currentConnId,
|
|
3460
|
+
currentClientId,
|
|
3461
|
+
connections: this.connections.values(),
|
|
3462
|
+
});
|
|
2797
3463
|
}
|
|
2798
3464
|
|
|
2799
3465
|
private degradeOutboundCapability(args: {
|
|
@@ -2811,33 +3477,16 @@ class BncrBridgeRuntime {
|
|
|
2811
3477
|
args.clientId,
|
|
2812
3478
|
);
|
|
2813
3479
|
const currentKey = this.activeConnectionByAccount.get(acc) || null;
|
|
2814
|
-
|
|
2815
|
-
|
|
2816
|
-
|
|
2817
|
-
|
|
2818
|
-
|
|
2819
|
-
})
|
|
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
|
-
}
|
|
3480
|
+
const matched = findCapabilityConnection({
|
|
3481
|
+
accountId: acc,
|
|
3482
|
+
connId: args.connId,
|
|
3483
|
+
clientId: args.clientId,
|
|
3484
|
+
connections: this.connections.entries(),
|
|
3485
|
+
});
|
|
2833
3486
|
|
|
2834
|
-
if (!
|
|
3487
|
+
if (!matched) return;
|
|
2835
3488
|
|
|
2836
|
-
const before =
|
|
2837
|
-
outboundReadyUntil: matchedConn.outboundReadyUntil || null,
|
|
2838
|
-
preferredForOutboundUntil: matchedConn.preferredForOutboundUntil || null,
|
|
2839
|
-
inboundOnly: matchedConn.inboundOnly === true,
|
|
2840
|
-
};
|
|
3489
|
+
const before = buildCapabilitySnapshot(matched.connection);
|
|
2841
3490
|
|
|
2842
3491
|
if (!hasAlternativeLiveConnection) {
|
|
2843
3492
|
this.logInfo(
|
|
@@ -2845,12 +3494,12 @@ class BncrBridgeRuntime {
|
|
|
2845
3494
|
`outbound-degrade skip ${JSON.stringify({
|
|
2846
3495
|
bridge: this.bridgeId,
|
|
2847
3496
|
accountId: acc,
|
|
2848
|
-
connId:
|
|
2849
|
-
clientId:
|
|
3497
|
+
connId: matched.connection.connId,
|
|
3498
|
+
clientId: matched.connection.clientId,
|
|
2850
3499
|
reason: args.reason,
|
|
2851
3500
|
at: t,
|
|
2852
3501
|
currentActiveKey: currentKey,
|
|
2853
|
-
degradedKey:
|
|
3502
|
+
degradedKey: matched.key,
|
|
2854
3503
|
skipReason: 'no-alternative-live-connection',
|
|
2855
3504
|
before,
|
|
2856
3505
|
})}`,
|
|
@@ -2859,27 +3508,22 @@ class BncrBridgeRuntime {
|
|
|
2859
3508
|
return;
|
|
2860
3509
|
}
|
|
2861
3510
|
|
|
2862
|
-
|
|
2863
|
-
|
|
2864
|
-
this.connections.set(matchedKey, matchedConn as BncrConnection);
|
|
3511
|
+
const next = clearOutboundCapability(matched.connection);
|
|
3512
|
+
this.connections.set(matched.key, next as BncrConnection);
|
|
2865
3513
|
|
|
2866
3514
|
this.logInfo(
|
|
2867
3515
|
'connection',
|
|
2868
3516
|
`outbound-degrade ${JSON.stringify({
|
|
2869
3517
|
bridge: this.bridgeId,
|
|
2870
3518
|
accountId: acc,
|
|
2871
|
-
connId:
|
|
2872
|
-
clientId:
|
|
3519
|
+
connId: next.connId,
|
|
3520
|
+
clientId: next.clientId,
|
|
2873
3521
|
reason: args.reason,
|
|
2874
3522
|
at: t,
|
|
2875
3523
|
currentActiveKey: currentKey,
|
|
2876
|
-
degradedKey:
|
|
3524
|
+
degradedKey: matched.key,
|
|
2877
3525
|
before,
|
|
2878
|
-
after:
|
|
2879
|
-
outboundReadyUntil: matchedConn.outboundReadyUntil || null,
|
|
2880
|
-
preferredForOutboundUntil: matchedConn.preferredForOutboundUntil || null,
|
|
2881
|
-
inboundOnly: matchedConn.inboundOnly === true,
|
|
2882
|
-
},
|
|
3526
|
+
after: buildCapabilitySnapshot(next),
|
|
2883
3527
|
})}`,
|
|
2884
3528
|
{ debugOnly: true },
|
|
2885
3529
|
);
|
|
@@ -3088,7 +3732,7 @@ class BncrBridgeRuntime {
|
|
|
3088
3732
|
const timer = setTimeout(() => {
|
|
3089
3733
|
this.fileAckWaiters.delete(key);
|
|
3090
3734
|
this.logWarn(
|
|
3091
|
-
|
|
3735
|
+
OUTBOUND_TERMINAL_REASON.FILE_ACK_TIMEOUT,
|
|
3092
3736
|
JSON.stringify({
|
|
3093
3737
|
bridge: this.bridgeId,
|
|
3094
3738
|
transferId,
|
|
@@ -3230,80 +3874,64 @@ class BncrBridgeRuntime {
|
|
|
3230
3874
|
return { path: finalPath, fileSha256: sha };
|
|
3231
3875
|
}
|
|
3232
3876
|
|
|
3233
|
-
private
|
|
3234
|
-
const
|
|
3235
|
-
|
|
3236
|
-
|
|
3237
|
-
|
|
3238
|
-
|
|
3239
|
-
|
|
3240
|
-
|
|
3241
|
-
|
|
3242
|
-
|
|
3243
|
-
|
|
3244
|
-
|
|
3245
|
-
|
|
3246
|
-
lastSession: this.lastSessionByAccount.get(acc) || null,
|
|
3247
|
-
lastActivityAt: this.lastActivityByAccount.get(acc) || null,
|
|
3248
|
-
lastInboundAt: this.lastInboundByAccount.get(acc) || null,
|
|
3249
|
-
lastOutboundAt: this.lastOutboundByAccount.get(acc) || null,
|
|
3250
|
-
sessionRoutesCount: Array.from(this.sessionRoutes.values()).filter((v) => v.accountId === acc)
|
|
3251
|
-
.length,
|
|
3252
|
-
invalidOutboxSessionKeys: this.countInvalidOutboxSessionKeys(acc),
|
|
3253
|
-
legacyAccountResidue: this.countLegacyAccountResidue(acc),
|
|
3254
|
-
channelRoot: path.join(process.cwd(), 'plugins', 'bncr'),
|
|
3255
|
-
});
|
|
3877
|
+
private buildRuntimeQueueSnapshot(accountId: string) {
|
|
3878
|
+
const pending = Array.from(this.outbox.values()).filter((v) => v.accountId === accountId).length;
|
|
3879
|
+
const deadLetter = this.deadLetter.filter((v) => v.accountId === accountId).length;
|
|
3880
|
+
const sessionRoutesCount = Array.from(this.sessionRoutes.values()).filter(
|
|
3881
|
+
(v) => v.accountId === accountId,
|
|
3882
|
+
).length;
|
|
3883
|
+
return {
|
|
3884
|
+
pending,
|
|
3885
|
+
deadLetter,
|
|
3886
|
+
sessionRoutesCount,
|
|
3887
|
+
invalidOutboxSessionKeys: this.countInvalidOutboxSessionKeys(accountId),
|
|
3888
|
+
legacyAccountResidue: this.countLegacyAccountResidue(accountId),
|
|
3889
|
+
};
|
|
3256
3890
|
}
|
|
3257
3891
|
|
|
3258
|
-
|
|
3259
|
-
|
|
3260
|
-
|
|
3261
|
-
|
|
3262
|
-
|
|
3263
|
-
|
|
3264
|
-
|
|
3265
|
-
|
|
3266
|
-
|
|
3267
|
-
|
|
3268
|
-
|
|
3269
|
-
|
|
3270
|
-
|
|
3271
|
-
|
|
3272
|
-
|
|
3273
|
-
|
|
3274
|
-
|
|
3275
|
-
sessionRoutesCount: Array.from(this.sessionRoutes.values()).filter((v) => v.accountId === acc)
|
|
3276
|
-
.length,
|
|
3277
|
-
invalidOutboxSessionKeys: this.countInvalidOutboxSessionKeys(acc),
|
|
3278
|
-
legacyAccountResidue: this.countLegacyAccountResidue(acc),
|
|
3279
|
-
running: true,
|
|
3280
|
-
channelRoot: path.join(process.cwd(), 'plugins', 'bncr'),
|
|
3281
|
-
});
|
|
3892
|
+
private buildRuntimeEventCounters(accountId: string) {
|
|
3893
|
+
return {
|
|
3894
|
+
connectEvents: this.getCounter(this.connectEventsByAccount, accountId),
|
|
3895
|
+
inboundEvents: this.getCounter(this.inboundEventsByAccount, accountId),
|
|
3896
|
+
activityEvents: this.getCounter(this.activityEventsByAccount, accountId),
|
|
3897
|
+
ackEvents: this.getCounter(this.ackEventsByAccount, accountId),
|
|
3898
|
+
};
|
|
3899
|
+
}
|
|
3900
|
+
|
|
3901
|
+
private buildRuntimeActivitySnapshot(accountId: string) {
|
|
3902
|
+
return {
|
|
3903
|
+
activeConnections: this.activeConnectionCount(accountId),
|
|
3904
|
+
lastSession: this.lastSessionByAccount.get(accountId) || null,
|
|
3905
|
+
lastActivityAt: this.lastActivityByAccount.get(accountId) || null,
|
|
3906
|
+
lastInboundAt: this.lastInboundByAccount.get(accountId) || null,
|
|
3907
|
+
lastOutboundAt: this.lastOutboundByAccount.get(accountId) || null,
|
|
3908
|
+
};
|
|
3282
3909
|
}
|
|
3283
3910
|
|
|
3284
|
-
private
|
|
3911
|
+
private buildRuntimeStatusInput(accountId: string, overrides: { running?: boolean } = {}) {
|
|
3285
3912
|
const acc = normalizeAccountId(accountId);
|
|
3286
|
-
return
|
|
3913
|
+
return {
|
|
3287
3914
|
accountId: acc,
|
|
3288
3915
|
connected: this.isOnline(acc),
|
|
3289
|
-
|
|
3290
|
-
|
|
3291
|
-
|
|
3292
|
-
connectEvents: this.getCounter(this.connectEventsByAccount, acc),
|
|
3293
|
-
inboundEvents: this.getCounter(this.inboundEventsByAccount, acc),
|
|
3294
|
-
activityEvents: this.getCounter(this.activityEventsByAccount, acc),
|
|
3295
|
-
ackEvents: this.getCounter(this.ackEventsByAccount, acc),
|
|
3916
|
+
...this.buildRuntimeQueueSnapshot(acc),
|
|
3917
|
+
...this.buildRuntimeEventCounters(acc),
|
|
3918
|
+
...this.buildRuntimeActivitySnapshot(acc),
|
|
3296
3919
|
startedAt: this.startedAt,
|
|
3297
|
-
|
|
3298
|
-
lastActivityAt: this.lastActivityByAccount.get(acc) || null,
|
|
3299
|
-
lastInboundAt: this.lastInboundByAccount.get(acc) || null,
|
|
3300
|
-
lastOutboundAt: this.lastOutboundByAccount.get(acc) || null,
|
|
3301
|
-
sessionRoutesCount: Array.from(this.sessionRoutes.values()).filter((v) => v.accountId === acc)
|
|
3302
|
-
.length,
|
|
3303
|
-
invalidOutboxSessionKeys: this.countInvalidOutboxSessionKeys(acc),
|
|
3304
|
-
legacyAccountResidue: this.countLegacyAccountResidue(acc),
|
|
3920
|
+
running: overrides.running,
|
|
3305
3921
|
channelRoot: path.join(process.cwd(), 'plugins', 'bncr'),
|
|
3306
|
-
}
|
|
3922
|
+
};
|
|
3923
|
+
}
|
|
3924
|
+
|
|
3925
|
+
private buildStatusMeta(accountId: string) {
|
|
3926
|
+
return buildStatusMetaFromRuntime(this.buildRuntimeStatusInput(accountId));
|
|
3927
|
+
}
|
|
3928
|
+
|
|
3929
|
+
getAccountRuntimeSnapshot(accountId: string) {
|
|
3930
|
+
return buildAccountRuntimeSnapshot(this.buildRuntimeStatusInput(accountId, { running: true }));
|
|
3931
|
+
}
|
|
3932
|
+
|
|
3933
|
+
private buildStatusHeadline(accountId: string): string {
|
|
3934
|
+
return buildStatusHeadlineFromRuntime(this.buildRuntimeStatusInput(accountId));
|
|
3307
3935
|
}
|
|
3308
3936
|
|
|
3309
3937
|
getStatusHeadline(accountId: string): string {
|
|
@@ -3331,22 +3959,21 @@ class BncrBridgeRuntime {
|
|
|
3331
3959
|
}
|
|
3332
3960
|
|
|
3333
3961
|
private enqueueOutbound(entry: OutboxEntry) {
|
|
3334
|
-
|
|
3335
|
-
|
|
3336
|
-
|
|
3337
|
-
|
|
3962
|
+
// Structure note (outbox enqueue entrypoint):
|
|
3963
|
+
// This is the sync handoff from message construction into the outbound state machine.
|
|
3964
|
+
// Responsibilities are intentionally narrow here: log/summary, persist into outbox,
|
|
3965
|
+
// schedule state save, then nudge flushPushQueue. Future refactors should keep enqueue
|
|
3966
|
+
// lightweight and avoid reintroducing retry / route / ACK policy decisions at this layer.
|
|
3338
3967
|
this.logInfo(
|
|
3339
3968
|
'outbound',
|
|
3340
|
-
JSON.stringify(
|
|
3341
|
-
|
|
3342
|
-
|
|
3343
|
-
|
|
3344
|
-
|
|
3345
|
-
|
|
3346
|
-
|
|
3347
|
-
|
|
3348
|
-
textPreview: text.slice(0, 120),
|
|
3349
|
-
}),
|
|
3969
|
+
JSON.stringify(
|
|
3970
|
+
buildOutboxEnqueueDebugInfo({
|
|
3971
|
+
bridgeId: this.bridgeId,
|
|
3972
|
+
entry,
|
|
3973
|
+
asString,
|
|
3974
|
+
formatDisplayScope,
|
|
3975
|
+
}),
|
|
3976
|
+
),
|
|
3350
3977
|
{ debugOnly: true },
|
|
3351
3978
|
);
|
|
3352
3979
|
this.logOutboundSummary(entry);
|
|
@@ -3356,50 +3983,46 @@ class BncrBridgeRuntime {
|
|
|
3356
3983
|
}
|
|
3357
3984
|
|
|
3358
3985
|
private moveToDeadLetter(entry: OutboxEntry, reason: string) {
|
|
3359
|
-
|
|
3360
|
-
|
|
3361
|
-
|
|
3362
|
-
|
|
3363
|
-
|
|
3364
|
-
|
|
3986
|
+
// Structure note (terminal transition):
|
|
3987
|
+
// Dead-lettering is the terminal state transition for an outbox entry. It also resolves any
|
|
3988
|
+
// waiter still blocked on the message id with timeout semantics, so future extraction should
|
|
3989
|
+
// treat dead-letter storage and waiter cleanup as one boundary rather than separate utilities.
|
|
3990
|
+
//
|
|
3991
|
+
// Queue-lifecycle note:
|
|
3992
|
+
// This path is shared by both explicit fatal outcomes and retry exhaustion. Keep that distinction
|
|
3993
|
+
// visible in callers, but keep the final sink centralized here so terminal accounting, persistence,
|
|
3994
|
+
// and waiter cleanup cannot drift apart.
|
|
3995
|
+
const dead = buildDeadLetterEntry(entry, reason);
|
|
3996
|
+
this.deadLetter = appendDeadLetter({
|
|
3997
|
+
deadLetter: this.deadLetter,
|
|
3998
|
+
entry: dead,
|
|
3999
|
+
maxEntries: 1000,
|
|
4000
|
+
});
|
|
3365
4001
|
this.outbox.delete(entry.messageId);
|
|
3366
4002
|
this.resolveMessageAck(entry.messageId, 'timeout');
|
|
3367
4003
|
this.scheduleSave();
|
|
3368
4004
|
}
|
|
3369
4005
|
|
|
3370
4006
|
private collectDue(accountId: string, maxBatch: number): Array<Record<string, unknown>> {
|
|
3371
|
-
const due: Array<Record<string, unknown>> = [];
|
|
3372
|
-
const t = now();
|
|
3373
4007
|
const key = normalizeAccountId(accountId);
|
|
4008
|
+
const result = collectDueOutboxEntries({
|
|
4009
|
+
outbox: this.outbox.values(),
|
|
4010
|
+
accountId: key,
|
|
4011
|
+
now: now(),
|
|
4012
|
+
maxBatch,
|
|
4013
|
+
maxRetry: MAX_RETRY,
|
|
4014
|
+
backoffMs,
|
|
4015
|
+
});
|
|
3374
4016
|
|
|
3375
|
-
for (const entry of
|
|
3376
|
-
if (entry.accountId !== key) continue;
|
|
3377
|
-
if (entry.nextAttemptAt > t) continue;
|
|
3378
|
-
|
|
3379
|
-
const nextAttempt = entry.retryCount + 1;
|
|
3380
|
-
if (nextAttempt > MAX_RETRY) {
|
|
3381
|
-
this.moveToDeadLetter(entry, 'retry-limit');
|
|
3382
|
-
continue;
|
|
3383
|
-
}
|
|
3384
|
-
|
|
3385
|
-
entry.retryCount = nextAttempt;
|
|
3386
|
-
entry.lastAttemptAt = t;
|
|
3387
|
-
entry.nextAttemptAt = t + backoffMs(nextAttempt);
|
|
4017
|
+
for (const entry of result.updatedEntries) {
|
|
3388
4018
|
this.outbox.set(entry.messageId, entry);
|
|
3389
|
-
|
|
3390
|
-
|
|
3391
|
-
|
|
3392
|
-
_meta: {
|
|
3393
|
-
retryCount: entry.retryCount,
|
|
3394
|
-
nextAttemptAt: entry.nextAttemptAt,
|
|
3395
|
-
},
|
|
3396
|
-
});
|
|
3397
|
-
|
|
3398
|
-
if (due.length >= maxBatch) break;
|
|
4019
|
+
}
|
|
4020
|
+
for (const entry of result.deadLetterEntries) {
|
|
4021
|
+
this.moveToDeadLetter(entry, entry.lastError || 'retry-limit');
|
|
3399
4022
|
}
|
|
3400
4023
|
|
|
3401
|
-
if (
|
|
3402
|
-
return
|
|
4024
|
+
if (result.duePayloads.length) this.scheduleSave();
|
|
4025
|
+
return result.duePayloads;
|
|
3403
4026
|
}
|
|
3404
4027
|
|
|
3405
4028
|
private async payloadMediaToBase64(
|
|
@@ -3417,6 +4040,301 @@ class BncrBridgeRuntime {
|
|
|
3417
4040
|
};
|
|
3418
4041
|
}
|
|
3419
4042
|
|
|
4043
|
+
private async loadOutboundTransferMedia(params: {
|
|
4044
|
+
mediaUrl: string;
|
|
4045
|
+
mediaLocalRoots?: readonly string[];
|
|
4046
|
+
}): Promise<{
|
|
4047
|
+
loaded: Awaited<ReturnType<OpenClawPluginApi['runtime']['media']['loadWebMedia']>>;
|
|
4048
|
+
size: number;
|
|
4049
|
+
mimeType?: string;
|
|
4050
|
+
fileName: string;
|
|
4051
|
+
}> {
|
|
4052
|
+
const loaded = await this.api.runtime.media.loadWebMedia(params.mediaUrl, {
|
|
4053
|
+
localRoots: params.mediaLocalRoots,
|
|
4054
|
+
maxBytes: 50 * 1024 * 1024,
|
|
4055
|
+
});
|
|
4056
|
+
const size = loaded.buffer.byteLength;
|
|
4057
|
+
const mimeType = loaded.contentType;
|
|
4058
|
+
const fileName = resolveOutboundFileName({
|
|
4059
|
+
mediaUrl: params.mediaUrl,
|
|
4060
|
+
fileName: loaded.fileName,
|
|
4061
|
+
mimeType,
|
|
4062
|
+
});
|
|
4063
|
+
return { loaded, size, mimeType, fileName };
|
|
4064
|
+
}
|
|
4065
|
+
|
|
4066
|
+
private buildTransferRouteDiagnostics(args: {
|
|
4067
|
+
accountId: string;
|
|
4068
|
+
recentInboundReachable: boolean;
|
|
4069
|
+
}) {
|
|
4070
|
+
const directConnIds = this.resolvePushConnIds(args.accountId);
|
|
4071
|
+
const recentConnIds = args.recentInboundReachable
|
|
4072
|
+
? this.resolveRecentInboundConnIds(args.accountId)
|
|
4073
|
+
: new Set<string>();
|
|
4074
|
+
const activeConnectionKey = this.activeConnectionByAccount.get(args.accountId) || null;
|
|
4075
|
+
const accountConnections = Array.from(this.connections.values())
|
|
4076
|
+
.filter((c) => c.accountId === args.accountId)
|
|
4077
|
+
.map((c) => ({
|
|
4078
|
+
connId: c.connId,
|
|
4079
|
+
clientId: c.clientId,
|
|
4080
|
+
connectedAt: c.connectedAt,
|
|
4081
|
+
lastSeenAt: c.lastSeenAt,
|
|
4082
|
+
}));
|
|
4083
|
+
|
|
4084
|
+
return {
|
|
4085
|
+
directConnIds,
|
|
4086
|
+
recentConnIds,
|
|
4087
|
+
activeConnectionKey,
|
|
4088
|
+
accountConnections,
|
|
4089
|
+
};
|
|
4090
|
+
}
|
|
4091
|
+
|
|
4092
|
+
private selectTransferConnIds(args: {
|
|
4093
|
+
directConnIds: Set<string>;
|
|
4094
|
+
recentConnIds: Set<string>;
|
|
4095
|
+
recentInboundReachable: boolean;
|
|
4096
|
+
}) {
|
|
4097
|
+
let connIds = args.directConnIds;
|
|
4098
|
+
if (!connIds.size && args.recentInboundReachable) {
|
|
4099
|
+
connIds = args.recentConnIds;
|
|
4100
|
+
}
|
|
4101
|
+
return connIds;
|
|
4102
|
+
}
|
|
4103
|
+
|
|
4104
|
+
private logFileChunkDiag(args: {
|
|
4105
|
+
accountId: string;
|
|
4106
|
+
sessionKey: string;
|
|
4107
|
+
mediaUrl: string;
|
|
4108
|
+
hasGatewayContext: boolean;
|
|
4109
|
+
activeConnectionKey: string | null;
|
|
4110
|
+
ownerConnId?: string;
|
|
4111
|
+
ownerClientId?: string;
|
|
4112
|
+
directConnIds: Iterable<string>;
|
|
4113
|
+
recentInboundReachable: boolean;
|
|
4114
|
+
recentConnIds: Iterable<string>;
|
|
4115
|
+
accountConnections: Array<{
|
|
4116
|
+
connId: string;
|
|
4117
|
+
clientId?: string;
|
|
4118
|
+
connectedAt: number;
|
|
4119
|
+
lastSeenAt: number;
|
|
4120
|
+
}>;
|
|
4121
|
+
}) {
|
|
4122
|
+
this.logInfo(
|
|
4123
|
+
'file-chunk-diag',
|
|
4124
|
+
JSON.stringify({
|
|
4125
|
+
bridge: this.bridgeId,
|
|
4126
|
+
accountId: args.accountId,
|
|
4127
|
+
sessionKey: args.sessionKey,
|
|
4128
|
+
mediaUrl: args.mediaUrl,
|
|
4129
|
+
hasGatewayContext: args.hasGatewayContext,
|
|
4130
|
+
activeConnectionKey: args.activeConnectionKey,
|
|
4131
|
+
ownerConnId: args.ownerConnId || null,
|
|
4132
|
+
ownerClientId: args.ownerClientId || null,
|
|
4133
|
+
directConnIds: Array.from(args.directConnIds),
|
|
4134
|
+
recentInboundReachable: args.recentInboundReachable,
|
|
4135
|
+
recentConnIds: Array.from(args.recentConnIds),
|
|
4136
|
+
accountConnections: args.accountConnections,
|
|
4137
|
+
}),
|
|
4138
|
+
{ debugOnly: true },
|
|
4139
|
+
);
|
|
4140
|
+
}
|
|
4141
|
+
|
|
4142
|
+
private logFileTransferStart(args: {
|
|
4143
|
+
transferId: string;
|
|
4144
|
+
accountId: string;
|
|
4145
|
+
sessionKey: string;
|
|
4146
|
+
mediaUrl: string;
|
|
4147
|
+
fileName: string;
|
|
4148
|
+
mimeType?: string;
|
|
4149
|
+
fileSize: number;
|
|
4150
|
+
chunkSize: number;
|
|
4151
|
+
totalChunks: number;
|
|
4152
|
+
connIds: Iterable<string>;
|
|
4153
|
+
ownerConnId?: string;
|
|
4154
|
+
ownerClientId?: string;
|
|
4155
|
+
}) {
|
|
4156
|
+
this.logInfo(
|
|
4157
|
+
'file-transfer-start',
|
|
4158
|
+
JSON.stringify({
|
|
4159
|
+
bridge: this.bridgeId,
|
|
4160
|
+
transferId: args.transferId,
|
|
4161
|
+
accountId: args.accountId,
|
|
4162
|
+
sessionKey: args.sessionKey,
|
|
4163
|
+
mediaUrl: args.mediaUrl,
|
|
4164
|
+
fileName: args.fileName,
|
|
4165
|
+
mimeType: args.mimeType,
|
|
4166
|
+
fileSize: args.fileSize,
|
|
4167
|
+
chunkSize: args.chunkSize,
|
|
4168
|
+
totalChunks: args.totalChunks,
|
|
4169
|
+
connIds: Array.from(args.connIds),
|
|
4170
|
+
ownerConnId: args.ownerConnId || null,
|
|
4171
|
+
ownerClientId: args.ownerClientId || null,
|
|
4172
|
+
}),
|
|
4173
|
+
{ debugOnly: true },
|
|
4174
|
+
);
|
|
4175
|
+
}
|
|
4176
|
+
|
|
4177
|
+
private logFileTransferChunkSend(args: {
|
|
4178
|
+
transferId: string;
|
|
4179
|
+
accountId: string;
|
|
4180
|
+
chunkIndex: number;
|
|
4181
|
+
attempt: number;
|
|
4182
|
+
offset: number;
|
|
4183
|
+
size: number;
|
|
4184
|
+
connIds: Iterable<string>;
|
|
4185
|
+
}) {
|
|
4186
|
+
this.logInfo(
|
|
4187
|
+
'file-transfer-chunk-send',
|
|
4188
|
+
JSON.stringify({
|
|
4189
|
+
bridge: this.bridgeId,
|
|
4190
|
+
transferId: args.transferId,
|
|
4191
|
+
accountId: args.accountId,
|
|
4192
|
+
chunkIndex: args.chunkIndex,
|
|
4193
|
+
attempt: args.attempt,
|
|
4194
|
+
offset: args.offset,
|
|
4195
|
+
size: args.size,
|
|
4196
|
+
connIds: Array.from(args.connIds),
|
|
4197
|
+
}),
|
|
4198
|
+
{ debugOnly: true },
|
|
4199
|
+
);
|
|
4200
|
+
}
|
|
4201
|
+
|
|
4202
|
+
private logFileTransferChunkAck(args: {
|
|
4203
|
+
transferId: string;
|
|
4204
|
+
accountId: string;
|
|
4205
|
+
chunkIndex: number;
|
|
4206
|
+
attempt: number;
|
|
4207
|
+
}) {
|
|
4208
|
+
this.logInfo(
|
|
4209
|
+
'file-transfer-chunk-ack',
|
|
4210
|
+
JSON.stringify({
|
|
4211
|
+
bridge: this.bridgeId,
|
|
4212
|
+
transferId: args.transferId,
|
|
4213
|
+
accountId: args.accountId,
|
|
4214
|
+
chunkIndex: args.chunkIndex,
|
|
4215
|
+
attempt: args.attempt,
|
|
4216
|
+
}),
|
|
4217
|
+
{ debugOnly: true },
|
|
4218
|
+
);
|
|
4219
|
+
}
|
|
4220
|
+
|
|
4221
|
+
private logFileTransferChunkAckFail(args: {
|
|
4222
|
+
transferId: string;
|
|
4223
|
+
accountId: string;
|
|
4224
|
+
chunkIndex: number;
|
|
4225
|
+
attempt: number;
|
|
4226
|
+
error: unknown;
|
|
4227
|
+
}) {
|
|
4228
|
+
this.logWarn(
|
|
4229
|
+
'file-transfer-chunk-ack-fail',
|
|
4230
|
+
JSON.stringify({
|
|
4231
|
+
bridge: this.bridgeId,
|
|
4232
|
+
transferId: args.transferId,
|
|
4233
|
+
accountId: args.accountId,
|
|
4234
|
+
chunkIndex: args.chunkIndex,
|
|
4235
|
+
attempt: args.attempt,
|
|
4236
|
+
error: asString((args.error as Error)?.message || args.error),
|
|
4237
|
+
}),
|
|
4238
|
+
{ debugOnly: true },
|
|
4239
|
+
);
|
|
4240
|
+
}
|
|
4241
|
+
|
|
4242
|
+
private logFileTransferCompleteSend(args: {
|
|
4243
|
+
transferId: string;
|
|
4244
|
+
accountId: string;
|
|
4245
|
+
connIds: Iterable<string>;
|
|
4246
|
+
}) {
|
|
4247
|
+
this.logInfo(
|
|
4248
|
+
'file-transfer-complete-send',
|
|
4249
|
+
JSON.stringify({
|
|
4250
|
+
bridge: this.bridgeId,
|
|
4251
|
+
transferId: args.transferId,
|
|
4252
|
+
accountId: args.accountId,
|
|
4253
|
+
connIds: Array.from(args.connIds),
|
|
4254
|
+
}),
|
|
4255
|
+
{ debugOnly: true },
|
|
4256
|
+
);
|
|
4257
|
+
}
|
|
4258
|
+
|
|
4259
|
+
private logFileTransferCompleteAck(args: {
|
|
4260
|
+
transferId: string;
|
|
4261
|
+
accountId: string;
|
|
4262
|
+
payload: { path: string };
|
|
4263
|
+
}) {
|
|
4264
|
+
this.logInfo(
|
|
4265
|
+
'file-transfer-complete-ack',
|
|
4266
|
+
JSON.stringify({
|
|
4267
|
+
bridge: this.bridgeId,
|
|
4268
|
+
transferId: args.transferId,
|
|
4269
|
+
accountId: args.accountId,
|
|
4270
|
+
payload: args.payload,
|
|
4271
|
+
}),
|
|
4272
|
+
{ debugOnly: true },
|
|
4273
|
+
);
|
|
4274
|
+
}
|
|
4275
|
+
|
|
4276
|
+
private buildFileTransferInitPayload(args: {
|
|
4277
|
+
transferId: string;
|
|
4278
|
+
sessionKey: string;
|
|
4279
|
+
route: BncrRoute;
|
|
4280
|
+
fileName: string;
|
|
4281
|
+
mimeType?: string;
|
|
4282
|
+
fileSize: number;
|
|
4283
|
+
chunkSize: number;
|
|
4284
|
+
totalChunks: number;
|
|
4285
|
+
fileSha256: string;
|
|
4286
|
+
}) {
|
|
4287
|
+
return {
|
|
4288
|
+
transferId: args.transferId,
|
|
4289
|
+
direction: 'oc2bncr' as const,
|
|
4290
|
+
sessionKey: args.sessionKey,
|
|
4291
|
+
platform: args.route.platform,
|
|
4292
|
+
groupId: args.route.groupId,
|
|
4293
|
+
userId: args.route.userId,
|
|
4294
|
+
fileName: args.fileName,
|
|
4295
|
+
mimeType: args.mimeType,
|
|
4296
|
+
fileSize: args.fileSize,
|
|
4297
|
+
chunkSize: args.chunkSize,
|
|
4298
|
+
totalChunks: args.totalChunks,
|
|
4299
|
+
fileSha256: args.fileSha256,
|
|
4300
|
+
ts: now(),
|
|
4301
|
+
};
|
|
4302
|
+
}
|
|
4303
|
+
|
|
4304
|
+
private buildInitialFileSendTransferState(args: {
|
|
4305
|
+
transferId: string;
|
|
4306
|
+
accountId: string;
|
|
4307
|
+
sessionKey: string;
|
|
4308
|
+
route: BncrRoute;
|
|
4309
|
+
fileName: string;
|
|
4310
|
+
mimeType?: string;
|
|
4311
|
+
fileSize: number;
|
|
4312
|
+
chunkSize: number;
|
|
4313
|
+
totalChunks: number;
|
|
4314
|
+
fileSha256: string;
|
|
4315
|
+
ownerConnId?: string;
|
|
4316
|
+
ownerClientId?: string;
|
|
4317
|
+
}): FileSendTransferState {
|
|
4318
|
+
return {
|
|
4319
|
+
transferId: args.transferId,
|
|
4320
|
+
accountId: normalizeAccountId(args.accountId),
|
|
4321
|
+
sessionKey: args.sessionKey,
|
|
4322
|
+
route: args.route,
|
|
4323
|
+
fileName: args.fileName,
|
|
4324
|
+
mimeType: args.mimeType || 'application/octet-stream',
|
|
4325
|
+
fileSize: args.fileSize,
|
|
4326
|
+
chunkSize: args.chunkSize,
|
|
4327
|
+
totalChunks: args.totalChunks,
|
|
4328
|
+
fileSha256: args.fileSha256,
|
|
4329
|
+
startedAt: now(),
|
|
4330
|
+
status: 'init',
|
|
4331
|
+
ackedChunks: new Set(),
|
|
4332
|
+
failedChunks: new Map(),
|
|
4333
|
+
ownerConnId: args.ownerConnId,
|
|
4334
|
+
ownerClientId: args.ownerClientId,
|
|
4335
|
+
};
|
|
4336
|
+
}
|
|
4337
|
+
|
|
3420
4338
|
private async sleepMs(ms: number): Promise<void> {
|
|
3421
4339
|
await new Promise<void>((resolve) => setTimeout(resolve, Math.max(0, Number(ms || 0))));
|
|
3422
4340
|
}
|
|
@@ -3426,6 +4344,10 @@ class BncrBridgeRuntime {
|
|
|
3426
4344
|
chunkIndex: number;
|
|
3427
4345
|
timeoutMs?: number;
|
|
3428
4346
|
}): Promise<void> {
|
|
4347
|
+
// Refactor boundary note (file-transfer / ACK coupling):
|
|
4348
|
+
// Chunk-level ACK waiting is part of the file-transfer sub-protocol, but it depends directly on
|
|
4349
|
+
// mutable transfer runtime state in fileSendTransfers. If this is extracted later, preserve the
|
|
4350
|
+
// current state ownership and timeout semantics before moving polling/wait logic out to another file.
|
|
3429
4351
|
const { transferId, chunkIndex } = params;
|
|
3430
4352
|
const timeoutMs = Math.max(
|
|
3431
4353
|
1_000,
|
|
@@ -3463,6 +4385,10 @@ class BncrBridgeRuntime {
|
|
|
3463
4385
|
transferId: string;
|
|
3464
4386
|
timeoutMs?: number;
|
|
3465
4387
|
}): Promise<{ path: string }> {
|
|
4388
|
+
// Refactor boundary note (file-transfer completion):
|
|
4389
|
+
// Completion ACK waiting shares the same transfer lifecycle boundary as chunk ACKs and relies on
|
|
4390
|
+
// transfer status transitions performed elsewhere in channel.ts. Keep completion wait behavior and
|
|
4391
|
+
// transfer-state mutation boundaries aligned if/when file-transfer pieces are moved out.
|
|
3466
4392
|
const { transferId } = params;
|
|
3467
4393
|
const timeoutMs = Math.max(2_000, Math.min(Number(params.timeoutMs || 60_000), 120_000));
|
|
3468
4394
|
const started = now();
|
|
@@ -3500,23 +4426,20 @@ class BncrBridgeRuntime {
|
|
|
3500
4426
|
mediaUrl: string;
|
|
3501
4427
|
mediaLocalRoots?: readonly string[];
|
|
3502
4428
|
}): Promise<{
|
|
4429
|
+
// Refactor boundary note (file-transfer root):
|
|
4430
|
+
// This method is the root of the outbound file-transfer protocol. It owns media loading,
|
|
4431
|
+
// inline-vs-chunk mode selection, route/owner selection for transfer delivery, chunk send,
|
|
4432
|
+
// chunk ACK waits, complete ACK waits, and abort propagation. Future extraction should treat
|
|
4433
|
+
// these as one protocol boundary first, rather than splitting transport and state handling separately.
|
|
3503
4434
|
mode: 'base64' | 'chunk';
|
|
3504
4435
|
mimeType?: string;
|
|
3505
4436
|
fileName?: string;
|
|
3506
4437
|
mediaBase64?: string;
|
|
3507
4438
|
path?: string;
|
|
3508
4439
|
}> {
|
|
3509
|
-
const loaded = await this.
|
|
3510
|
-
localRoots: params.mediaLocalRoots,
|
|
3511
|
-
maxBytes: 50 * 1024 * 1024,
|
|
3512
|
-
});
|
|
3513
|
-
|
|
3514
|
-
const size = loaded.buffer.byteLength;
|
|
3515
|
-
const mimeType = loaded.contentType;
|
|
3516
|
-
const fileName = resolveOutboundFileName({
|
|
4440
|
+
const { loaded, size, mimeType, fileName } = await this.loadOutboundTransferMedia({
|
|
3517
4441
|
mediaUrl: params.mediaUrl,
|
|
3518
|
-
|
|
3519
|
-
mimeType,
|
|
4442
|
+
mediaLocalRoots: params.mediaLocalRoots,
|
|
3520
4443
|
});
|
|
3521
4444
|
|
|
3522
4445
|
if (!FILE_FORCE_CHUNK && size <= FILE_INLINE_THRESHOLD) {
|
|
@@ -3531,108 +4454,82 @@ class BncrBridgeRuntime {
|
|
|
3531
4454
|
const ctx = this.gatewayContext;
|
|
3532
4455
|
const owner = this.resolveOutboxPushOwner(params.accountId);
|
|
3533
4456
|
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
4457
|
const accountId = normalizeAccountId(params.accountId);
|
|
3539
|
-
const
|
|
3540
|
-
|
|
3541
|
-
|
|
3542
|
-
|
|
3543
|
-
|
|
3544
|
-
|
|
3545
|
-
|
|
3546
|
-
|
|
3547
|
-
|
|
3548
|
-
|
|
3549
|
-
|
|
3550
|
-
|
|
3551
|
-
|
|
3552
|
-
|
|
3553
|
-
|
|
3554
|
-
|
|
3555
|
-
|
|
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
|
-
);
|
|
4458
|
+
const routeDiagnostics = this.buildTransferRouteDiagnostics({
|
|
4459
|
+
accountId,
|
|
4460
|
+
recentInboundReachable,
|
|
4461
|
+
});
|
|
4462
|
+
this.logFileChunkDiag({
|
|
4463
|
+
accountId,
|
|
4464
|
+
sessionKey: params.sessionKey,
|
|
4465
|
+
mediaUrl: params.mediaUrl,
|
|
4466
|
+
hasGatewayContext: Boolean(ctx),
|
|
4467
|
+
activeConnectionKey: routeDiagnostics.activeConnectionKey,
|
|
4468
|
+
ownerConnId: owner?.connId,
|
|
4469
|
+
ownerClientId: owner?.clientId,
|
|
4470
|
+
directConnIds: routeDiagnostics.directConnIds,
|
|
4471
|
+
recentInboundReachable,
|
|
4472
|
+
recentConnIds: routeDiagnostics.recentConnIds,
|
|
4473
|
+
accountConnections: routeDiagnostics.accountConnections,
|
|
4474
|
+
});
|
|
3566
4475
|
if (!ctx) throw new Error('gateway context unavailable');
|
|
3567
4476
|
|
|
3568
|
-
|
|
3569
|
-
|
|
3570
|
-
|
|
3571
|
-
|
|
3572
|
-
|
|
3573
|
-
|
|
3574
|
-
|
|
3575
|
-
const
|
|
3576
|
-
const
|
|
3577
|
-
const
|
|
3578
|
-
|
|
3579
|
-
|
|
3580
|
-
|
|
3581
|
-
|
|
3582
|
-
|
|
3583
|
-
|
|
3584
|
-
|
|
3585
|
-
|
|
3586
|
-
|
|
3587
|
-
|
|
3588
|
-
|
|
3589
|
-
|
|
3590
|
-
|
|
3591
|
-
|
|
3592
|
-
|
|
3593
|
-
|
|
3594
|
-
ownerClientId: owner?.clientId || null,
|
|
3595
|
-
}),
|
|
3596
|
-
{ debugOnly: true },
|
|
3597
|
-
);
|
|
4477
|
+
const connIds = this.selectTransferConnIds({
|
|
4478
|
+
directConnIds: routeDiagnostics.directConnIds,
|
|
4479
|
+
recentConnIds: routeDiagnostics.recentConnIds,
|
|
4480
|
+
recentInboundReachable,
|
|
4481
|
+
});
|
|
4482
|
+
if (!connIds.size) throw new Error('no active bncr client for file chunk transfer');
|
|
4483
|
+
|
|
4484
|
+
const transferId = randomUUID();
|
|
4485
|
+
const chunkSize = 256 * 1024;
|
|
4486
|
+
const totalChunks = Math.ceil(size / chunkSize);
|
|
4487
|
+
const fileSha256 = createHash('sha256').update(loaded.buffer).digest('hex');
|
|
4488
|
+
|
|
4489
|
+
this.logFileTransferStart({
|
|
4490
|
+
transferId,
|
|
4491
|
+
accountId,
|
|
4492
|
+
sessionKey: params.sessionKey,
|
|
4493
|
+
mediaUrl: params.mediaUrl,
|
|
4494
|
+
fileName,
|
|
4495
|
+
mimeType,
|
|
4496
|
+
fileSize: size,
|
|
4497
|
+
chunkSize,
|
|
4498
|
+
totalChunks,
|
|
4499
|
+
connIds,
|
|
4500
|
+
ownerConnId: owner?.connId,
|
|
4501
|
+
ownerClientId: owner?.clientId,
|
|
4502
|
+
});
|
|
3598
4503
|
|
|
3599
|
-
const st
|
|
4504
|
+
const st = this.buildInitialFileSendTransferState({
|
|
3600
4505
|
transferId,
|
|
3601
|
-
accountId:
|
|
4506
|
+
accountId: params.accountId,
|
|
3602
4507
|
sessionKey: params.sessionKey,
|
|
3603
4508
|
route: params.route,
|
|
3604
4509
|
fileName,
|
|
3605
|
-
mimeType
|
|
4510
|
+
mimeType,
|
|
3606
4511
|
fileSize: size,
|
|
3607
4512
|
chunkSize,
|
|
3608
4513
|
totalChunks,
|
|
3609
4514
|
fileSha256,
|
|
3610
|
-
startedAt: now(),
|
|
3611
|
-
status: 'init',
|
|
3612
|
-
ackedChunks: new Set(),
|
|
3613
|
-
failedChunks: new Map(),
|
|
3614
4515
|
ownerConnId: owner?.connId,
|
|
3615
4516
|
ownerClientId: owner?.clientId,
|
|
3616
|
-
};
|
|
4517
|
+
});
|
|
3617
4518
|
this.fileSendTransfers.set(transferId, st);
|
|
3618
4519
|
|
|
3619
4520
|
ctx.broadcastToConnIds(
|
|
3620
4521
|
BNCR_FILE_INIT_EVENT,
|
|
3621
|
-
{
|
|
4522
|
+
this.buildFileTransferInitPayload({
|
|
3622
4523
|
transferId,
|
|
3623
|
-
direction: 'oc2bncr',
|
|
3624
4524
|
sessionKey: params.sessionKey,
|
|
3625
|
-
|
|
3626
|
-
groupId: params.route.groupId,
|
|
3627
|
-
userId: params.route.userId,
|
|
4525
|
+
route: params.route,
|
|
3628
4526
|
fileName,
|
|
3629
4527
|
mimeType,
|
|
3630
4528
|
fileSize: size,
|
|
3631
4529
|
chunkSize,
|
|
3632
4530
|
totalChunks,
|
|
3633
4531
|
fileSha256,
|
|
3634
|
-
|
|
3635
|
-
},
|
|
4532
|
+
}),
|
|
3636
4533
|
connIds,
|
|
3637
4534
|
);
|
|
3638
4535
|
|
|
@@ -3660,20 +4557,15 @@ class BncrBridgeRuntime {
|
|
|
3660
4557
|
connIds,
|
|
3661
4558
|
);
|
|
3662
4559
|
|
|
3663
|
-
this.
|
|
3664
|
-
|
|
3665
|
-
|
|
3666
|
-
|
|
3667
|
-
|
|
3668
|
-
|
|
3669
|
-
|
|
3670
|
-
|
|
3671
|
-
|
|
3672
|
-
size: slice.byteLength,
|
|
3673
|
-
connIds: Array.from(connIds),
|
|
3674
|
-
}),
|
|
3675
|
-
{ debugOnly: true },
|
|
3676
|
-
);
|
|
4560
|
+
this.logFileTransferChunkSend({
|
|
4561
|
+
transferId,
|
|
4562
|
+
accountId,
|
|
4563
|
+
chunkIndex: idx,
|
|
4564
|
+
attempt,
|
|
4565
|
+
offset: start,
|
|
4566
|
+
size: slice.byteLength,
|
|
4567
|
+
connIds,
|
|
4568
|
+
});
|
|
3677
4569
|
|
|
3678
4570
|
try {
|
|
3679
4571
|
await this.waitChunkAck({
|
|
@@ -3681,33 +4573,23 @@ class BncrBridgeRuntime {
|
|
|
3681
4573
|
chunkIndex: idx,
|
|
3682
4574
|
timeoutMs: FILE_TRANSFER_ACK_TTL_MS,
|
|
3683
4575
|
});
|
|
3684
|
-
this.
|
|
3685
|
-
|
|
3686
|
-
|
|
3687
|
-
|
|
3688
|
-
|
|
3689
|
-
|
|
3690
|
-
chunkIndex: idx,
|
|
3691
|
-
attempt,
|
|
3692
|
-
}),
|
|
3693
|
-
{ debugOnly: true },
|
|
3694
|
-
);
|
|
4576
|
+
this.logFileTransferChunkAck({
|
|
4577
|
+
transferId,
|
|
4578
|
+
accountId,
|
|
4579
|
+
chunkIndex: idx,
|
|
4580
|
+
attempt,
|
|
4581
|
+
});
|
|
3695
4582
|
ok = true;
|
|
3696
4583
|
break;
|
|
3697
4584
|
} catch (err) {
|
|
3698
4585
|
lastErr = err;
|
|
3699
|
-
this.
|
|
3700
|
-
|
|
3701
|
-
|
|
3702
|
-
|
|
3703
|
-
|
|
3704
|
-
|
|
3705
|
-
|
|
3706
|
-
attempt,
|
|
3707
|
-
error: asString((err as Error)?.message || err),
|
|
3708
|
-
}),
|
|
3709
|
-
{ debugOnly: true },
|
|
3710
|
-
);
|
|
4586
|
+
this.logFileTransferChunkAckFail({
|
|
4587
|
+
transferId,
|
|
4588
|
+
accountId,
|
|
4589
|
+
chunkIndex: idx,
|
|
4590
|
+
attempt,
|
|
4591
|
+
error: err,
|
|
4592
|
+
});
|
|
3711
4593
|
await this.sleepMs(150 * attempt);
|
|
3712
4594
|
}
|
|
3713
4595
|
}
|
|
@@ -3738,29 +4620,19 @@ class BncrBridgeRuntime {
|
|
|
3738
4620
|
connIds,
|
|
3739
4621
|
);
|
|
3740
4622
|
|
|
3741
|
-
this.
|
|
3742
|
-
|
|
3743
|
-
|
|
3744
|
-
|
|
3745
|
-
|
|
3746
|
-
accountId,
|
|
3747
|
-
connIds: Array.from(connIds),
|
|
3748
|
-
}),
|
|
3749
|
-
{ debugOnly: true },
|
|
3750
|
-
);
|
|
4623
|
+
this.logFileTransferCompleteSend({
|
|
4624
|
+
transferId,
|
|
4625
|
+
accountId,
|
|
4626
|
+
connIds,
|
|
4627
|
+
});
|
|
3751
4628
|
|
|
3752
4629
|
const done = await this.waitCompleteAck({ transferId, timeoutMs: 60_000 });
|
|
3753
4630
|
|
|
3754
|
-
this.
|
|
3755
|
-
|
|
3756
|
-
|
|
3757
|
-
|
|
3758
|
-
|
|
3759
|
-
accountId,
|
|
3760
|
-
payload: done,
|
|
3761
|
-
}),
|
|
3762
|
-
{ debugOnly: true },
|
|
3763
|
-
);
|
|
4631
|
+
this.logFileTransferCompleteAck({
|
|
4632
|
+
transferId,
|
|
4633
|
+
accountId,
|
|
4634
|
+
payload: done,
|
|
4635
|
+
});
|
|
3764
4636
|
|
|
3765
4637
|
return {
|
|
3766
4638
|
mode: 'chunk',
|
|
@@ -3774,130 +4646,101 @@ class BncrBridgeRuntime {
|
|
|
3774
4646
|
accountId: string;
|
|
3775
4647
|
sessionKey: string;
|
|
3776
4648
|
route: BncrRoute;
|
|
3777
|
-
payload:
|
|
3778
|
-
text?: string;
|
|
3779
|
-
mediaUrl?: string;
|
|
3780
|
-
mediaUrls?: string[];
|
|
3781
|
-
asVoice?: boolean;
|
|
3782
|
-
audioAsVoice?: boolean;
|
|
3783
|
-
kind?: 'tool' | 'block' | 'final';
|
|
3784
|
-
replyToId?: string;
|
|
3785
|
-
};
|
|
4649
|
+
payload: ReplyPayloadInput;
|
|
3786
4650
|
mediaLocalRoots?: readonly string[];
|
|
3787
4651
|
}) {
|
|
3788
4652
|
const { accountId, sessionKey, route, payload, mediaLocalRoots } = params;
|
|
4653
|
+
const normalized = normalizeReplyPayload(payload, { asString });
|
|
3789
4654
|
|
|
3790
|
-
|
|
3791
|
-
|
|
3792
|
-
`enqueue-from-reply ${JSON.stringify({
|
|
4655
|
+
enqueueNormalizedReplyPayload(
|
|
4656
|
+
{
|
|
3793
4657
|
accountId,
|
|
3794
4658
|
sessionKey,
|
|
3795
|
-
route
|
|
3796
|
-
|
|
3797
|
-
|
|
3798
|
-
|
|
3799
|
-
|
|
3800
|
-
|
|
3801
|
-
|
|
3802
|
-
|
|
3803
|
-
|
|
3804
|
-
|
|
3805
|
-
|
|
3806
|
-
|
|
3807
|
-
|
|
3808
|
-
|
|
3809
|
-
})}`,
|
|
3810
|
-
{ debugOnly: true },
|
|
4659
|
+
route,
|
|
4660
|
+
payload: normalized,
|
|
4661
|
+
mediaLocalRoots,
|
|
4662
|
+
},
|
|
4663
|
+
{
|
|
4664
|
+
logEnqueueFromReply: (args) => this.logEnqueueFromReply(args),
|
|
4665
|
+
hasReplyMediaEntries,
|
|
4666
|
+
enqueueReplyMediaEntries: (args) => this.enqueueReplyMediaEntries(args),
|
|
4667
|
+
enqueueReplyTextEntry: (args) =>
|
|
4668
|
+
enqueueReplyTextEntry(args, {
|
|
4669
|
+
enqueueOutbound: (entry) => this.enqueueOutbound(entry),
|
|
4670
|
+
buildTextOutboxEntry: (entryArgs) => this.buildTextOutboxEntry(entryArgs),
|
|
4671
|
+
}),
|
|
4672
|
+
},
|
|
3811
4673
|
);
|
|
4674
|
+
}
|
|
3812
4675
|
|
|
3813
|
-
|
|
3814
|
-
|
|
3815
|
-
|
|
3816
|
-
|
|
3817
|
-
|
|
3818
|
-
|
|
3819
|
-
|
|
3820
|
-
|
|
3821
|
-
|
|
3822
|
-
|
|
3823
|
-
|
|
3824
|
-
|
|
3825
|
-
|
|
3826
|
-
const normalizedText = normalizeMessageText(first ? payload.text : '');
|
|
3827
|
-
const normalizedReplyToId = normalizeReplyToId(payload.replyToId);
|
|
3828
|
-
const fallback = this.tryBuildMediaDedupeFallback({
|
|
3829
|
-
sessionKey,
|
|
3830
|
-
mediaUrl: normalizedMediaUrl,
|
|
3831
|
-
text: normalizedText,
|
|
3832
|
-
replyToId: normalizedReplyToId,
|
|
3833
|
-
currentTime,
|
|
3834
|
-
});
|
|
4676
|
+
private logEnqueueFromReply(args: {
|
|
4677
|
+
accountId: string;
|
|
4678
|
+
sessionKey: string;
|
|
4679
|
+
route: BncrRoute;
|
|
4680
|
+
payload: NormalizedReplyPayload;
|
|
4681
|
+
}) {
|
|
4682
|
+
this.logInfo(
|
|
4683
|
+
'outbound',
|
|
4684
|
+
`enqueue-from-reply ${JSON.stringify(buildEnqueueFromReplyDebugInfo(args))}`,
|
|
4685
|
+
{ debugOnly: true },
|
|
4686
|
+
);
|
|
4687
|
+
}
|
|
3835
4688
|
|
|
3836
|
-
|
|
3837
|
-
|
|
3838
|
-
|
|
3839
|
-
|
|
3840
|
-
|
|
3841
|
-
|
|
3842
|
-
|
|
3843
|
-
|
|
3844
|
-
|
|
3845
|
-
|
|
3846
|
-
|
|
3847
|
-
|
|
3848
|
-
|
|
3849
|
-
|
|
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
|
-
}
|
|
4689
|
+
private enqueueSingleReplyMediaEntry(args: {
|
|
4690
|
+
params: ReplyMediaEntriesParams;
|
|
4691
|
+
mediaUrl: string;
|
|
4692
|
+
first: boolean;
|
|
4693
|
+
currentTime: number;
|
|
4694
|
+
}) {
|
|
4695
|
+
const normalizedText = normalizeMessageText(args.first ? args.params.payload.text : '');
|
|
4696
|
+
const fallback = this.tryBuildMediaDedupeFallback({
|
|
4697
|
+
sessionKey: args.params.sessionKey,
|
|
4698
|
+
mediaUrl: args.mediaUrl,
|
|
4699
|
+
text: normalizedText,
|
|
4700
|
+
replyToId: args.params.payload.replyToId,
|
|
4701
|
+
currentTime: args.currentTime,
|
|
4702
|
+
});
|
|
3861
4703
|
|
|
3862
|
-
|
|
3863
|
-
|
|
3864
|
-
|
|
3865
|
-
|
|
3866
|
-
|
|
3867
|
-
|
|
3868
|
-
|
|
3869
|
-
|
|
3870
|
-
|
|
3871
|
-
|
|
3872
|
-
|
|
3873
|
-
|
|
4704
|
+
enqueueSingleReplyMediaEntry(
|
|
4705
|
+
{
|
|
4706
|
+
params: args.params,
|
|
4707
|
+
mediaUrl: args.mediaUrl,
|
|
4708
|
+
normalizedText,
|
|
4709
|
+
text: args.first ? args.params.payload.text : '',
|
|
4710
|
+
fallback,
|
|
4711
|
+
currentTime: args.currentTime,
|
|
4712
|
+
},
|
|
4713
|
+
{
|
|
4714
|
+
enqueueReplyMediaFallbackTextEntry: (params) =>
|
|
4715
|
+
enqueueReplyMediaFallbackTextEntry(params, {
|
|
4716
|
+
logInfo: (scope, message, options) => this.logInfo(scope, message, options),
|
|
4717
|
+
enqueueOutbound: (entry) => this.enqueueOutbound(entry),
|
|
4718
|
+
buildTextOutboxEntry: (entryParams) => this.buildTextOutboxEntry(entryParams),
|
|
3874
4719
|
}),
|
|
3875
|
-
)
|
|
3876
|
-
|
|
3877
|
-
|
|
3878
|
-
|
|
3879
|
-
|
|
3880
|
-
|
|
3881
|
-
|
|
3882
|
-
|
|
3883
|
-
|
|
3884
|
-
|
|
3885
|
-
return;
|
|
3886
|
-
}
|
|
4720
|
+
enqueueReplyMediaFileTransferEntry: (params) =>
|
|
4721
|
+
enqueueReplyMediaFileTransferEntry(params, {
|
|
4722
|
+
enqueueOutbound: (entry) => this.enqueueOutbound(entry),
|
|
4723
|
+
buildFileTransferOutboxEntry: (entryParams) =>
|
|
4724
|
+
this.buildFileTransferOutboxEntry(entryParams),
|
|
4725
|
+
rememberRecentMediaSend: (entryParams) => this.rememberRecentMediaSend(entryParams),
|
|
4726
|
+
}),
|
|
4727
|
+
},
|
|
4728
|
+
);
|
|
4729
|
+
}
|
|
3887
4730
|
|
|
3888
|
-
|
|
3889
|
-
|
|
4731
|
+
private enqueueReplyMediaEntries(params: ReplyMediaEntriesParams) {
|
|
4732
|
+
let first = true;
|
|
4733
|
+
const currentTime = now();
|
|
3890
4734
|
|
|
3891
|
-
|
|
3892
|
-
this.
|
|
3893
|
-
|
|
3894
|
-
|
|
3895
|
-
|
|
3896
|
-
|
|
3897
|
-
|
|
3898
|
-
|
|
3899
|
-
|
|
3900
|
-
);
|
|
4735
|
+
for (const mediaUrl of params.payload.mediaList) {
|
|
4736
|
+
this.enqueueSingleReplyMediaEntry({
|
|
4737
|
+
params,
|
|
4738
|
+
mediaUrl,
|
|
4739
|
+
first,
|
|
4740
|
+
currentTime,
|
|
4741
|
+
});
|
|
4742
|
+
first = false;
|
|
4743
|
+
}
|
|
3901
4744
|
}
|
|
3902
4745
|
|
|
3903
4746
|
handleConnect = async ({ params, respond, client, context }: GatewayRequestHandlerOptions) => {
|
|
@@ -3924,159 +4767,71 @@ class BncrBridgeRuntime {
|
|
|
3924
4767
|
{ debugOnly: true },
|
|
3925
4768
|
);
|
|
3926
4769
|
|
|
3927
|
-
this.
|
|
3928
|
-
this.markSeen(accountId, connId, clientId);
|
|
3929
|
-
this.markOutboundCapability({
|
|
4770
|
+
this.refreshLiveConnectionState({
|
|
3930
4771
|
accountId,
|
|
3931
4772
|
connId,
|
|
3932
4773
|
clientId,
|
|
3933
4774
|
outboundReady,
|
|
3934
4775
|
preferredForOutbound,
|
|
3935
|
-
inboundOnly,
|
|
3936
|
-
|
|
3937
|
-
|
|
3938
|
-
this.incrementCounter(this.connectEventsByAccount, accountId);
|
|
3939
|
-
const lease = this.acceptConnection();
|
|
3940
|
-
|
|
3941
|
-
respond(true, {
|
|
3942
|
-
channel: CHANNEL_ID,
|
|
3943
|
-
accountId,
|
|
3944
|
-
bridgeVersion: BRIDGE_VERSION,
|
|
3945
|
-
pushEvent: BNCR_PUSH_EVENT,
|
|
3946
|
-
online: true,
|
|
3947
|
-
isPrimary: this.isPrimaryConnection(accountId, clientId),
|
|
3948
|
-
activeConnections: this.activeConnectionCount(accountId),
|
|
3949
|
-
pending: Array.from(this.outbox.values()).filter((v) => v.accountId === accountId).length,
|
|
3950
|
-
deadLetter: this.deadLetter.filter((v) => v.accountId === accountId).length,
|
|
3951
|
-
diagnostics: this.buildExtendedDiagnostics(accountId),
|
|
3952
|
-
runtimeFlags: this.buildRuntimeFlags(accountId),
|
|
3953
|
-
waiters: {
|
|
3954
|
-
messageAck: this.messageAckWaiters.size,
|
|
3955
|
-
fileAck: this.fileAckWaiters.size,
|
|
3956
|
-
},
|
|
3957
|
-
leaseId: lease.leaseId,
|
|
3958
|
-
connectionEpoch: lease.connectionEpoch,
|
|
3959
|
-
protocolVersion: 2,
|
|
3960
|
-
acceptedAt: lease.acceptedAt,
|
|
3961
|
-
serverPid: this.gatewayPid,
|
|
3962
|
-
bridgeId: this.bridgeId,
|
|
3963
|
-
now: now(),
|
|
3964
|
-
});
|
|
3965
|
-
|
|
3966
|
-
// WS 一旦在线,立即尝试把离线期间积压队列直推出去
|
|
3967
|
-
this.flushPushQueue({ accountId, trigger: 'connect', reason: 'ws-online' });
|
|
3968
|
-
};
|
|
3969
|
-
|
|
3970
|
-
handleAck = async ({ params, respond, client, context }: GatewayRequestHandlerOptions) => {
|
|
3971
|
-
await this.syncDebugFlag();
|
|
3972
|
-
const accountId = normalizeAccountId(asString(params?.accountId || ''));
|
|
3973
|
-
const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
|
|
3974
|
-
const clientId = asString((params as any)?.clientId || '').trim() || undefined;
|
|
3975
|
-
const messageId = asString(params?.messageId || '').trim();
|
|
3976
|
-
const staleObserved = this.observeLease('ack', params ?? {});
|
|
3977
|
-
|
|
3978
|
-
this.logInfo(
|
|
3979
|
-
'outbox',
|
|
3980
|
-
`ack ${JSON.stringify({
|
|
3981
|
-
accountId,
|
|
3982
|
-
messageId,
|
|
3983
|
-
ok: params?.ok !== false,
|
|
3984
|
-
fatal: params?.fatal === true,
|
|
3985
|
-
error: asString(params?.error || ''),
|
|
3986
|
-
stale: staleObserved.stale,
|
|
3987
|
-
})}`,
|
|
3988
|
-
{ debugOnly: true },
|
|
3989
|
-
);
|
|
3990
|
-
if (!messageId) {
|
|
3991
|
-
respond(false, { error: 'messageId required' });
|
|
3992
|
-
return;
|
|
3993
|
-
}
|
|
3994
|
-
|
|
3995
|
-
const entry = this.outbox.get(messageId);
|
|
3996
|
-
if (!entry) {
|
|
3997
|
-
respond(true, { ok: true, message: 'already-acked-or-missing', stale: staleObserved.stale });
|
|
3998
|
-
return;
|
|
3999
|
-
}
|
|
4000
|
-
|
|
4001
|
-
if (entry.accountId !== accountId) {
|
|
4002
|
-
respond(false, { error: 'account mismatch' });
|
|
4003
|
-
return;
|
|
4004
|
-
}
|
|
4005
|
-
|
|
4006
|
-
if (staleObserved.stale) {
|
|
4007
|
-
const sameConn = !!entry.lastPushConnId && entry.lastPushConnId === connId;
|
|
4008
|
-
const sameClient =
|
|
4009
|
-
!entry.lastPushConnId &&
|
|
4010
|
-
!!entry.lastPushClientId &&
|
|
4011
|
-
!!clientId &&
|
|
4012
|
-
entry.lastPushClientId === clientId;
|
|
4013
|
-
if (!(sameConn || sameClient)) {
|
|
4014
|
-
this.logWarn(
|
|
4015
|
-
'stale',
|
|
4016
|
-
`ignore kind=ack accountId=${accountId} connId=${connId} clientId=${clientId || '-'} messageId=${messageId} reason=owner-mismatch lastPushConnId=${entry.lastPushConnId || '-'} lastPushClientId=${entry.lastPushClientId || '-'}`,
|
|
4017
|
-
{ debugOnly: true },
|
|
4018
|
-
);
|
|
4019
|
-
respond(true, { ok: true, stale: true, ignored: true });
|
|
4020
|
-
return;
|
|
4021
|
-
}
|
|
4022
|
-
} else {
|
|
4023
|
-
this.rememberGatewayContext(context);
|
|
4024
|
-
this.markSeen(accountId, connId, clientId);
|
|
4025
|
-
}
|
|
4026
|
-
this.lastAckAtGlobal = now();
|
|
4027
|
-
this.incrementCounter(this.ackEventsByAccount, accountId);
|
|
4028
|
-
|
|
4029
|
-
const ok = params?.ok !== false;
|
|
4030
|
-
const fatal = params?.fatal === true;
|
|
4031
|
-
|
|
4032
|
-
if (ok) {
|
|
4033
|
-
this.markOutboundCapability({
|
|
4034
|
-
accountId,
|
|
4035
|
-
connId,
|
|
4036
|
-
clientId,
|
|
4037
|
-
outboundReady: true,
|
|
4038
|
-
preferredForOutbound: true,
|
|
4039
|
-
});
|
|
4040
|
-
this.outbox.delete(messageId);
|
|
4041
|
-
this.scheduleSave();
|
|
4042
|
-
this.resolveMessageAck(messageId, 'acked');
|
|
4043
|
-
this.logInfo('outbox ack ok', `mid=${messageId}|q=${this.outbox.size}`);
|
|
4044
|
-
respond(
|
|
4045
|
-
true,
|
|
4046
|
-
staleObserved.stale ? { ok: true, stale: true, staleAccepted: true } : { ok: true },
|
|
4047
|
-
);
|
|
4048
|
-
this.flushPushQueue({ accountId, trigger: 'ack-ok', reason: 'message-acked' });
|
|
4049
|
-
return;
|
|
4050
|
-
}
|
|
4776
|
+
inboundOnly,
|
|
4777
|
+
context,
|
|
4778
|
+
});
|
|
4779
|
+
this.incrementCounter(this.connectEventsByAccount, accountId);
|
|
4780
|
+
const lease = this.acceptConnection();
|
|
4051
4781
|
|
|
4052
|
-
|
|
4053
|
-
|
|
4054
|
-
|
|
4055
|
-
|
|
4056
|
-
|
|
4057
|
-
|
|
4058
|
-
|
|
4059
|
-
|
|
4060
|
-
|
|
4061
|
-
)
|
|
4062
|
-
|
|
4063
|
-
|
|
4782
|
+
respond(true, {
|
|
4783
|
+
channel: CHANNEL_ID,
|
|
4784
|
+
accountId,
|
|
4785
|
+
bridgeVersion: BRIDGE_VERSION,
|
|
4786
|
+
pushEvent: BNCR_PUSH_EVENT,
|
|
4787
|
+
online: true,
|
|
4788
|
+
isPrimary: this.isPrimaryConnection(accountId, clientId),
|
|
4789
|
+
activeConnections: this.activeConnectionCount(accountId),
|
|
4790
|
+
pending: Array.from(this.outbox.values()).filter((v) => v.accountId === accountId).length,
|
|
4791
|
+
deadLetter: this.deadLetter.filter((v) => v.accountId === accountId).length,
|
|
4792
|
+
diagnostics: this.buildExtendedDiagnostics(accountId),
|
|
4793
|
+
runtimeFlags: this.buildRuntimeFlags(accountId),
|
|
4794
|
+
waiters: {
|
|
4795
|
+
messageAck: this.messageAckWaiters.size,
|
|
4796
|
+
fileAck: this.fileAckWaiters.size,
|
|
4797
|
+
},
|
|
4798
|
+
leaseId: lease.leaseId,
|
|
4799
|
+
connectionEpoch: lease.connectionEpoch,
|
|
4800
|
+
protocolVersion: 2,
|
|
4801
|
+
acceptedAt: lease.acceptedAt,
|
|
4802
|
+
serverPid: this.gatewayPid,
|
|
4803
|
+
bridgeId: this.bridgeId,
|
|
4804
|
+
now: now(),
|
|
4805
|
+
});
|
|
4064
4806
|
|
|
4065
|
-
|
|
4066
|
-
|
|
4067
|
-
|
|
4068
|
-
|
|
4069
|
-
|
|
4807
|
+
// WS 一旦在线,立即尝试把离线期间积压队列直推出去
|
|
4808
|
+
this.flushPushQueue({
|
|
4809
|
+
accountId,
|
|
4810
|
+
trigger: OUTBOUND_FLUSH_TRIGGER.CONNECT,
|
|
4811
|
+
reason: OUTBOUND_FLUSH_REASON.WS_ONLINE,
|
|
4812
|
+
});
|
|
4813
|
+
};
|
|
4070
4814
|
|
|
4071
|
-
|
|
4072
|
-
|
|
4073
|
-
|
|
4074
|
-
|
|
4075
|
-
|
|
4076
|
-
);
|
|
4815
|
+
handleAck = async ({ params, respond, client, context }: GatewayRequestHandlerOptions) => {
|
|
4816
|
+
// Structure note (explicit ACK event boundary):
|
|
4817
|
+
// Successful ACK events are the authoritative source for removing outbox entries and resolving
|
|
4818
|
+
// message-ack waiters. flushPushQueue may wait for ACKs, but it is not the source of truth for
|
|
4819
|
+
// final entry deletion. Keep this boundary explicit in future refactors.
|
|
4820
|
+
await this.syncDebugFlag();
|
|
4821
|
+
const prepared = this.prepareAckHandling({ params, respond, client, context });
|
|
4822
|
+
if (!prepared) return;
|
|
4823
|
+
|
|
4824
|
+
const { accountId } = prepared;
|
|
4825
|
+
this.lastAckAtGlobal = now();
|
|
4826
|
+
this.incrementCounter(this.ackEventsByAccount, accountId);
|
|
4827
|
+
this.handleAckOutcome({ params, respond, ...prepared });
|
|
4077
4828
|
};
|
|
4078
4829
|
|
|
4079
4830
|
handleActivity = async ({ params, respond, client, context }: GatewayRequestHandlerOptions) => {
|
|
4831
|
+
// Structure note (activity-driven flush nudge):
|
|
4832
|
+
// Activity events refresh liveness/capability state first, then nudge outbound draining.
|
|
4833
|
+
// They are not a retry policy engine by themselves; they only give the scheduler a better
|
|
4834
|
+
// chance to drain with fresher reachability information.
|
|
4080
4835
|
await this.syncDebugFlag();
|
|
4081
4836
|
const accountId = normalizeAccountId(asString(params?.accountId || ''));
|
|
4082
4837
|
const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
|
|
@@ -4111,17 +4866,15 @@ class BncrBridgeRuntime {
|
|
|
4111
4866
|
})}`,
|
|
4112
4867
|
{ debugOnly: true },
|
|
4113
4868
|
);
|
|
4114
|
-
this.
|
|
4115
|
-
this.markSeen(accountId, connId, clientId);
|
|
4116
|
-
this.markOutboundCapability({
|
|
4869
|
+
this.refreshLiveConnectionState({
|
|
4117
4870
|
accountId,
|
|
4118
4871
|
connId,
|
|
4119
4872
|
clientId,
|
|
4120
4873
|
outboundReady,
|
|
4121
4874
|
preferredForOutbound,
|
|
4122
4875
|
inboundOnly,
|
|
4876
|
+
context,
|
|
4123
4877
|
});
|
|
4124
|
-
this.markActivity(accountId);
|
|
4125
4878
|
this.incrementCounter(this.activityEventsByAccount, accountId);
|
|
4126
4879
|
|
|
4127
4880
|
// 轻量活动心跳:仅刷新在线活跃状态,不承担拉取职责。
|
|
@@ -4134,7 +4887,11 @@ class BncrBridgeRuntime {
|
|
|
4134
4887
|
deadLetter: this.deadLetter.filter((v) => v.accountId === accountId).length,
|
|
4135
4888
|
now: now(),
|
|
4136
4889
|
});
|
|
4137
|
-
this.flushPushQueue({
|
|
4890
|
+
this.flushPushQueue({
|
|
4891
|
+
accountId,
|
|
4892
|
+
trigger: OUTBOUND_FLUSH_TRIGGER.ACTIVITY,
|
|
4893
|
+
reason: OUTBOUND_FLUSH_REASON.ACTIVITY_HEARTBEAT,
|
|
4894
|
+
});
|
|
4138
4895
|
};
|
|
4139
4896
|
|
|
4140
4897
|
handleDiagnostics = async ({ params, respond }: GatewayRequestHandlerOptions) => {
|
|
@@ -4142,37 +4899,27 @@ class BncrBridgeRuntime {
|
|
|
4142
4899
|
const cfg = this.api.runtime.config.current();
|
|
4143
4900
|
const runtime = this.getAccountRuntimeSnapshot(accountId);
|
|
4144
4901
|
const diagnostics = this.buildExtendedDiagnostics(accountId);
|
|
4145
|
-
const permissions = buildBncrPermissionSummary(cfg ?? {});
|
|
4146
|
-
const probe = probeBncrAccount({
|
|
4147
|
-
accountId,
|
|
4148
|
-
connected: Boolean(runtime?.connected),
|
|
4149
|
-
pending: Number(runtime?.meta?.pending ?? 0),
|
|
4150
|
-
deadLetter: Number(runtime?.meta?.deadLetter ?? 0),
|
|
4151
|
-
activeConnections: this.activeConnectionCount(accountId),
|
|
4152
|
-
invalidOutboxSessionKeys: this.countInvalidOutboxSessionKeys(accountId),
|
|
4153
|
-
legacyAccountResidue: this.countLegacyAccountResidue(accountId),
|
|
4154
|
-
lastActivityAt: runtime?.meta?.lastActivityAt ?? null,
|
|
4155
|
-
structure: {
|
|
4156
|
-
coreComplete: true,
|
|
4157
|
-
inboundComplete: true,
|
|
4158
|
-
outboundComplete: true,
|
|
4159
|
-
},
|
|
4160
|
-
});
|
|
4161
4902
|
|
|
4162
|
-
respond(
|
|
4163
|
-
|
|
4164
|
-
|
|
4165
|
-
|
|
4166
|
-
|
|
4167
|
-
|
|
4168
|
-
|
|
4169
|
-
|
|
4170
|
-
|
|
4171
|
-
|
|
4172
|
-
|
|
4173
|
-
|
|
4174
|
-
|
|
4175
|
-
|
|
4903
|
+
respond(
|
|
4904
|
+
true,
|
|
4905
|
+
buildDiagnosticsPayload({
|
|
4906
|
+
cfg,
|
|
4907
|
+
channelId: CHANNEL_ID,
|
|
4908
|
+
accountId,
|
|
4909
|
+
runtime,
|
|
4910
|
+
diagnostics,
|
|
4911
|
+
downlinkHealth: this.buildDownlinkHealth(accountId),
|
|
4912
|
+
runtimeFlags: this.buildRuntimeFlags(accountId),
|
|
4913
|
+
waiters: {
|
|
4914
|
+
messageAck: this.messageAckWaiters.size,
|
|
4915
|
+
fileAck: this.fileAckWaiters.size,
|
|
4916
|
+
},
|
|
4917
|
+
activeConnections: this.activeConnectionCount(accountId),
|
|
4918
|
+
invalidOutboxSessionKeys: this.countInvalidOutboxSessionKeys(accountId),
|
|
4919
|
+
legacyAccountResidue: this.countLegacyAccountResidue(accountId),
|
|
4920
|
+
now: now(),
|
|
4921
|
+
}),
|
|
4922
|
+
);
|
|
4176
4923
|
};
|
|
4177
4924
|
|
|
4178
4925
|
handleFileInit = async ({ params, respond, client, context }: GatewayRequestHandlerOptions) => {
|
|
@@ -4191,9 +4938,12 @@ class BncrBridgeRuntime {
|
|
|
4191
4938
|
respond(true, { ok: true, stale: true, ignored: true });
|
|
4192
4939
|
return;
|
|
4193
4940
|
}
|
|
4194
|
-
this.
|
|
4195
|
-
|
|
4196
|
-
|
|
4941
|
+
this.refreshAcceptedFileTransferLiveState({
|
|
4942
|
+
accountId,
|
|
4943
|
+
connId,
|
|
4944
|
+
clientId,
|
|
4945
|
+
context,
|
|
4946
|
+
});
|
|
4197
4947
|
|
|
4198
4948
|
const transferId = asString(params?.transferId || '').trim();
|
|
4199
4949
|
const sessionKey = asString(params?.sessionKey || '').trim();
|
|
@@ -4301,9 +5051,12 @@ class BncrBridgeRuntime {
|
|
|
4301
5051
|
return;
|
|
4302
5052
|
}
|
|
4303
5053
|
} else {
|
|
4304
|
-
this.
|
|
4305
|
-
|
|
4306
|
-
|
|
5054
|
+
this.refreshAcceptedFileTransferLiveState({
|
|
5055
|
+
accountId,
|
|
5056
|
+
connId,
|
|
5057
|
+
clientId,
|
|
5058
|
+
context,
|
|
5059
|
+
});
|
|
4307
5060
|
}
|
|
4308
5061
|
|
|
4309
5062
|
try {
|
|
@@ -4388,9 +5141,12 @@ class BncrBridgeRuntime {
|
|
|
4388
5141
|
return;
|
|
4389
5142
|
}
|
|
4390
5143
|
} else {
|
|
4391
|
-
this.
|
|
4392
|
-
|
|
4393
|
-
|
|
5144
|
+
this.refreshAcceptedFileTransferLiveState({
|
|
5145
|
+
accountId,
|
|
5146
|
+
connId,
|
|
5147
|
+
clientId,
|
|
5148
|
+
context,
|
|
5149
|
+
});
|
|
4394
5150
|
}
|
|
4395
5151
|
|
|
4396
5152
|
try {
|
|
@@ -4491,9 +5247,12 @@ class BncrBridgeRuntime {
|
|
|
4491
5247
|
return;
|
|
4492
5248
|
}
|
|
4493
5249
|
} else {
|
|
4494
|
-
this.
|
|
4495
|
-
|
|
4496
|
-
|
|
5250
|
+
this.refreshAcceptedFileTransferLiveState({
|
|
5251
|
+
accountId,
|
|
5252
|
+
connId,
|
|
5253
|
+
clientId,
|
|
5254
|
+
context,
|
|
5255
|
+
});
|
|
4497
5256
|
}
|
|
4498
5257
|
|
|
4499
5258
|
st.status = 'aborted';
|
|
@@ -4583,9 +5342,12 @@ class BncrBridgeRuntime {
|
|
|
4583
5342
|
return;
|
|
4584
5343
|
}
|
|
4585
5344
|
} else {
|
|
4586
|
-
this.
|
|
4587
|
-
|
|
4588
|
-
|
|
5345
|
+
this.refreshAcceptedFileTransferLiveState({
|
|
5346
|
+
accountId,
|
|
5347
|
+
connId,
|
|
5348
|
+
clientId,
|
|
5349
|
+
context,
|
|
5350
|
+
});
|
|
4589
5351
|
}
|
|
4590
5352
|
|
|
4591
5353
|
if (st) {
|
|
@@ -4645,6 +5407,10 @@ class BncrBridgeRuntime {
|
|
|
4645
5407
|
};
|
|
4646
5408
|
|
|
4647
5409
|
handleInbound = async ({ params, respond, client, context }: GatewayRequestHandlerOptions) => {
|
|
5410
|
+
// Structure note (inbound-driven flush nudge):
|
|
5411
|
+
// Inbound acceptance is another explicit wake source for outbound draining. It should stay
|
|
5412
|
+
// separate from retry policy so later refactors can reason clearly about "new inbound signal"
|
|
5413
|
+
// versus "scheduled retry" versus "ACK-driven continuation".
|
|
4648
5414
|
await this.syncDebugFlag();
|
|
4649
5415
|
const parsed = parseBncrInboundParams(params);
|
|
4650
5416
|
const {
|
|
@@ -4679,102 +5445,69 @@ class BncrBridgeRuntime {
|
|
|
4679
5445
|
clientId,
|
|
4680
5446
|
})
|
|
4681
5447
|
) {
|
|
4682
|
-
respond(
|
|
4683
|
-
|
|
4684
|
-
|
|
4685
|
-
|
|
4686
|
-
|
|
4687
|
-
|
|
4688
|
-
|
|
5448
|
+
respond(
|
|
5449
|
+
true,
|
|
5450
|
+
buildInboundResponsePayload({
|
|
5451
|
+
kind: 'stale-ignored',
|
|
5452
|
+
accountId,
|
|
5453
|
+
msgId: msgId ?? null,
|
|
5454
|
+
}),
|
|
5455
|
+
);
|
|
4689
5456
|
return;
|
|
4690
5457
|
}
|
|
4691
|
-
this.
|
|
4692
|
-
this.markSeen(accountId, connId, clientId);
|
|
4693
|
-
this.markOutboundCapability({
|
|
5458
|
+
this.refreshLiveConnectionState({
|
|
4694
5459
|
accountId,
|
|
4695
5460
|
connId,
|
|
4696
5461
|
clientId,
|
|
4697
5462
|
outboundReady,
|
|
4698
5463
|
preferredForOutbound,
|
|
4699
5464
|
inboundOnly,
|
|
5465
|
+
context,
|
|
4700
5466
|
});
|
|
4701
|
-
this.markActivity(accountId);
|
|
4702
5467
|
this.logInfo(
|
|
4703
5468
|
'inbound',
|
|
4704
|
-
`lifecycle ${JSON.stringify(
|
|
4705
|
-
|
|
4706
|
-
|
|
4707
|
-
|
|
4708
|
-
|
|
4709
|
-
|
|
4710
|
-
|
|
4711
|
-
|
|
4712
|
-
|
|
4713
|
-
|
|
4714
|
-
|
|
4715
|
-
|
|
4716
|
-
|
|
4717
|
-
.
|
|
4718
|
-
|
|
4719
|
-
|
|
4720
|
-
|
|
4721
|
-
|
|
4722
|
-
|
|
4723
|
-
|
|
4724
|
-
|
|
5469
|
+
`lifecycle ${JSON.stringify(
|
|
5470
|
+
buildInboundAcceptedLifecycleDebugInfo({
|
|
5471
|
+
stage: 'accepted',
|
|
5472
|
+
bridge: this.bridgeId,
|
|
5473
|
+
accountId,
|
|
5474
|
+
connId,
|
|
5475
|
+
clientId,
|
|
5476
|
+
outboundReady,
|
|
5477
|
+
preferredForOutbound,
|
|
5478
|
+
inboundOnly,
|
|
5479
|
+
onlineAfterSeen: this.isOnline(accountId),
|
|
5480
|
+
recentInboundReachable: this.hasRecentInboundReachability(accountId),
|
|
5481
|
+
activeConnectionKey: this.activeConnectionByAccount.get(accountId) || null,
|
|
5482
|
+
activeConnections: Array.from(this.connections.values())
|
|
5483
|
+
.filter((c) => c.accountId === accountId)
|
|
5484
|
+
.map((c) => ({
|
|
5485
|
+
connId: c.connId,
|
|
5486
|
+
clientId: c.clientId,
|
|
5487
|
+
connectedAt: c.connectedAt,
|
|
5488
|
+
lastSeenAt: c.lastSeenAt,
|
|
5489
|
+
})),
|
|
5490
|
+
}),
|
|
5491
|
+
)}`,
|
|
4725
5492
|
{ debugOnly: true },
|
|
4726
5493
|
);
|
|
4727
5494
|
this.lastInboundAtGlobal = now();
|
|
4728
5495
|
this.incrementCounter(this.inboundEventsByAccount, accountId);
|
|
4729
5496
|
|
|
4730
|
-
if (!platform || (!userId && !groupId)) {
|
|
4731
|
-
respond(false, { error: 'platform/groupId/userId required' });
|
|
4732
|
-
return;
|
|
4733
|
-
}
|
|
4734
|
-
if (this.markInboundDedupSeen(dedupKey)) {
|
|
4735
|
-
respond(true, {
|
|
4736
|
-
accepted: true,
|
|
4737
|
-
duplicated: true,
|
|
4738
|
-
accountId,
|
|
4739
|
-
msgId: msgId ?? null,
|
|
4740
|
-
});
|
|
4741
|
-
return;
|
|
4742
|
-
}
|
|
4743
|
-
|
|
4744
5497
|
const cfg = this.api.runtime.config.current();
|
|
4745
|
-
const gate = checkBncrMessageGate({
|
|
4746
|
-
parsed,
|
|
4747
|
-
cfg,
|
|
4748
|
-
account: resolveAccount(cfg, accountId),
|
|
4749
|
-
});
|
|
4750
|
-
if (!gate.allowed) {
|
|
4751
|
-
respond(true, {
|
|
4752
|
-
accepted: false,
|
|
4753
|
-
accountId,
|
|
4754
|
-
msgId: msgId ?? null,
|
|
4755
|
-
reason: gate.reason,
|
|
4756
|
-
});
|
|
4757
|
-
return;
|
|
4758
|
-
}
|
|
4759
|
-
|
|
4760
5498
|
const canonicalAgentId = this.ensureCanonicalAgentId({
|
|
4761
5499
|
cfg,
|
|
4762
5500
|
accountId,
|
|
4763
5501
|
peer,
|
|
4764
5502
|
channelId: CHANNEL_ID,
|
|
4765
5503
|
});
|
|
4766
|
-
const
|
|
4767
|
-
|
|
4768
|
-
|
|
4769
|
-
|
|
4770
|
-
|
|
4771
|
-
|
|
4772
|
-
const
|
|
4773
|
-
normalizeInboundSessionKey(sessionKeyfromroute, route, canonicalAgentId) ||
|
|
4774
|
-
resolvedRoute.sessionKey;
|
|
4775
|
-
const taskSessionKey = withTaskSessionKey(baseSessionKey, extracted.taskKey);
|
|
4776
|
-
const sessionKey = taskSessionKey || baseSessionKey;
|
|
4777
|
-
const inboundText = asString(extracted.text || text || '');
|
|
5504
|
+
const acceptance = this.prepareInboundAcceptance({ parsed, canonicalAgentId });
|
|
5505
|
+
if (!acceptance.ok) {
|
|
5506
|
+
respond(acceptance.status, acceptance.payload);
|
|
5507
|
+
return;
|
|
5508
|
+
}
|
|
5509
|
+
|
|
5510
|
+
const { sessionKey, inboundText, hasMedia } = acceptance;
|
|
4778
5511
|
this.logInfo(
|
|
4779
5512
|
'inbound',
|
|
4780
5513
|
JSON.stringify({
|
|
@@ -4787,7 +5520,7 @@ class BncrBridgeRuntime {
|
|
|
4787
5520
|
msgType,
|
|
4788
5521
|
textLen: inboundText.length,
|
|
4789
5522
|
textPreview: inboundText.slice(0, 120),
|
|
4790
|
-
hasMedia
|
|
5523
|
+
hasMedia,
|
|
4791
5524
|
}),
|
|
4792
5525
|
{ debugOnly: true },
|
|
4793
5526
|
);
|
|
@@ -4796,17 +5529,24 @@ class BncrBridgeRuntime {
|
|
|
4796
5529
|
route,
|
|
4797
5530
|
msgType,
|
|
4798
5531
|
text: inboundText,
|
|
4799
|
-
hasMedia
|
|
5532
|
+
hasMedia,
|
|
4800
5533
|
});
|
|
4801
5534
|
|
|
4802
|
-
respond(
|
|
4803
|
-
|
|
5535
|
+
respond(
|
|
5536
|
+
true,
|
|
5537
|
+
buildInboundResponsePayload({
|
|
5538
|
+
kind: 'accepted',
|
|
5539
|
+
accountId,
|
|
5540
|
+
sessionKey,
|
|
5541
|
+
msgId: msgId ?? null,
|
|
5542
|
+
taskKey: extracted.taskKey ?? null,
|
|
5543
|
+
}),
|
|
5544
|
+
);
|
|
5545
|
+
this.flushPushQueue({
|
|
4804
5546
|
accountId,
|
|
4805
|
-
|
|
4806
|
-
|
|
4807
|
-
taskKey: extracted.taskKey ?? null,
|
|
5547
|
+
trigger: OUTBOUND_FLUSH_TRIGGER.INBOUND,
|
|
5548
|
+
reason: OUTBOUND_FLUSH_REASON.INBOUND_ACCEPTED,
|
|
4808
5549
|
});
|
|
4809
|
-
this.flushPushQueue({ accountId, trigger: 'inbound', reason: 'inbound-accepted' });
|
|
4810
5550
|
|
|
4811
5551
|
void dispatchBncrInbound({
|
|
4812
5552
|
api: this.api,
|
|
@@ -4947,36 +5687,69 @@ class BncrBridgeRuntime {
|
|
|
4947
5687
|
this.logInfo('health', `status-stop ${accountId}|cleared=${cleared}`);
|
|
4948
5688
|
};
|
|
4949
5689
|
|
|
4950
|
-
|
|
4951
|
-
|
|
4952
|
-
|
|
4953
|
-
|
|
4954
|
-
|
|
5690
|
+
private logChannelSendEntry(args: {
|
|
5691
|
+
kind: 'text' | 'media';
|
|
5692
|
+
accountId: string;
|
|
5693
|
+
to: string;
|
|
5694
|
+
ctx: any;
|
|
5695
|
+
payload: {
|
|
5696
|
+
text: string;
|
|
5697
|
+
mediaUrl: string;
|
|
5698
|
+
mediaUrls?: string[];
|
|
5699
|
+
asVoice?: boolean;
|
|
5700
|
+
audioAsVoice?: boolean;
|
|
5701
|
+
};
|
|
5702
|
+
}) {
|
|
4955
5703
|
this.logInfo(
|
|
4956
5704
|
'outbound',
|
|
4957
|
-
`send-entry
|
|
4958
|
-
accountId,
|
|
4959
|
-
to,
|
|
4960
|
-
text:
|
|
4961
|
-
mediaUrl:
|
|
4962
|
-
|
|
4963
|
-
|
|
5705
|
+
`send-entry:${args.kind} ${JSON.stringify({
|
|
5706
|
+
accountId: args.accountId,
|
|
5707
|
+
to: args.to,
|
|
5708
|
+
text: args.payload.text,
|
|
5709
|
+
mediaUrl: args.payload.mediaUrl,
|
|
5710
|
+
mediaUrls: args.payload.mediaUrls,
|
|
5711
|
+
asVoice: args.payload.asVoice,
|
|
5712
|
+
audioAsVoice: args.payload.audioAsVoice,
|
|
5713
|
+
sessionKey: asString(args.ctx?.sessionKey || ''),
|
|
5714
|
+
mirrorSessionKey: asString(args.ctx?.mirror?.sessionKey || ''),
|
|
4964
5715
|
rawCtx: {
|
|
4965
|
-
to: ctx?.to,
|
|
4966
|
-
accountId: ctx?.accountId,
|
|
4967
|
-
threadId: ctx?.threadId,
|
|
4968
|
-
replyToId: ctx?.replyToId,
|
|
5716
|
+
to: args.ctx?.to,
|
|
5717
|
+
accountId: args.ctx?.accountId,
|
|
5718
|
+
threadId: args.ctx?.threadId,
|
|
5719
|
+
replyToId: args.ctx?.replyToId,
|
|
4969
5720
|
},
|
|
4970
5721
|
})}`,
|
|
4971
5722
|
{ debugOnly: true },
|
|
4972
5723
|
);
|
|
5724
|
+
}
|
|
5725
|
+
|
|
5726
|
+
private resolveChannelSendReplyToId(ctx: any) {
|
|
5727
|
+
return asString(ctx?.replyToId || ctx?.replyToMessageId || '').trim() || undefined;
|
|
5728
|
+
}
|
|
5729
|
+
|
|
5730
|
+
channelSendText = async (ctx: any) => {
|
|
5731
|
+
await this.syncDebugFlag();
|
|
5732
|
+
const accountId = normalizeAccountId(ctx.accountId);
|
|
5733
|
+
const to = asString(ctx.to || '').trim();
|
|
5734
|
+
const replyToId = this.resolveChannelSendReplyToId(ctx);
|
|
5735
|
+
|
|
5736
|
+
this.logChannelSendEntry({
|
|
5737
|
+
kind: 'text',
|
|
5738
|
+
accountId,
|
|
5739
|
+
to,
|
|
5740
|
+
ctx,
|
|
5741
|
+
payload: {
|
|
5742
|
+
text: asString(ctx?.text || ''),
|
|
5743
|
+
mediaUrl: asString(ctx?.mediaUrl || ''),
|
|
5744
|
+
},
|
|
5745
|
+
});
|
|
4973
5746
|
|
|
4974
5747
|
return sendBncrText({
|
|
4975
5748
|
channelId: CHANNEL_ID,
|
|
4976
5749
|
accountId,
|
|
4977
5750
|
to,
|
|
4978
5751
|
text: asString(ctx.text || ''),
|
|
4979
|
-
replyToId
|
|
5752
|
+
replyToId,
|
|
4980
5753
|
mediaLocalRoots: ctx.mediaLocalRoots,
|
|
4981
5754
|
resolveVerifiedTarget: (to, accountId) => this.resolveVerifiedTarget(to, accountId),
|
|
4982
5755
|
rememberSessionRoute: (sessionKey, accountId, route) =>
|
|
@@ -4992,28 +5765,21 @@ class BncrBridgeRuntime {
|
|
|
4992
5765
|
const to = asString(ctx.to || '').trim();
|
|
4993
5766
|
const asVoice = ctx?.asVoice === true;
|
|
4994
5767
|
const audioAsVoice = ctx?.audioAsVoice === true;
|
|
5768
|
+
const replyToId = this.resolveChannelSendReplyToId(ctx);
|
|
4995
5769
|
|
|
4996
|
-
this.
|
|
4997
|
-
'
|
|
4998
|
-
|
|
4999
|
-
|
|
5000
|
-
|
|
5770
|
+
this.logChannelSendEntry({
|
|
5771
|
+
kind: 'media',
|
|
5772
|
+
accountId,
|
|
5773
|
+
to,
|
|
5774
|
+
ctx,
|
|
5775
|
+
payload: {
|
|
5001
5776
|
text: asString(ctx?.text || ''),
|
|
5002
5777
|
mediaUrl: asString(ctx?.mediaUrl || ''),
|
|
5003
5778
|
mediaUrls: Array.isArray(ctx?.mediaUrls) ? ctx.mediaUrls : undefined,
|
|
5004
5779
|
asVoice,
|
|
5005
5780
|
audioAsVoice,
|
|
5006
|
-
|
|
5007
|
-
|
|
5008
|
-
rawCtx: {
|
|
5009
|
-
to: ctx?.to,
|
|
5010
|
-
accountId: ctx?.accountId,
|
|
5011
|
-
threadId: ctx?.threadId,
|
|
5012
|
-
replyToId: ctx?.replyToId,
|
|
5013
|
-
},
|
|
5014
|
-
})}`,
|
|
5015
|
-
{ debugOnly: true },
|
|
5016
|
-
);
|
|
5781
|
+
},
|
|
5782
|
+
});
|
|
5017
5783
|
|
|
5018
5784
|
return sendBncrMedia({
|
|
5019
5785
|
channelId: CHANNEL_ID,
|
|
@@ -5024,7 +5790,7 @@ class BncrBridgeRuntime {
|
|
|
5024
5790
|
mediaUrls: Array.isArray(ctx?.mediaUrls) ? ctx.mediaUrls : undefined,
|
|
5025
5791
|
asVoice,
|
|
5026
5792
|
audioAsVoice,
|
|
5027
|
-
replyToId
|
|
5793
|
+
replyToId,
|
|
5028
5794
|
mediaLocalRoots: ctx.mediaLocalRoots,
|
|
5029
5795
|
resolveVerifiedTarget: (to, accountId) => this.resolveVerifiedTarget(to, accountId),
|
|
5030
5796
|
rememberSessionRoute: (sessionKey, accountId, route) =>
|
|
@@ -5294,53 +6060,13 @@ export function createBncrChannelPlugin(getBridge: () => BncrBridgeRuntime) {
|
|
|
5294
6060
|
buildAccountSnapshot: async ({ account, runtime }: any) => {
|
|
5295
6061
|
const runtimeBridge = getBridge();
|
|
5296
6062
|
const rt = runtime || runtimeBridge.getAccountRuntimeSnapshot(account?.accountId);
|
|
5297
|
-
|
|
5298
|
-
|
|
5299
|
-
|
|
5300
|
-
const deadLetter = Number(rt?.deadLetter ?? meta.deadLetter ?? 0);
|
|
5301
|
-
const lastSessionKey = rt?.lastSessionKey ?? meta.lastSessionKey ?? null;
|
|
5302
|
-
const lastSessionScope = rt?.lastSessionScope ?? meta.lastSessionScope ?? null;
|
|
5303
|
-
const lastSessionAt = rt?.lastSessionAt ?? meta.lastSessionAt ?? null;
|
|
5304
|
-
const lastSessionAgo = rt?.lastSessionAgo ?? meta.lastSessionAgo ?? '-';
|
|
5305
|
-
const lastActivityAt = rt?.lastActivityAt ?? meta.lastActivityAt ?? null;
|
|
5306
|
-
const lastActivityAgo = rt?.lastActivityAgo ?? meta.lastActivityAgo ?? '-';
|
|
5307
|
-
const lastInboundAt = rt?.lastInboundAt ?? meta.lastInboundAt ?? null;
|
|
5308
|
-
const lastInboundAgo = rt?.lastInboundAgo ?? meta.lastInboundAgo ?? '-';
|
|
5309
|
-
const lastOutboundAt = rt?.lastOutboundAt ?? meta.lastOutboundAt ?? null;
|
|
5310
|
-
const lastOutboundAgo = rt?.lastOutboundAgo ?? meta.lastOutboundAgo ?? '-';
|
|
5311
|
-
const diagnostics = rt?.diagnostics ?? meta.diagnostics ?? null;
|
|
5312
|
-
// 右侧状态字段统一:离线时也显示 Status(避免出现 configured 文案)
|
|
5313
|
-
const normalizedMode = rt?.mode === 'linked' ? 'linked' : 'Status';
|
|
5314
|
-
|
|
5315
|
-
const displayName = resolveDefaultDisplayName(account?.name, account?.accountId);
|
|
5316
|
-
|
|
5317
|
-
return {
|
|
5318
|
-
accountId: account.accountId,
|
|
5319
|
-
// default 名不可隐藏时,统一展示稳定默认值
|
|
5320
|
-
name: displayName,
|
|
5321
|
-
enabled: account.enabled !== false,
|
|
5322
|
-
configured: true,
|
|
5323
|
-
linked: Boolean(rt?.connected),
|
|
5324
|
-
running: rt?.running ?? false,
|
|
5325
|
-
connected: rt?.connected ?? false,
|
|
5326
|
-
lastEventAt: rt?.lastEventAt ?? null,
|
|
5327
|
-
lastError: rt?.lastError ?? null,
|
|
5328
|
-
mode: normalizedMode,
|
|
5329
|
-
pending,
|
|
5330
|
-
deadLetter,
|
|
6063
|
+
return buildAccountStatusSnapshot({
|
|
6064
|
+
account,
|
|
6065
|
+
runtime: rt,
|
|
5331
6066
|
healthSummary: runtimeBridge.getStatusHeadline(account?.accountId),
|
|
5332
|
-
|
|
5333
|
-
|
|
5334
|
-
|
|
5335
|
-
lastSessionAgo,
|
|
5336
|
-
lastActivityAt,
|
|
5337
|
-
lastActivityAgo,
|
|
5338
|
-
lastInboundAt,
|
|
5339
|
-
lastInboundAgo,
|
|
5340
|
-
lastOutboundAt,
|
|
5341
|
-
lastOutboundAgo,
|
|
5342
|
-
diagnostics,
|
|
5343
|
-
};
|
|
6067
|
+
// default 名不可隐藏时,统一展示稳定默认值
|
|
6068
|
+
displayName: resolveDefaultDisplayName(account?.name, account?.accountId),
|
|
6069
|
+
});
|
|
5344
6070
|
},
|
|
5345
6071
|
resolveAccountState: ({ enabled, configured, account, cfg, runtime }: any) => {
|
|
5346
6072
|
if (!enabled) return 'disabled';
|