@xmoxmo/bncr 0.2.3 → 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/index.ts +24 -1
- package/package.json +1 -1
- package/src/channel.ts +2823 -1178
- 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 +62 -10
- package/src/core/types.ts +3 -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/send.ts +2 -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 {
|
|
@@ -83,6 +325,8 @@ const RECENT_INBOUND_SEND_WINDOW_MS = 60_000;
|
|
|
83
325
|
const MAX_RETRY = 10;
|
|
84
326
|
const PUSH_DRAIN_INTERVAL_MS = 500;
|
|
85
327
|
const PUSH_ACK_TIMEOUT_MS = 30_000;
|
|
328
|
+
const OUTBOUND_READY_TTL_MS = 30_000;
|
|
329
|
+
const PREFERRED_OUTBOUND_TTL_MS = 12_000;
|
|
86
330
|
const FILE_FORCE_CHUNK = true; // 统一走 WS 分块,保留 base64 仅作兜底
|
|
87
331
|
const FILE_INLINE_THRESHOLD = 5 * 1024 * 1024; // fallback 阈值(仅 FILE_FORCE_CHUNK=false 时生效)
|
|
88
332
|
const FILE_CHUNK_SIZE = 256 * 1024; // 256KB
|
|
@@ -246,6 +490,7 @@ function normalizeBncrSendParams(input: {
|
|
|
246
490
|
};
|
|
247
491
|
}
|
|
248
492
|
|
|
493
|
+
|
|
249
494
|
function now() {
|
|
250
495
|
return Date.now();
|
|
251
496
|
}
|
|
@@ -333,6 +578,7 @@ class BncrBridgeRuntime {
|
|
|
333
578
|
private api: OpenClawPluginApi;
|
|
334
579
|
private statePath: string | null = null;
|
|
335
580
|
private bridgeId = `${process.pid}-${Math.random().toString(16).slice(2, 8)}`;
|
|
581
|
+
private recentMediaDedupeBySession = new Map<string, Map<string, MediaDedupeCacheEntry>>();
|
|
336
582
|
private gatewayPid = process.pid;
|
|
337
583
|
private registerCount = 0;
|
|
338
584
|
private apiGeneration = 0;
|
|
@@ -409,7 +655,11 @@ class BncrBridgeRuntime {
|
|
|
409
655
|
private lastActivityByAccount = new Map<string, number>();
|
|
410
656
|
private lastInboundByAccount = new Map<string, number>();
|
|
411
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>();
|
|
412
661
|
private channelAccountTimers = new Map<string, NodeJS.Timeout>();
|
|
662
|
+
private logDedupeState = new Map<string, { at: number; sig: string }>();
|
|
413
663
|
private canonicalAgentId: string | null = null;
|
|
414
664
|
private canonicalAgentSource: 'startup' | 'runtime' | 'fallback-main' | null = null;
|
|
415
665
|
private canonicalAgentResolvedAt: number | null = null;
|
|
@@ -425,6 +675,11 @@ class BncrBridgeRuntime {
|
|
|
425
675
|
private pushTimer: NodeJS.Timeout | null = null;
|
|
426
676
|
private pushDrainRunningAccounts = new Set<string>();
|
|
427
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.
|
|
428
683
|
string,
|
|
429
684
|
{
|
|
430
685
|
resolve: (result: 'acked' | 'timeout') => void;
|
|
@@ -470,6 +725,102 @@ class BncrBridgeRuntime {
|
|
|
470
725
|
emitBncrLog('error', scope, message, options, () => this.isDebugEnabled());
|
|
471
726
|
}
|
|
472
727
|
|
|
728
|
+
private buildDebugJsonMessage(event: string, payload: Record<string, unknown>) {
|
|
729
|
+
return `${event} ${JSON.stringify(payload)}`;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
private logInfoJson(
|
|
733
|
+
scope: string | undefined,
|
|
734
|
+
event: string,
|
|
735
|
+
payload: Record<string, unknown>,
|
|
736
|
+
options?: { debugOnly?: boolean },
|
|
737
|
+
) {
|
|
738
|
+
this.logInfo(scope, this.buildDebugJsonMessage(event, payload), options);
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
private logWarnJson(
|
|
742
|
+
scope: string | undefined,
|
|
743
|
+
event: string,
|
|
744
|
+
payload: Record<string, unknown>,
|
|
745
|
+
options?: { debugOnly?: boolean },
|
|
746
|
+
) {
|
|
747
|
+
this.logWarn(scope, this.buildDebugJsonMessage(event, payload), options);
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
private logErrorJson(
|
|
751
|
+
scope: string | undefined,
|
|
752
|
+
event: string,
|
|
753
|
+
payload: Record<string, unknown>,
|
|
754
|
+
options?: { debugOnly?: boolean },
|
|
755
|
+
) {
|
|
756
|
+
this.logError(scope, this.buildDebugJsonMessage(event, payload), options);
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
private shouldEmitDedupLog(key: string, sig: string, windowMs = 5 * 60 * 1000) {
|
|
760
|
+
const t = now();
|
|
761
|
+
const prev = this.logDedupeState.get(key) || null;
|
|
762
|
+
if (prev && prev.sig === sig && t - prev.at < windowMs) return false;
|
|
763
|
+
this.logDedupeState.set(key, { at: t, sig });
|
|
764
|
+
return true;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
private logInfoDedup(
|
|
768
|
+
scope: string | undefined,
|
|
769
|
+
message: string,
|
|
770
|
+
options: { key: string; sig: string; debugOnly?: boolean; windowMs?: number },
|
|
771
|
+
) {
|
|
772
|
+
if (!this.shouldEmitDedupLog(options.key, options.sig, options.windowMs)) return;
|
|
773
|
+
this.logInfo(scope, message, { debugOnly: options.debugOnly });
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
private logWarnDedup(
|
|
777
|
+
scope: string | undefined,
|
|
778
|
+
message: string,
|
|
779
|
+
options: { key: string; sig: string; debugOnly?: boolean; windowMs?: number },
|
|
780
|
+
) {
|
|
781
|
+
if (!this.shouldEmitDedupLog(options.key, options.sig, options.windowMs)) return;
|
|
782
|
+
this.logWarn(scope, message, { debugOnly: options.debugOnly });
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
private logErrorDedup(
|
|
786
|
+
scope: string | undefined,
|
|
787
|
+
message: string,
|
|
788
|
+
options: { key: string; sig: string; debugOnly?: boolean; windowMs?: number },
|
|
789
|
+
) {
|
|
790
|
+
if (!this.shouldEmitDedupLog(options.key, options.sig, options.windowMs)) return;
|
|
791
|
+
this.logError(scope, message, { debugOnly: options.debugOnly });
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
private logInfoDedupJson(
|
|
795
|
+
scope: string | undefined,
|
|
796
|
+
event: string,
|
|
797
|
+
payload: Record<string, unknown>,
|
|
798
|
+
options: { key: string; sig: string; debugOnly?: boolean; windowMs?: number },
|
|
799
|
+
) {
|
|
800
|
+
if (!this.shouldEmitDedupLog(options.key, options.sig, options.windowMs)) return;
|
|
801
|
+
this.logInfoJson(scope, event, payload, { debugOnly: options.debugOnly });
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
private logWarnDedupJson(
|
|
805
|
+
scope: string | undefined,
|
|
806
|
+
event: string,
|
|
807
|
+
payload: Record<string, unknown>,
|
|
808
|
+
options: { key: string; sig: string; debugOnly?: boolean; windowMs?: number },
|
|
809
|
+
) {
|
|
810
|
+
if (!this.shouldEmitDedupLog(options.key, options.sig, options.windowMs)) return;
|
|
811
|
+
this.logWarnJson(scope, event, payload, { debugOnly: options.debugOnly });
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
private logErrorDedupJson(
|
|
815
|
+
scope: string | undefined,
|
|
816
|
+
event: string,
|
|
817
|
+
payload: Record<string, unknown>,
|
|
818
|
+
options: { key: string; sig: string; debugOnly?: boolean; windowMs?: number },
|
|
819
|
+
) {
|
|
820
|
+
if (!this.shouldEmitDedupLog(options.key, options.sig, options.windowMs)) return;
|
|
821
|
+
this.logErrorJson(scope, event, payload, { debugOnly: options.debugOnly });
|
|
822
|
+
}
|
|
823
|
+
|
|
473
824
|
private summarizeTextPreview(raw: string, limit = 8) {
|
|
474
825
|
const compact = asString(raw || '')
|
|
475
826
|
.replace(/\s+/g, ' ')
|
|
@@ -496,11 +847,15 @@ class BncrBridgeRuntime {
|
|
|
496
847
|
}
|
|
497
848
|
|
|
498
849
|
private logOutboundSummary(entry: OutboxEntry) {
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
850
|
+
this.logInfo(
|
|
851
|
+
'outbound',
|
|
852
|
+
summarizeOutboxEntry({
|
|
853
|
+
entry,
|
|
854
|
+
asString,
|
|
855
|
+
formatDisplayScope,
|
|
856
|
+
summarizeTextPreview: (raw, limit) => this.summarizeTextPreview(raw, limit),
|
|
857
|
+
}),
|
|
858
|
+
);
|
|
504
859
|
}
|
|
505
860
|
|
|
506
861
|
private clearChannelAccountWorker(accountId: string, reason: string) {
|
|
@@ -516,41 +871,6 @@ class BncrBridgeRuntime {
|
|
|
516
871
|
return true;
|
|
517
872
|
}
|
|
518
873
|
|
|
519
|
-
private classifyRegisterTrace(stack: string) {
|
|
520
|
-
if (
|
|
521
|
-
stack.includes('prepareSecretsRuntimeSnapshot') ||
|
|
522
|
-
stack.includes('resolveRuntimeWebTools') ||
|
|
523
|
-
stack.includes('resolvePluginWebSearchProviders')
|
|
524
|
-
) {
|
|
525
|
-
return 'runtime/webtools';
|
|
526
|
-
}
|
|
527
|
-
if (stack.includes('startGatewayServer') || stack.includes('loadGatewayPlugins')) {
|
|
528
|
-
return 'gateway/startup';
|
|
529
|
-
}
|
|
530
|
-
if (stack.includes('resolvePluginImplicitProviders')) {
|
|
531
|
-
return 'provider/discovery/implicit';
|
|
532
|
-
}
|
|
533
|
-
if (stack.includes('resolvePluginDiscoveryProviders')) {
|
|
534
|
-
return 'provider/discovery/discovery';
|
|
535
|
-
}
|
|
536
|
-
if (stack.includes('resolvePluginProviders')) {
|
|
537
|
-
return 'provider/discovery/providers';
|
|
538
|
-
}
|
|
539
|
-
return 'other';
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
private dominantRegisterBucket(sourceBuckets: Record<string, number>) {
|
|
543
|
-
let winner: string | null = null;
|
|
544
|
-
let winnerCount = -1;
|
|
545
|
-
for (const [bucket, count] of Object.entries(sourceBuckets)) {
|
|
546
|
-
if (count > winnerCount) {
|
|
547
|
-
winner = bucket;
|
|
548
|
-
winnerCount = count;
|
|
549
|
-
}
|
|
550
|
-
}
|
|
551
|
-
return winner;
|
|
552
|
-
}
|
|
553
|
-
|
|
554
874
|
private captureDriftSnapshot(
|
|
555
875
|
summary: ReturnType<BncrBridgeRuntime['buildRegisterTraceSummary']>,
|
|
556
876
|
) {
|
|
@@ -570,41 +890,11 @@ class BncrBridgeRuntime {
|
|
|
570
890
|
}
|
|
571
891
|
|
|
572
892
|
private buildRegisterTraceSummary() {
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
const baseline = this.firstRegisterAt;
|
|
579
|
-
|
|
580
|
-
for (const trace of this.registerTraceRecent) {
|
|
581
|
-
buckets[trace.stackBucket] = (buckets[trace.stackBucket] || 0) + 1;
|
|
582
|
-
const isWarmup = baseline != null && trace.ts - baseline <= REGISTER_WARMUP_WINDOW_MS;
|
|
583
|
-
if (isWarmup) {
|
|
584
|
-
warmupCount += 1;
|
|
585
|
-
} else {
|
|
586
|
-
postWarmupCount += 1;
|
|
587
|
-
unexpectedRegisterAfterWarmup = true;
|
|
588
|
-
lastUnexpectedRegisterAt = trace.ts;
|
|
589
|
-
}
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
const dominantBucket = this.dominantRegisterBucket(buckets);
|
|
593
|
-
const likelyRuntimeRegistryDrift = postWarmupCount > 0;
|
|
594
|
-
const likelyStartupFanoutOnly = warmupCount > 0 && postWarmupCount === 0;
|
|
595
|
-
|
|
596
|
-
return {
|
|
597
|
-
startupWindowMs: REGISTER_WARMUP_WINDOW_MS,
|
|
598
|
-
traceWindowSize: this.registerTraceRecent.length,
|
|
599
|
-
sourceBuckets: buckets,
|
|
600
|
-
dominantBucket,
|
|
601
|
-
warmupRegisterCount: warmupCount,
|
|
602
|
-
postWarmupRegisterCount: postWarmupCount,
|
|
603
|
-
unexpectedRegisterAfterWarmup,
|
|
604
|
-
lastUnexpectedRegisterAt,
|
|
605
|
-
likelyRuntimeRegistryDrift,
|
|
606
|
-
likelyStartupFanoutOnly,
|
|
607
|
-
};
|
|
893
|
+
return buildRegisterTraceSummaryFromEntries({
|
|
894
|
+
traceRecent: this.registerTraceRecent,
|
|
895
|
+
firstRegisterAt: this.firstRegisterAt,
|
|
896
|
+
warmupWindowMs: REGISTER_WARMUP_WINDOW_MS,
|
|
897
|
+
});
|
|
608
898
|
}
|
|
609
899
|
|
|
610
900
|
noteRegister(meta: {
|
|
@@ -635,7 +925,7 @@ class BncrBridgeRuntime {
|
|
|
635
925
|
.map((line) => line.trim())
|
|
636
926
|
.filter(Boolean)
|
|
637
927
|
.join(' <- ');
|
|
638
|
-
const stackBucket =
|
|
928
|
+
const stackBucket = classifyRegisterTraceFromStack(stack);
|
|
639
929
|
|
|
640
930
|
const trace = {
|
|
641
931
|
ts,
|
|
@@ -706,48 +996,21 @@ class BncrBridgeRuntime {
|
|
|
706
996
|
const leaseId = typeof params.leaseId === 'string' ? params.leaseId.trim() : '';
|
|
707
997
|
const connectionEpoch =
|
|
708
998
|
typeof params.connectionEpoch === 'number' ? params.connectionEpoch : undefined;
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
connectionEpoch
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
if (!stale) return
|
|
718
|
-
this.staleCounters.lastStaleAt = now();
|
|
719
|
-
switch (kind) {
|
|
720
|
-
case 'connect':
|
|
721
|
-
this.staleCounters.staleConnect += 1;
|
|
722
|
-
break;
|
|
723
|
-
case 'inbound':
|
|
724
|
-
this.staleCounters.staleInbound += 1;
|
|
725
|
-
break;
|
|
726
|
-
case 'activity':
|
|
727
|
-
this.staleCounters.staleActivity += 1;
|
|
728
|
-
break;
|
|
729
|
-
case 'ack':
|
|
730
|
-
this.staleCounters.staleAck += 1;
|
|
731
|
-
break;
|
|
732
|
-
case 'file.init':
|
|
733
|
-
this.staleCounters.staleFileInit += 1;
|
|
734
|
-
break;
|
|
735
|
-
case 'file.chunk':
|
|
736
|
-
this.staleCounters.staleFileChunk += 1;
|
|
737
|
-
break;
|
|
738
|
-
case 'file.complete':
|
|
739
|
-
this.staleCounters.staleFileComplete += 1;
|
|
740
|
-
break;
|
|
741
|
-
case 'file.abort':
|
|
742
|
-
this.staleCounters.staleFileAbort += 1;
|
|
743
|
-
break;
|
|
744
|
-
}
|
|
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;
|
|
745
1008
|
this.logWarn(
|
|
746
1009
|
'stale',
|
|
747
1010
|
`observed kind=${kind} lease=${leaseId || '-'} epoch=${connectionEpoch ?? '-'} currentLease=${this.primaryLeaseId || '-'} currentEpoch=${this.connectionEpoch}`,
|
|
748
1011
|
{ debugOnly: true },
|
|
749
1012
|
);
|
|
750
|
-
return
|
|
1013
|
+
return observed;
|
|
751
1014
|
}
|
|
752
1015
|
|
|
753
1016
|
private shouldIgnoreStaleEvent(params: {
|
|
@@ -780,19 +1043,14 @@ class BncrBridgeRuntime {
|
|
|
780
1043
|
connId: string;
|
|
781
1044
|
clientId?: string;
|
|
782
1045
|
}) {
|
|
783
|
-
|
|
784
|
-
const sameClient =
|
|
785
|
-
!params.ownerConnId &&
|
|
786
|
-
!!params.ownerClientId &&
|
|
787
|
-
!!params.clientId &&
|
|
788
|
-
params.ownerClientId === params.clientId;
|
|
789
|
-
return sameConn || sameClient;
|
|
1046
|
+
return matchesTransferOwnerFromRuntime(params);
|
|
790
1047
|
}
|
|
791
1048
|
|
|
792
1049
|
private buildExtendedDiagnostics(accountId: string) {
|
|
793
|
-
const
|
|
794
|
-
|
|
795
|
-
|
|
1050
|
+
const acc = normalizeAccountId(accountId);
|
|
1051
|
+
const diagnostics = this.buildIntegratedDiagnostics(acc) as Record<string, any>;
|
|
1052
|
+
return buildExtendedDiagnosticsFromRuntime({
|
|
1053
|
+
diagnostics,
|
|
796
1054
|
register: {
|
|
797
1055
|
bridgeId: this.bridgeId,
|
|
798
1056
|
gatewayPid: this.gatewayPid,
|
|
@@ -805,12 +1063,12 @@ class BncrBridgeRuntime {
|
|
|
805
1063
|
lastRegisterAt: this.lastRegisterAt,
|
|
806
1064
|
lastApiRebindAt: this.lastApiRebindAt,
|
|
807
1065
|
apiGeneration: this.apiGeneration,
|
|
808
|
-
traceRecent: this.registerTraceRecent
|
|
1066
|
+
traceRecent: this.registerTraceRecent,
|
|
809
1067
|
traceSummary: this.buildRegisterTraceSummary(),
|
|
810
1068
|
lastDriftSnapshot: this.lastDriftSnapshot,
|
|
811
1069
|
},
|
|
812
1070
|
connection: {
|
|
813
|
-
active: this.activeConnectionCount(
|
|
1071
|
+
active: this.activeConnectionCount(acc),
|
|
814
1072
|
primaryLeaseId: this.primaryLeaseId,
|
|
815
1073
|
primaryEpoch: this.connectionEpoch || null,
|
|
816
1074
|
acceptedConnections: this.acceptedConnections,
|
|
@@ -839,8 +1097,8 @@ class BncrBridgeRuntime {
|
|
|
839
1097
|
staleRejectFile: false,
|
|
840
1098
|
},
|
|
841
1099
|
},
|
|
842
|
-
stale:
|
|
843
|
-
};
|
|
1100
|
+
stale: this.staleCounters,
|
|
1101
|
+
});
|
|
844
1102
|
}
|
|
845
1103
|
|
|
846
1104
|
isDebugEnabled(): boolean {
|
|
@@ -1039,27 +1297,22 @@ class BncrBridgeRuntime {
|
|
|
1039
1297
|
}
|
|
1040
1298
|
|
|
1041
1299
|
private buildIntegratedDiagnostics(accountId: string) {
|
|
1300
|
+
return buildIntegratedDiagnosticsFromRuntime(this.buildRuntimeStatusInput(accountId));
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
private buildDownlinkHealth(accountId: string) {
|
|
1042
1304
|
const acc = normalizeAccountId(accountId);
|
|
1043
|
-
return
|
|
1305
|
+
return buildDownlinkHealthFromRuntime({
|
|
1044
1306
|
accountId: acc,
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
activityEvents: this.getCounter(this.activityEventsByAccount, acc),
|
|
1052
|
-
ackEvents: this.getCounter(this.ackEventsByAccount, acc),
|
|
1053
|
-
startedAt: this.startedAt,
|
|
1054
|
-
lastSession: this.lastSessionByAccount.get(acc) || null,
|
|
1055
|
-
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),
|
|
1056
1313
|
lastInboundAt: this.lastInboundByAccount.get(acc) || null,
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
.length,
|
|
1060
|
-
invalidOutboxSessionKeys: this.countInvalidOutboxSessionKeys(acc),
|
|
1061
|
-
legacyAccountResidue: this.countLegacyAccountResidue(acc),
|
|
1062
|
-
channelRoot: path.join(process.cwd(), 'plugins', 'bncr'),
|
|
1314
|
+
lastActivityAt: this.lastActivityByAccount.get(acc) || null,
|
|
1315
|
+
onlineByConn: this.isOnline(acc),
|
|
1063
1316
|
});
|
|
1064
1317
|
}
|
|
1065
1318
|
|
|
@@ -1338,26 +1591,165 @@ class BncrBridgeRuntime {
|
|
|
1338
1591
|
const acc = normalizeAccountId(accountId);
|
|
1339
1592
|
const t = now();
|
|
1340
1593
|
const primaryKey = this.activeConnectionByAccount.get(acc);
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1594
|
+
const primary = primaryKey ? this.connections.get(primaryKey) : null;
|
|
1595
|
+
|
|
1596
|
+
const isEligible = (conn: BncrConnection | null | undefined): conn is BncrConnection & {
|
|
1597
|
+
outboundReadyUntil?: number;
|
|
1598
|
+
preferredForOutboundUntil?: number;
|
|
1599
|
+
inboundOnly?: boolean;
|
|
1600
|
+
} => {
|
|
1601
|
+
if (!conn?.connId) return false;
|
|
1602
|
+
if (t - conn.lastSeenAt > CONNECT_TTL_MS) return false;
|
|
1603
|
+
if ((conn as any).inboundOnly === true) return false;
|
|
1604
|
+
return true;
|
|
1605
|
+
};
|
|
1606
|
+
|
|
1607
|
+
const recentInboundConnIds = this.resolveRecentInboundConnIds(acc);
|
|
1608
|
+
const candidateScore = (conn: BncrConnection) => {
|
|
1609
|
+
const preferredForOutboundUntil = Number((conn as any).preferredForOutboundUntil || 0);
|
|
1610
|
+
const outboundReadyUntil = Number((conn as any).outboundReadyUntil || 0);
|
|
1611
|
+
const lastPushTimeoutAt = Number((conn as any).lastPushTimeoutAt || 0);
|
|
1612
|
+
const lastAckOkAt = Number((conn as any).lastAckOkAt || 0);
|
|
1613
|
+
const pushFailureScore = Number((conn as any).pushFailureScore || 0);
|
|
1614
|
+
const recentTimeoutPenalty = lastPushTimeoutAt > 0 && t - lastPushTimeoutAt <= 30_000 ? 1 : 0;
|
|
1615
|
+
return {
|
|
1616
|
+
preferred: preferredForOutboundUntil > t ? 1 : 0,
|
|
1617
|
+
ready: outboundReadyUntil > t ? 1 : 0,
|
|
1618
|
+
recentInbound: recentInboundConnIds.has(conn.connId) ? 1 : 0,
|
|
1619
|
+
recentTimeoutPenalty,
|
|
1620
|
+
pushFailureScore,
|
|
1621
|
+
lastAckOkAt,
|
|
1622
|
+
lastPushTimeoutAt,
|
|
1623
|
+
lastSeenAt: conn.lastSeenAt,
|
|
1624
|
+
connectedAt: conn.connectedAt,
|
|
1625
|
+
};
|
|
1626
|
+
};
|
|
1627
|
+
|
|
1628
|
+
if (isEligible(primary)) {
|
|
1629
|
+
const score = candidateScore(primary);
|
|
1630
|
+
if (score.preferred || score.ready) return primary;
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
const candidates = Array.from(this.connections.values())
|
|
1634
|
+
.filter((c): c is BncrConnection => c.accountId === acc)
|
|
1635
|
+
.filter((c) => isEligible(c))
|
|
1636
|
+
.sort((a, b) => {
|
|
1637
|
+
const sa = candidateScore(a);
|
|
1638
|
+
const sb = candidateScore(b);
|
|
1639
|
+
if (sb.preferred !== sa.preferred) return sb.preferred - sa.preferred;
|
|
1640
|
+
if (sb.ready !== sa.ready) return sb.ready - sa.ready;
|
|
1641
|
+
if (sa.recentTimeoutPenalty !== sb.recentTimeoutPenalty) return sa.recentTimeoutPenalty - sb.recentTimeoutPenalty;
|
|
1642
|
+
if (sa.pushFailureScore !== sb.pushFailureScore) return sa.pushFailureScore - sb.pushFailureScore;
|
|
1643
|
+
if (sb.lastAckOkAt !== sa.lastAckOkAt) return sb.lastAckOkAt - sa.lastAckOkAt;
|
|
1644
|
+
if (sa.lastPushTimeoutAt !== sb.lastPushTimeoutAt) return sa.lastPushTimeoutAt - sb.lastPushTimeoutAt;
|
|
1645
|
+
if (sb.recentInbound !== sa.recentInbound) return sb.recentInbound - sa.recentInbound;
|
|
1646
|
+
if (sb.lastSeenAt !== sa.lastSeenAt) return sb.lastSeenAt - sa.lastSeenAt;
|
|
1647
|
+
return sb.connectedAt - sa.connectedAt;
|
|
1648
|
+
});
|
|
1649
|
+
|
|
1650
|
+
const next = candidates[0] || null;
|
|
1651
|
+
if (!next) return null;
|
|
1652
|
+
|
|
1653
|
+
const nextKey = this.connectionKey(acc, next.clientId);
|
|
1654
|
+
if (primaryKey !== nextKey) {
|
|
1655
|
+
this.activeConnectionByAccount.set(acc, nextKey);
|
|
1656
|
+
this.logInfo(
|
|
1657
|
+
'connection',
|
|
1658
|
+
`owner:promote ${JSON.stringify({
|
|
1659
|
+
bridge: this.bridgeId,
|
|
1660
|
+
accountId: acc,
|
|
1661
|
+
previousActiveKey: primaryKey || null,
|
|
1662
|
+
previousActiveConn: primary || null,
|
|
1663
|
+
nextActiveKey: nextKey,
|
|
1664
|
+
nextActiveConn: {
|
|
1665
|
+
connId: next.connId,
|
|
1666
|
+
clientId: next.clientId,
|
|
1667
|
+
connectedAt: next.connectedAt,
|
|
1668
|
+
lastSeenAt: next.lastSeenAt,
|
|
1669
|
+
outboundReadyUntil: (next as any).outboundReadyUntil || null,
|
|
1670
|
+
preferredForOutboundUntil: (next as any).preferredForOutboundUntil || null,
|
|
1671
|
+
inboundOnly: (next as any).inboundOnly === true,
|
|
1672
|
+
},
|
|
1673
|
+
reason: 'better-outbound-candidate',
|
|
1674
|
+
})}`,
|
|
1675
|
+
{ debugOnly: true },
|
|
1676
|
+
);
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
return next;
|
|
1346
1680
|
}
|
|
1347
1681
|
|
|
1348
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.
|
|
1349
1688
|
const acc = normalizeAccountId(accountId);
|
|
1350
1689
|
const t = now();
|
|
1351
1690
|
const connIds = new Set<string>();
|
|
1352
1691
|
|
|
1692
|
+
const isEligible = (conn: BncrConnection | null | undefined): conn is BncrConnection & {
|
|
1693
|
+
outboundReadyUntil?: number;
|
|
1694
|
+
preferredForOutboundUntil?: number;
|
|
1695
|
+
inboundOnly?: boolean;
|
|
1696
|
+
} => {
|
|
1697
|
+
if (!conn?.connId) return false;
|
|
1698
|
+
if (t - conn.lastSeenAt > CONNECT_TTL_MS) return false;
|
|
1699
|
+
if ((conn as any).inboundOnly === true) return false;
|
|
1700
|
+
return true;
|
|
1701
|
+
};
|
|
1702
|
+
|
|
1703
|
+
const recentInboundConnIds = this.resolveRecentInboundConnIds(acc);
|
|
1704
|
+
const candidateScore = (conn: BncrConnection) => {
|
|
1705
|
+
const preferredForOutboundUntil = Number((conn as any).preferredForOutboundUntil || 0);
|
|
1706
|
+
const outboundReadyUntil = Number((conn as any).outboundReadyUntil || 0);
|
|
1707
|
+
const lastPushTimeoutAt = Number((conn as any).lastPushTimeoutAt || 0);
|
|
1708
|
+
const lastAckOkAt = Number((conn as any).lastAckOkAt || 0);
|
|
1709
|
+
const pushFailureScore = Number((conn as any).pushFailureScore || 0);
|
|
1710
|
+
const recentTimeoutPenalty = lastPushTimeoutAt > 0 && t - lastPushTimeoutAt <= 30_000 ? 1 : 0;
|
|
1711
|
+
return {
|
|
1712
|
+
preferred: preferredForOutboundUntil > t ? 1 : 0,
|
|
1713
|
+
ready: outboundReadyUntil > t ? 1 : 0,
|
|
1714
|
+
recentInbound: recentInboundConnIds.has(conn.connId) ? 1 : 0,
|
|
1715
|
+
recentTimeoutPenalty,
|
|
1716
|
+
pushFailureScore,
|
|
1717
|
+
lastAckOkAt,
|
|
1718
|
+
lastPushTimeoutAt,
|
|
1719
|
+
lastSeenAt: conn.lastSeenAt,
|
|
1720
|
+
connectedAt: conn.connectedAt,
|
|
1721
|
+
};
|
|
1722
|
+
};
|
|
1723
|
+
|
|
1353
1724
|
const primaryKey = this.activeConnectionByAccount.get(acc);
|
|
1354
1725
|
if (primaryKey) {
|
|
1355
1726
|
const primary = this.connections.get(primaryKey);
|
|
1356
|
-
if (primary
|
|
1727
|
+
if (isEligible(primary)) {
|
|
1357
1728
|
connIds.add(primary.connId);
|
|
1358
1729
|
}
|
|
1359
1730
|
}
|
|
1360
1731
|
|
|
1732
|
+
const candidates = Array.from(this.connections.values())
|
|
1733
|
+
.filter((c): c is BncrConnection => c.accountId === acc)
|
|
1734
|
+
.filter((c) => isEligible(c))
|
|
1735
|
+
.sort((a, b) => {
|
|
1736
|
+
const sa = candidateScore(a);
|
|
1737
|
+
const sb = candidateScore(b);
|
|
1738
|
+
if (sb.preferred !== sa.preferred) return sb.preferred - sa.preferred;
|
|
1739
|
+
if (sb.ready !== sa.ready) return sb.ready - sa.ready;
|
|
1740
|
+
if (sa.recentTimeoutPenalty !== sb.recentTimeoutPenalty) return sa.recentTimeoutPenalty - sb.recentTimeoutPenalty;
|
|
1741
|
+
if (sa.pushFailureScore !== sb.pushFailureScore) return sa.pushFailureScore - sb.pushFailureScore;
|
|
1742
|
+
if (sb.lastAckOkAt !== sa.lastAckOkAt) return sb.lastAckOkAt - sa.lastAckOkAt;
|
|
1743
|
+
if (sa.lastPushTimeoutAt !== sb.lastPushTimeoutAt) return sa.lastPushTimeoutAt - sb.lastPushTimeoutAt;
|
|
1744
|
+
if (sb.recentInbound !== sa.recentInbound) return sb.recentInbound - sa.recentInbound;
|
|
1745
|
+
if (sb.lastSeenAt !== sa.lastSeenAt) return sb.lastSeenAt - sa.lastSeenAt;
|
|
1746
|
+
return sb.connectedAt - sa.connectedAt;
|
|
1747
|
+
});
|
|
1748
|
+
|
|
1749
|
+
for (const c of candidates) {
|
|
1750
|
+
connIds.add(c.connId);
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1361
1753
|
if (connIds.size > 0) return connIds;
|
|
1362
1754
|
|
|
1363
1755
|
for (const c of this.connections.values()) {
|
|
@@ -1372,44 +1764,61 @@ class BncrBridgeRuntime {
|
|
|
1372
1764
|
|
|
1373
1765
|
private hasRecentInboundReachability(accountId: string): boolean {
|
|
1374
1766
|
const acc = normalizeAccountId(accountId);
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
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
|
+
});
|
|
1380
1773
|
}
|
|
1381
1774
|
|
|
1382
1775
|
private resolveRecentInboundConnIds(accountId: string): Set<string> {
|
|
1383
1776
|
const acc = normalizeAccountId(accountId);
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
if (t - c.lastSeenAt > CONNECT_TTL_MS * 2) continue;
|
|
1392
|
-
connIds.add(c.connId);
|
|
1393
|
-
}
|
|
1394
|
-
|
|
1395
|
-
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
|
+
});
|
|
1396
1784
|
}
|
|
1397
1785
|
|
|
1398
1786
|
private isRecentlyReachableConn(accountId: string, connId?: string, clientId?: string): boolean {
|
|
1399
1787
|
const acc = normalizeAccountId(accountId);
|
|
1400
|
-
const
|
|
1401
|
-
const
|
|
1402
|
-
|
|
1788
|
+
const activeKey = this.activeConnectionByAccount.get(acc);
|
|
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
|
+
});
|
|
1797
|
+
}
|
|
1403
1798
|
|
|
1404
|
-
|
|
1405
|
-
|
|
1799
|
+
private isRevalidatedAttemptedConn(entry: OutboxEntry, connId: string): boolean {
|
|
1800
|
+
const acc = normalizeAccountId(entry.accountId);
|
|
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;
|
|
1406
1811
|
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
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
|
+
);
|
|
1413
1822
|
return true;
|
|
1414
1823
|
}
|
|
1415
1824
|
|
|
@@ -1455,282 +1864,1023 @@ class BncrBridgeRuntime {
|
|
|
1455
1864
|
return retryableMarkers.some((marker) => msg.includes(marker));
|
|
1456
1865
|
}
|
|
1457
1866
|
|
|
1458
|
-
private
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
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;
|
|
1462
1874
|
mediaUrl: string;
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
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
|
+
);
|
|
1495
1926
|
}
|
|
1496
1927
|
|
|
1497
|
-
private
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
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
|
+
}
|
|
1516
1954
|
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
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
|
+
}
|
|
1541
1982
|
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
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
|
+
|
|
2035
|
+
private buildFileTransferOutboxEntry(params: {
|
|
2036
|
+
accountId: string;
|
|
2037
|
+
sessionKey: string;
|
|
2038
|
+
route: BncrRoute;
|
|
2039
|
+
mediaUrl: string;
|
|
2040
|
+
mediaLocalRoots?: readonly string[];
|
|
2041
|
+
text?: string;
|
|
2042
|
+
asVoice?: boolean;
|
|
2043
|
+
audioAsVoice?: boolean;
|
|
2044
|
+
kind?: 'tool' | 'block' | 'final';
|
|
2045
|
+
replyToId?: string;
|
|
2046
|
+
}): OutboxEntry {
|
|
2047
|
+
return buildFileTransferOutboxEntryFromRuntime({
|
|
2048
|
+
createMessageId: () => randomUUID(),
|
|
2049
|
+
now,
|
|
2050
|
+
normalizeAccountId,
|
|
2051
|
+
pushEvent: BNCR_PUSH_EVENT,
|
|
2052
|
+
accountId: params.accountId,
|
|
2053
|
+
sessionKey: params.sessionKey,
|
|
2054
|
+
route: params.route,
|
|
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
|
+
});
|
|
2063
|
+
}
|
|
2064
|
+
|
|
2065
|
+
private pruneMediaDedupeCache(sessionKey: string, currentTime = now()) {
|
|
2066
|
+
const sessionCache = this.recentMediaDedupeBySession.get(sessionKey);
|
|
2067
|
+
if (!sessionCache) return;
|
|
2068
|
+
|
|
2069
|
+
for (const [mediaUrl, entry] of sessionCache.entries()) {
|
|
2070
|
+
if (currentTime - entry.createdAt > 10_000) {
|
|
2071
|
+
sessionCache.delete(mediaUrl);
|
|
1557
2072
|
}
|
|
2073
|
+
}
|
|
1558
2074
|
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
2075
|
+
if (sessionCache.size === 0) {
|
|
2076
|
+
this.recentMediaDedupeBySession.delete(sessionKey);
|
|
2077
|
+
}
|
|
2078
|
+
}
|
|
2079
|
+
|
|
2080
|
+
private rememberRecentMediaSend(params: {
|
|
2081
|
+
sessionKey: string;
|
|
2082
|
+
mediaUrl: string;
|
|
2083
|
+
text: string;
|
|
2084
|
+
replyToId: string;
|
|
2085
|
+
createdAt?: number;
|
|
2086
|
+
}) {
|
|
2087
|
+
const sessionKey = asString(params.sessionKey || '').trim();
|
|
2088
|
+
const mediaUrl = asString(params.mediaUrl || '').trim();
|
|
2089
|
+
if (!sessionKey || !mediaUrl) return;
|
|
2090
|
+
|
|
2091
|
+
const createdAt = typeof params.createdAt === 'number' ? params.createdAt : now();
|
|
2092
|
+
this.pruneMediaDedupeCache(sessionKey, createdAt);
|
|
2093
|
+
let sessionCache = this.recentMediaDedupeBySession.get(sessionKey);
|
|
2094
|
+
if (!sessionCache) {
|
|
2095
|
+
sessionCache = new Map<string, MediaDedupeCacheEntry>();
|
|
2096
|
+
this.recentMediaDedupeBySession.set(sessionKey, sessionCache);
|
|
2097
|
+
}
|
|
2098
|
+
sessionCache.set(mediaUrl, {
|
|
2099
|
+
mediaUrl,
|
|
2100
|
+
text: normalizeMessageText(params.text),
|
|
2101
|
+
replyToId: normalizeReplyToId(params.replyToId),
|
|
2102
|
+
createdAt,
|
|
2103
|
+
});
|
|
2104
|
+
}
|
|
2105
|
+
|
|
2106
|
+
private tryBuildMediaDedupeFallback(params: {
|
|
2107
|
+
sessionKey: string;
|
|
2108
|
+
mediaUrl: string;
|
|
2109
|
+
text: string;
|
|
2110
|
+
replyToId: string;
|
|
2111
|
+
currentTime?: number;
|
|
2112
|
+
}): { text: string; reason: 'same-text-sent-checkmark' | 'text-changed-downgrade' } | null {
|
|
2113
|
+
const sessionKey = asString(params.sessionKey || '').trim();
|
|
2114
|
+
const mediaUrl = asString(params.mediaUrl || '').trim();
|
|
2115
|
+
if (!sessionKey || !mediaUrl) return null;
|
|
2116
|
+
|
|
2117
|
+
const currentTime = typeof params.currentTime === 'number' ? params.currentTime : now();
|
|
2118
|
+
this.pruneMediaDedupeCache(sessionKey, currentTime);
|
|
2119
|
+
const sessionCache = this.recentMediaDedupeBySession.get(sessionKey);
|
|
2120
|
+
const previous = sessionCache?.get(mediaUrl);
|
|
2121
|
+
if (!previous) return null;
|
|
2122
|
+
if (currentTime - previous.createdAt > 10_000) return null;
|
|
2123
|
+
|
|
2124
|
+
return buildMediaTextFallback({
|
|
2125
|
+
currentText: normalizeMessageText(params.text),
|
|
2126
|
+
previousText: previous.text,
|
|
2127
|
+
currentReplyToId: normalizeReplyToId(params.replyToId),
|
|
2128
|
+
previousReplyToId: previous.replyToId,
|
|
2129
|
+
});
|
|
2130
|
+
}
|
|
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
|
+
|
|
2165
|
+
private buildTextOutboxEntry(params: {
|
|
2166
|
+
accountId: string;
|
|
2167
|
+
sessionKey: string;
|
|
2168
|
+
route: BncrRoute;
|
|
2169
|
+
text: string;
|
|
2170
|
+
kind?: 'tool' | 'block' | 'final';
|
|
2171
|
+
replyToId?: string;
|
|
2172
|
+
}): OutboxEntry {
|
|
2173
|
+
return buildTextOutboxEntryFromRuntime({
|
|
2174
|
+
createMessageId: () => randomUUID(),
|
|
2175
|
+
now,
|
|
2176
|
+
normalizeAccountId,
|
|
2177
|
+
normalizeReplyToId,
|
|
2178
|
+
accountId: params.accountId,
|
|
2179
|
+
sessionKey: params.sessionKey,
|
|
2180
|
+
route: params.route,
|
|
2181
|
+
text: params.text,
|
|
2182
|
+
kind: params.kind,
|
|
2183
|
+
replyToId: params.replyToId,
|
|
2184
|
+
});
|
|
2185
|
+
}
|
|
2186
|
+
|
|
2187
|
+
private async tryPushEntry(entry: OutboxEntry): Promise<boolean> {
|
|
2188
|
+
const meta = isPlainObject(entry.payload?._meta) ? entry.payload._meta : null;
|
|
2189
|
+
if (meta?.kind === 'file-transfer') {
|
|
2190
|
+
return this.tryPushFileTransferEntry(entry, meta);
|
|
2191
|
+
}
|
|
2192
|
+
|
|
2193
|
+
return this.tryPushTextEntry(entry);
|
|
2194
|
+
}
|
|
2195
|
+
|
|
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
|
+
}
|
|
2240
|
+
|
|
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,
|
|
1583
2387
|
kind:
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
meta.messageKind === 'final'
|
|
1587
|
-
? meta.messageKind
|
|
2388
|
+
isPlainObject(args.entry.payload?._meta) && args.entry.payload?._meta?.kind === 'file-transfer'
|
|
2389
|
+
? 'file-transfer'
|
|
1588
2390
|
: undefined,
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
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
|
+
}
|
|
1592
2404
|
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
entry.
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
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 || '-'}`,
|
|
1638
2525
|
{ debugOnly: true },
|
|
1639
2526
|
);
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
}
|
|
1643
|
-
return false;
|
|
2527
|
+
respond(true, { ok: true, stale: true, ignored: true });
|
|
2528
|
+
return null;
|
|
1644
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;
|
|
1645
2650
|
}
|
|
1646
2651
|
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
reason: 'no-gateway-context',
|
|
1655
|
-
})}`,
|
|
1656
|
-
{ debugOnly: true },
|
|
1657
|
-
);
|
|
1658
|
-
return false;
|
|
1659
|
-
}
|
|
2652
|
+
this.handleAckRetry({
|
|
2653
|
+
entry,
|
|
2654
|
+
messageId,
|
|
2655
|
+
connId,
|
|
2656
|
+
clientId,
|
|
2657
|
+
error: asString(params?.error || 'retryable-ack'),
|
|
2658
|
+
});
|
|
1660
2659
|
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
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;
|
|
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
|
+
};
|
|
1668
2705
|
}
|
|
1669
|
-
if (
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
})
|
|
1678
|
-
|
|
1679
|
-
);
|
|
1680
|
-
return false;
|
|
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
|
+
};
|
|
1681
2716
|
}
|
|
1682
2717
|
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
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
|
+
}),
|
|
1687
2734
|
};
|
|
2735
|
+
}
|
|
1688
2736
|
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
)
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
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 },
|
|
2811
|
+
);
|
|
2812
|
+
}
|
|
2813
|
+
|
|
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
|
+
}
|
|
2821
|
+
|
|
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);
|
|
1725
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();
|
|
1726
2857
|
}
|
|
1727
2858
|
|
|
1728
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.
|
|
1729
2865
|
if (this.pushTimer) return;
|
|
1730
|
-
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
|
+
);
|
|
1731
2878
|
this.pushTimer = setTimeout(() => {
|
|
1732
2879
|
this.pushTimer = null;
|
|
1733
|
-
void this.flushPushQueue(
|
|
2880
|
+
void this.flushPushQueue({
|
|
2881
|
+
trigger: OUTBOUND_FLUSH_TRIGGER.TIMER,
|
|
2882
|
+
reason: OUTBOUND_FLUSH_REASON.SCHEDULED_DRAIN,
|
|
2883
|
+
});
|
|
1734
2884
|
}, delay);
|
|
1735
2885
|
}
|
|
1736
2886
|
|
|
@@ -1770,23 +2920,56 @@ class BncrBridgeRuntime {
|
|
|
1770
2920
|
};
|
|
1771
2921
|
}
|
|
1772
2922
|
|
|
1773
|
-
private async flushPushQueue(
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
2923
|
+
private async flushPushQueue(args?: {
|
|
2924
|
+
accountId?: string;
|
|
2925
|
+
trigger?: string;
|
|
2926
|
+
reason?: string;
|
|
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.
|
|
2953
|
+
const filterAcc = args?.accountId ? normalizeAccountId(args.accountId) : null;
|
|
2954
|
+
const trigger = asString(args?.trigger || '').trim() || 'manual';
|
|
2955
|
+
const reason = asString(args?.reason || '').trim() || undefined;
|
|
2956
|
+
const targetAccounts = selectOutboxTargetAccounts({
|
|
2957
|
+
accountId: filterAcc,
|
|
2958
|
+
outboxEntries: this.outbox.values(),
|
|
2959
|
+
normalizeAccountId,
|
|
2960
|
+
});
|
|
1782
2961
|
this.logInfo(
|
|
1783
2962
|
'outbox',
|
|
1784
|
-
`flush ${JSON.stringify(
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
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
|
+
)}`,
|
|
1790
2973
|
{ debugOnly: true },
|
|
1791
2974
|
);
|
|
1792
2975
|
|
|
@@ -1798,18 +2981,15 @@ class BncrBridgeRuntime {
|
|
|
1798
2981
|
const recentInboundReachable = this.hasRecentInboundReachability(acc);
|
|
1799
2982
|
this.logInfo(
|
|
1800
2983
|
'outbox',
|
|
1801
|
-
`online ${JSON.stringify(
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
lastSeenAt: c.lastSeenAt,
|
|
1811
|
-
})),
|
|
1812
|
-
})}`,
|
|
2984
|
+
`online ${JSON.stringify(
|
|
2985
|
+
buildOutboxOnlineDebugInfo({
|
|
2986
|
+
bridgeId: this.bridgeId,
|
|
2987
|
+
accountId: acc,
|
|
2988
|
+
online,
|
|
2989
|
+
recentInboundReachable,
|
|
2990
|
+
connections: this.connections.values(),
|
|
2991
|
+
}),
|
|
2992
|
+
)}`,
|
|
1813
2993
|
{ debugOnly: true },
|
|
1814
2994
|
);
|
|
1815
2995
|
this.pushDrainRunningAccounts.add(acc);
|
|
@@ -1818,20 +2998,38 @@ class BncrBridgeRuntime {
|
|
|
1818
2998
|
|
|
1819
2999
|
while (true) {
|
|
1820
3000
|
const t = now();
|
|
1821
|
-
const entries =
|
|
1822
|
-
|
|
1823
|
-
|
|
3001
|
+
const entries = listAccountOutboxEntries({
|
|
3002
|
+
accountId: acc,
|
|
3003
|
+
outboxEntries: this.outbox.values(),
|
|
3004
|
+
normalizeAccountId,
|
|
3005
|
+
});
|
|
1824
3006
|
|
|
1825
3007
|
if (!entries.length) break;
|
|
1826
3008
|
|
|
1827
|
-
const entry = entries
|
|
3009
|
+
const entry = findDueOutboxEntry(entries, t);
|
|
1828
3010
|
if (!entry) {
|
|
1829
|
-
const wait =
|
|
1830
|
-
|
|
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
|
+
}
|
|
1831
3028
|
break;
|
|
1832
3029
|
}
|
|
1833
3030
|
|
|
1834
|
-
const onlineNow = this.isOnline(acc)
|
|
3031
|
+
const onlineNow = this.isOnline(acc);
|
|
3032
|
+
const recentInboundReachable = this.hasRecentInboundReachability(acc);
|
|
1835
3033
|
const pushed = await this.tryPushEntry(entry);
|
|
1836
3034
|
if (pushed) {
|
|
1837
3035
|
const requireAck = this.isOutboundAckRequired(acc);
|
|
@@ -1840,17 +3038,13 @@ class BncrBridgeRuntime {
|
|
|
1840
3038
|
ackResult = await this.waitForMessageAck(entry.messageId, PUSH_ACK_TIMEOUT_MS);
|
|
1841
3039
|
}
|
|
1842
3040
|
|
|
1843
|
-
this.
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
onlineNow,
|
|
1851
|
-
})}`,
|
|
1852
|
-
{ debugOnly: true },
|
|
1853
|
-
);
|
|
3041
|
+
this.logOutboxAckWait({
|
|
3042
|
+
entry,
|
|
3043
|
+
requireAck,
|
|
3044
|
+
ackResult,
|
|
3045
|
+
onlineNow,
|
|
3046
|
+
recentInboundReachable,
|
|
3047
|
+
});
|
|
1854
3048
|
|
|
1855
3049
|
if (!this.outbox.has(entry.messageId)) {
|
|
1856
3050
|
await this.sleepMs(PUSH_DRAIN_INTERVAL_MS);
|
|
@@ -1862,22 +3056,74 @@ class BncrBridgeRuntime {
|
|
|
1862
3056
|
continue;
|
|
1863
3057
|
}
|
|
1864
3058
|
|
|
1865
|
-
entry.
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
entry,
|
|
1870
|
-
|
|
3059
|
+
if (entry.lastPushConnId || entry.lastPushClientId) {
|
|
3060
|
+
this.degradeOutboundCapability({
|
|
3061
|
+
accountId: acc,
|
|
3062
|
+
connId: entry.lastPushConnId || undefined,
|
|
3063
|
+
clientId: entry.lastPushClientId || undefined,
|
|
3064
|
+
reason: requireAck
|
|
3065
|
+
? OUTBOUND_DEGRADE_REASON.ACK_TIMEOUT
|
|
3066
|
+
: OUTBOUND_DEGRADE_REASON.PUSH_UNCONFIRMED,
|
|
3067
|
+
});
|
|
3068
|
+
}
|
|
3069
|
+
|
|
3070
|
+
const attemptedConnIds = Array.isArray(entry.routeAttemptConnIds)
|
|
3071
|
+
? entry.routeAttemptConnIds.filter((v): v is string => typeof v === 'string' && !!v)
|
|
3072
|
+
: [];
|
|
3073
|
+
const currentConnId = asString(entry.lastPushConnId || '').trim();
|
|
3074
|
+
const availableConnIds = Array.from(this.resolvePushConnIds(acc));
|
|
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 },
|
|
3089
|
+
);
|
|
3090
|
+
|
|
3091
|
+
if (decision.kind === 'dead-letter') {
|
|
3092
|
+
this.logInfo(
|
|
3093
|
+
'outbox ack fatal',
|
|
3094
|
+
`mid=${entry.messageId}|q=${this.outbox.size}|err=${decision.terminalReason}`,
|
|
1871
3095
|
);
|
|
3096
|
+
this.moveToDeadLetter(entry, decision.terminalReason);
|
|
1872
3097
|
continue;
|
|
1873
3098
|
}
|
|
1874
|
-
|
|
1875
|
-
entry.
|
|
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;
|
|
1876
3107
|
this.outbox.set(entry.messageId, entry);
|
|
1877
3108
|
this.scheduleSave();
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
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
|
+
});
|
|
1881
3127
|
await this.sleepMs(PUSH_DRAIN_INTERVAL_MS);
|
|
1882
3128
|
break;
|
|
1883
3129
|
}
|
|
@@ -1887,34 +3133,82 @@ class BncrBridgeRuntime {
|
|
|
1887
3133
|
continue;
|
|
1888
3134
|
}
|
|
1889
3135
|
|
|
1890
|
-
const
|
|
1891
|
-
|
|
1892
|
-
|
|
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);
|
|
1893
3147
|
continue;
|
|
1894
3148
|
}
|
|
1895
3149
|
|
|
1896
|
-
entry.retryCount =
|
|
1897
|
-
entry.lastAttemptAt =
|
|
1898
|
-
entry.nextAttemptAt =
|
|
1899
|
-
entry.lastError =
|
|
3150
|
+
entry.retryCount = decision.nextRetryCount;
|
|
3151
|
+
entry.lastAttemptAt = decision.lastAttemptAt;
|
|
3152
|
+
entry.nextAttemptAt = decision.nextAttemptAt;
|
|
3153
|
+
entry.lastError = decision.lastError;
|
|
1900
3154
|
this.outbox.set(entry.messageId, entry);
|
|
1901
3155
|
this.scheduleSave();
|
|
1902
3156
|
|
|
1903
|
-
const wait =
|
|
1904
|
-
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
|
+
);
|
|
1905
3173
|
break;
|
|
1906
3174
|
}
|
|
1907
3175
|
|
|
1908
3176
|
if (localNextDelay != null) {
|
|
1909
|
-
globalNextDelay =
|
|
1910
|
-
|
|
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
|
+
);
|
|
1911
3191
|
}
|
|
1912
3192
|
} finally {
|
|
1913
3193
|
this.pushDrainRunningAccounts.delete(acc);
|
|
1914
3194
|
}
|
|
1915
3195
|
}
|
|
1916
3196
|
|
|
1917
|
-
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
|
+
}
|
|
1918
3212
|
}
|
|
1919
3213
|
|
|
1920
3214
|
private async waitForMessageAck(messageId: string, waitMs: number): Promise<'acked' | 'timeout'> {
|
|
@@ -1994,27 +3288,47 @@ class BncrBridgeRuntime {
|
|
|
1994
3288
|
const previousActiveKey = this.activeConnectionByAccount.get(acc) || null;
|
|
1995
3289
|
const previousActiveConn = previousActiveKey ? this.connections.get(previousActiveKey) || null : null;
|
|
1996
3290
|
|
|
1997
|
-
const nextConn
|
|
3291
|
+
const nextConn = {
|
|
1998
3292
|
accountId: acc,
|
|
1999
3293
|
connId,
|
|
2000
3294
|
clientId: asString(clientId || '').trim() || undefined,
|
|
2001
3295
|
connectedAt: prev?.connectedAt || t,
|
|
2002
3296
|
lastSeenAt: t,
|
|
3297
|
+
outboundReadyUntil: (prev as any)?.outboundReadyUntil,
|
|
3298
|
+
preferredForOutboundUntil: (prev as any)?.preferredForOutboundUntil,
|
|
3299
|
+
inboundOnly: (prev as any)?.inboundOnly,
|
|
3300
|
+
} as BncrConnection & {
|
|
3301
|
+
outboundReadyUntil?: number;
|
|
3302
|
+
preferredForOutboundUntil?: number;
|
|
3303
|
+
inboundOnly?: boolean;
|
|
2003
3304
|
};
|
|
2004
3305
|
|
|
2005
|
-
this.connections.set(key, nextConn);
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
3306
|
+
this.connections.set(key, nextConn as BncrConnection);
|
|
3307
|
+
const connectionSeenPayload = {
|
|
3308
|
+
bridge: this.bridgeId,
|
|
3309
|
+
accountId: acc,
|
|
3310
|
+
connId,
|
|
3311
|
+
clientId: nextConn.clientId,
|
|
3312
|
+
connectedAt: nextConn.connectedAt,
|
|
3313
|
+
lastSeenAt: nextConn.lastSeenAt,
|
|
3314
|
+
outboundReadyUntil: nextConn.outboundReadyUntil || null,
|
|
3315
|
+
preferredForOutboundUntil: nextConn.preferredForOutboundUntil || null,
|
|
3316
|
+
inboundOnly: nextConn.inboundOnly === true,
|
|
3317
|
+
};
|
|
3318
|
+
const connectionSeenSig = JSON.stringify({
|
|
3319
|
+
bridge: this.bridgeId,
|
|
3320
|
+
accountId: acc,
|
|
3321
|
+
connId,
|
|
3322
|
+
clientId: nextConn.clientId || null,
|
|
3323
|
+
inboundOnly: nextConn.inboundOnly === true,
|
|
3324
|
+
outboundReadyActive: Number(nextConn.outboundReadyUntil || 0) > t,
|
|
3325
|
+
preferredForOutboundActive: Number(nextConn.preferredForOutboundUntil || 0) > t,
|
|
3326
|
+
});
|
|
3327
|
+
this.logInfoDedupJson('connection', 'seen', connectionSeenPayload, {
|
|
3328
|
+
key: `connection-seen:${acc}:${nextConn.clientId || connId}`,
|
|
3329
|
+
sig: connectionSeenSig,
|
|
3330
|
+
debugOnly: true,
|
|
3331
|
+
});
|
|
2018
3332
|
|
|
2019
3333
|
const current = this.activeConnectionByAccount.get(acc);
|
|
2020
3334
|
if (!current) {
|
|
@@ -2036,6 +3350,9 @@ class BncrBridgeRuntime {
|
|
|
2036
3350
|
clientId: c.clientId,
|
|
2037
3351
|
connectedAt: c.connectedAt,
|
|
2038
3352
|
lastSeenAt: c.lastSeenAt,
|
|
3353
|
+
outboundReadyUntil: (c as any).outboundReadyUntil || null,
|
|
3354
|
+
preferredForOutboundUntil: (c as any).preferredForOutboundUntil || null,
|
|
3355
|
+
inboundOnly: (c as any).inboundOnly === true,
|
|
2039
3356
|
})),
|
|
2040
3357
|
})}`,
|
|
2041
3358
|
{ debugOnly: true },
|
|
@@ -2043,39 +3360,173 @@ class BncrBridgeRuntime {
|
|
|
2043
3360
|
return;
|
|
2044
3361
|
}
|
|
2045
3362
|
|
|
2046
|
-
const curConn = this.connections.get(current);
|
|
2047
|
-
if (
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
3363
|
+
const curConn = this.connections.get(current);
|
|
3364
|
+
if (!curConn || t - curConn.lastSeenAt > CONNECT_TTL_MS) {
|
|
3365
|
+
this.activeConnectionByAccount.set(acc, key);
|
|
3366
|
+
this.logInfo(
|
|
3367
|
+
'connection',
|
|
3368
|
+
`seen:promote ${JSON.stringify({
|
|
3369
|
+
bridge: this.bridgeId,
|
|
3370
|
+
accountId: acc,
|
|
3371
|
+
reason: !curConn ? 'current-missing' : 'current-stale',
|
|
3372
|
+
previousActiveKey,
|
|
3373
|
+
previousActiveConn,
|
|
3374
|
+
nextActiveKey: key,
|
|
3375
|
+
nextActiveConn: nextConn,
|
|
3376
|
+
activeConnections: Array.from(this.connections.values())
|
|
3377
|
+
.filter((c) => c.accountId === acc)
|
|
3378
|
+
.map((c) => ({
|
|
3379
|
+
connId: c.connId,
|
|
3380
|
+
clientId: c.clientId,
|
|
3381
|
+
connectedAt: c.connectedAt,
|
|
3382
|
+
lastSeenAt: c.lastSeenAt,
|
|
3383
|
+
outboundReadyUntil: (c as any).outboundReadyUntil || null,
|
|
3384
|
+
preferredForOutboundUntil: (c as any).preferredForOutboundUntil || null,
|
|
3385
|
+
inboundOnly: (c as any).inboundOnly === true,
|
|
3386
|
+
})),
|
|
3387
|
+
})}`,
|
|
3388
|
+
{ debugOnly: true },
|
|
3389
|
+
);
|
|
3390
|
+
}
|
|
3391
|
+
}
|
|
3392
|
+
|
|
3393
|
+
private markOutboundCapability(args: {
|
|
3394
|
+
accountId: string;
|
|
3395
|
+
connId: string;
|
|
3396
|
+
clientId?: string;
|
|
3397
|
+
outboundReady?: boolean;
|
|
3398
|
+
preferredForOutbound?: boolean;
|
|
3399
|
+
inboundOnly?: boolean;
|
|
3400
|
+
at?: number;
|
|
3401
|
+
}) {
|
|
3402
|
+
const acc = normalizeAccountId(args.accountId);
|
|
3403
|
+
const key = this.connectionKey(acc, args.clientId);
|
|
3404
|
+
const t = Number(args.at || now());
|
|
3405
|
+
const current = this.connections.get(key) as BncrConnection | undefined;
|
|
3406
|
+
if (!current || current.connId !== args.connId) return;
|
|
3407
|
+
|
|
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
|
+
});
|
|
3417
|
+
|
|
3418
|
+
this.connections.set(key, next as BncrConnection);
|
|
3419
|
+
const snapshot = buildCapabilitySnapshot(next);
|
|
3420
|
+
const connectionCapabilityPayload = {
|
|
3421
|
+
bridge: this.bridgeId,
|
|
3422
|
+
accountId: acc,
|
|
3423
|
+
connId: next.connId,
|
|
3424
|
+
clientId: next.clientId,
|
|
3425
|
+
outboundReady: args.outboundReady === true,
|
|
3426
|
+
preferredForOutbound: args.preferredForOutbound === true,
|
|
3427
|
+
inboundOnly: snapshot.inboundOnly,
|
|
3428
|
+
outboundReadyUntil: snapshot.outboundReadyUntil,
|
|
3429
|
+
preferredForOutboundUntil: snapshot.preferredForOutboundUntil,
|
|
3430
|
+
};
|
|
3431
|
+
const connectionCapabilitySig = JSON.stringify({
|
|
3432
|
+
bridge: this.bridgeId,
|
|
3433
|
+
accountId: acc,
|
|
3434
|
+
connId: next.connId,
|
|
3435
|
+
clientId: next.clientId || null,
|
|
3436
|
+
outboundReady: args.outboundReady === true,
|
|
3437
|
+
preferredForOutbound: args.preferredForOutbound === true,
|
|
3438
|
+
inboundOnly: snapshot.inboundOnly,
|
|
3439
|
+
outboundReadyActive: Number(snapshot.outboundReadyUntil || 0) > t,
|
|
3440
|
+
preferredForOutboundActive: Number(snapshot.preferredForOutboundUntil || 0) > t,
|
|
3441
|
+
});
|
|
3442
|
+
this.logInfoDedupJson('connection', 'capability', connectionCapabilityPayload, {
|
|
3443
|
+
key: `connection-capability:${acc}:${next.clientId || next.connId}`,
|
|
3444
|
+
sig: connectionCapabilitySig,
|
|
3445
|
+
debugOnly: true,
|
|
3446
|
+
});
|
|
3447
|
+
}
|
|
3448
|
+
|
|
3449
|
+
private hasAlternativeLiveConnection(
|
|
3450
|
+
accountId: string,
|
|
3451
|
+
currentConnId?: string,
|
|
3452
|
+
currentClientId?: string,
|
|
3453
|
+
): boolean {
|
|
3454
|
+
const acc = normalizeAccountId(accountId);
|
|
3455
|
+
return hasAlternativeLiveConnectionFromRuntime({
|
|
3456
|
+
accountId: acc,
|
|
3457
|
+
now: now(),
|
|
3458
|
+
connectTtlMs: CONNECT_TTL_MS,
|
|
3459
|
+
currentConnId,
|
|
3460
|
+
currentClientId,
|
|
3461
|
+
connections: this.connections.values(),
|
|
3462
|
+
});
|
|
3463
|
+
}
|
|
3464
|
+
|
|
3465
|
+
private degradeOutboundCapability(args: {
|
|
3466
|
+
accountId: string;
|
|
3467
|
+
connId?: string;
|
|
3468
|
+
clientId?: string;
|
|
3469
|
+
reason: string;
|
|
3470
|
+
at?: number;
|
|
3471
|
+
}) {
|
|
3472
|
+
const acc = normalizeAccountId(args.accountId);
|
|
3473
|
+
const t = Number(args.at || now());
|
|
3474
|
+
const hasAlternativeLiveConnection = this.hasAlternativeLiveConnection(
|
|
3475
|
+
acc,
|
|
3476
|
+
args.connId,
|
|
3477
|
+
args.clientId,
|
|
3478
|
+
);
|
|
3479
|
+
const currentKey = this.activeConnectionByAccount.get(acc) || null;
|
|
3480
|
+
const matched = findCapabilityConnection({
|
|
3481
|
+
accountId: acc,
|
|
3482
|
+
connId: args.connId,
|
|
3483
|
+
clientId: args.clientId,
|
|
3484
|
+
connections: this.connections.entries(),
|
|
3485
|
+
});
|
|
3486
|
+
|
|
3487
|
+
if (!matched) return;
|
|
3488
|
+
|
|
3489
|
+
const before = buildCapabilitySnapshot(matched.connection);
|
|
3490
|
+
|
|
3491
|
+
if (!hasAlternativeLiveConnection) {
|
|
2053
3492
|
this.logInfo(
|
|
2054
3493
|
'connection',
|
|
2055
|
-
`
|
|
3494
|
+
`outbound-degrade skip ${JSON.stringify({
|
|
2056
3495
|
bridge: this.bridgeId,
|
|
2057
3496
|
accountId: acc,
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
nextActiveConn: nextConn,
|
|
2067
|
-
activeConnections: Array.from(this.connections.values())
|
|
2068
|
-
.filter((c) => c.accountId === acc)
|
|
2069
|
-
.map((c) => ({
|
|
2070
|
-
connId: c.connId,
|
|
2071
|
-
clientId: c.clientId,
|
|
2072
|
-
connectedAt: c.connectedAt,
|
|
2073
|
-
lastSeenAt: c.lastSeenAt,
|
|
2074
|
-
})),
|
|
3497
|
+
connId: matched.connection.connId,
|
|
3498
|
+
clientId: matched.connection.clientId,
|
|
3499
|
+
reason: args.reason,
|
|
3500
|
+
at: t,
|
|
3501
|
+
currentActiveKey: currentKey,
|
|
3502
|
+
degradedKey: matched.key,
|
|
3503
|
+
skipReason: 'no-alternative-live-connection',
|
|
3504
|
+
before,
|
|
2075
3505
|
})}`,
|
|
2076
3506
|
{ debugOnly: true },
|
|
2077
3507
|
);
|
|
3508
|
+
return;
|
|
2078
3509
|
}
|
|
3510
|
+
|
|
3511
|
+
const next = clearOutboundCapability(matched.connection);
|
|
3512
|
+
this.connections.set(matched.key, next as BncrConnection);
|
|
3513
|
+
|
|
3514
|
+
this.logInfo(
|
|
3515
|
+
'connection',
|
|
3516
|
+
`outbound-degrade ${JSON.stringify({
|
|
3517
|
+
bridge: this.bridgeId,
|
|
3518
|
+
accountId: acc,
|
|
3519
|
+
connId: next.connId,
|
|
3520
|
+
clientId: next.clientId,
|
|
3521
|
+
reason: args.reason,
|
|
3522
|
+
at: t,
|
|
3523
|
+
currentActiveKey: currentKey,
|
|
3524
|
+
degradedKey: matched.key,
|
|
3525
|
+
before,
|
|
3526
|
+
after: buildCapabilitySnapshot(next),
|
|
3527
|
+
})}`,
|
|
3528
|
+
{ debugOnly: true },
|
|
3529
|
+
);
|
|
2079
3530
|
}
|
|
2080
3531
|
|
|
2081
3532
|
private isOnline(accountId: string): boolean {
|
|
@@ -2281,7 +3732,7 @@ class BncrBridgeRuntime {
|
|
|
2281
3732
|
const timer = setTimeout(() => {
|
|
2282
3733
|
this.fileAckWaiters.delete(key);
|
|
2283
3734
|
this.logWarn(
|
|
2284
|
-
|
|
3735
|
+
OUTBOUND_TERMINAL_REASON.FILE_ACK_TIMEOUT,
|
|
2285
3736
|
JSON.stringify({
|
|
2286
3737
|
bridge: this.bridgeId,
|
|
2287
3738
|
transferId,
|
|
@@ -2423,80 +3874,64 @@ class BncrBridgeRuntime {
|
|
|
2423
3874
|
return { path: finalPath, fileSha256: sha };
|
|
2424
3875
|
}
|
|
2425
3876
|
|
|
2426
|
-
private
|
|
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
|
+
};
|
|
3890
|
+
}
|
|
3891
|
+
|
|
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
|
+
};
|
|
3909
|
+
}
|
|
3910
|
+
|
|
3911
|
+
private buildRuntimeStatusInput(accountId: string, overrides: { running?: boolean } = {}) {
|
|
2427
3912
|
const acc = normalizeAccountId(accountId);
|
|
2428
|
-
return
|
|
3913
|
+
return {
|
|
2429
3914
|
accountId: acc,
|
|
2430
3915
|
connected: this.isOnline(acc),
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
connectEvents: this.getCounter(this.connectEventsByAccount, acc),
|
|
2435
|
-
inboundEvents: this.getCounter(this.inboundEventsByAccount, acc),
|
|
2436
|
-
activityEvents: this.getCounter(this.activityEventsByAccount, acc),
|
|
2437
|
-
ackEvents: this.getCounter(this.ackEventsByAccount, acc),
|
|
3916
|
+
...this.buildRuntimeQueueSnapshot(acc),
|
|
3917
|
+
...this.buildRuntimeEventCounters(acc),
|
|
3918
|
+
...this.buildRuntimeActivitySnapshot(acc),
|
|
2438
3919
|
startedAt: this.startedAt,
|
|
2439
|
-
|
|
2440
|
-
lastActivityAt: this.lastActivityByAccount.get(acc) || null,
|
|
2441
|
-
lastInboundAt: this.lastInboundByAccount.get(acc) || null,
|
|
2442
|
-
lastOutboundAt: this.lastOutboundByAccount.get(acc) || null,
|
|
2443
|
-
sessionRoutesCount: Array.from(this.sessionRoutes.values()).filter((v) => v.accountId === acc)
|
|
2444
|
-
.length,
|
|
2445
|
-
invalidOutboxSessionKeys: this.countInvalidOutboxSessionKeys(acc),
|
|
2446
|
-
legacyAccountResidue: this.countLegacyAccountResidue(acc),
|
|
3920
|
+
running: overrides.running,
|
|
2447
3921
|
channelRoot: path.join(process.cwd(), 'plugins', 'bncr'),
|
|
2448
|
-
}
|
|
3922
|
+
};
|
|
3923
|
+
}
|
|
3924
|
+
|
|
3925
|
+
private buildStatusMeta(accountId: string) {
|
|
3926
|
+
return buildStatusMetaFromRuntime(this.buildRuntimeStatusInput(accountId));
|
|
2449
3927
|
}
|
|
2450
3928
|
|
|
2451
3929
|
getAccountRuntimeSnapshot(accountId: string) {
|
|
2452
|
-
|
|
2453
|
-
return buildAccountRuntimeSnapshot({
|
|
2454
|
-
accountId: acc,
|
|
2455
|
-
connected: this.isOnline(acc),
|
|
2456
|
-
pending: Array.from(this.outbox.values()).filter((v) => v.accountId === acc).length,
|
|
2457
|
-
deadLetter: this.deadLetter.filter((v) => v.accountId === acc).length,
|
|
2458
|
-
activeConnections: this.activeConnectionCount(acc),
|
|
2459
|
-
connectEvents: this.getCounter(this.connectEventsByAccount, acc),
|
|
2460
|
-
inboundEvents: this.getCounter(this.inboundEventsByAccount, acc),
|
|
2461
|
-
activityEvents: this.getCounter(this.activityEventsByAccount, acc),
|
|
2462
|
-
ackEvents: this.getCounter(this.ackEventsByAccount, acc),
|
|
2463
|
-
startedAt: this.startedAt,
|
|
2464
|
-
lastSession: this.lastSessionByAccount.get(acc) || null,
|
|
2465
|
-
lastActivityAt: this.lastActivityByAccount.get(acc) || null,
|
|
2466
|
-
lastInboundAt: this.lastInboundByAccount.get(acc) || null,
|
|
2467
|
-
lastOutboundAt: this.lastOutboundByAccount.get(acc) || null,
|
|
2468
|
-
sessionRoutesCount: Array.from(this.sessionRoutes.values()).filter((v) => v.accountId === acc)
|
|
2469
|
-
.length,
|
|
2470
|
-
invalidOutboxSessionKeys: this.countInvalidOutboxSessionKeys(acc),
|
|
2471
|
-
legacyAccountResidue: this.countLegacyAccountResidue(acc),
|
|
2472
|
-
running: true,
|
|
2473
|
-
channelRoot: path.join(process.cwd(), 'plugins', 'bncr'),
|
|
2474
|
-
});
|
|
3930
|
+
return buildAccountRuntimeSnapshot(this.buildRuntimeStatusInput(accountId, { running: true }));
|
|
2475
3931
|
}
|
|
2476
3932
|
|
|
2477
3933
|
private buildStatusHeadline(accountId: string): string {
|
|
2478
|
-
|
|
2479
|
-
return buildStatusHeadlineFromRuntime({
|
|
2480
|
-
accountId: acc,
|
|
2481
|
-
connected: this.isOnline(acc),
|
|
2482
|
-
pending: Array.from(this.outbox.values()).filter((v) => v.accountId === acc).length,
|
|
2483
|
-
deadLetter: this.deadLetter.filter((v) => v.accountId === acc).length,
|
|
2484
|
-
activeConnections: this.activeConnectionCount(acc),
|
|
2485
|
-
connectEvents: this.getCounter(this.connectEventsByAccount, acc),
|
|
2486
|
-
inboundEvents: this.getCounter(this.inboundEventsByAccount, acc),
|
|
2487
|
-
activityEvents: this.getCounter(this.activityEventsByAccount, acc),
|
|
2488
|
-
ackEvents: this.getCounter(this.ackEventsByAccount, acc),
|
|
2489
|
-
startedAt: this.startedAt,
|
|
2490
|
-
lastSession: this.lastSessionByAccount.get(acc) || null,
|
|
2491
|
-
lastActivityAt: this.lastActivityByAccount.get(acc) || null,
|
|
2492
|
-
lastInboundAt: this.lastInboundByAccount.get(acc) || null,
|
|
2493
|
-
lastOutboundAt: this.lastOutboundByAccount.get(acc) || null,
|
|
2494
|
-
sessionRoutesCount: Array.from(this.sessionRoutes.values()).filter((v) => v.accountId === acc)
|
|
2495
|
-
.length,
|
|
2496
|
-
invalidOutboxSessionKeys: this.countInvalidOutboxSessionKeys(acc),
|
|
2497
|
-
legacyAccountResidue: this.countLegacyAccountResidue(acc),
|
|
2498
|
-
channelRoot: path.join(process.cwd(), 'plugins', 'bncr'),
|
|
2499
|
-
});
|
|
3934
|
+
return buildStatusHeadlineFromRuntime(this.buildRuntimeStatusInput(accountId));
|
|
2500
3935
|
}
|
|
2501
3936
|
|
|
2502
3937
|
getStatusHeadline(accountId: string): string {
|
|
@@ -2524,22 +3959,21 @@ class BncrBridgeRuntime {
|
|
|
2524
3959
|
}
|
|
2525
3960
|
|
|
2526
3961
|
private enqueueOutbound(entry: OutboxEntry) {
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
|
|
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.
|
|
2531
3967
|
this.logInfo(
|
|
2532
3968
|
'outbound',
|
|
2533
|
-
JSON.stringify(
|
|
2534
|
-
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
textPreview: text.slice(0, 120),
|
|
2542
|
-
}),
|
|
3969
|
+
JSON.stringify(
|
|
3970
|
+
buildOutboxEnqueueDebugInfo({
|
|
3971
|
+
bridgeId: this.bridgeId,
|
|
3972
|
+
entry,
|
|
3973
|
+
asString,
|
|
3974
|
+
formatDisplayScope,
|
|
3975
|
+
}),
|
|
3976
|
+
),
|
|
2543
3977
|
{ debugOnly: true },
|
|
2544
3978
|
);
|
|
2545
3979
|
this.logOutboundSummary(entry);
|
|
@@ -2549,50 +3983,46 @@ class BncrBridgeRuntime {
|
|
|
2549
3983
|
}
|
|
2550
3984
|
|
|
2551
3985
|
private moveToDeadLetter(entry: OutboxEntry, reason: string) {
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
|
|
2556
|
-
|
|
2557
|
-
|
|
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
|
+
});
|
|
2558
4001
|
this.outbox.delete(entry.messageId);
|
|
2559
4002
|
this.resolveMessageAck(entry.messageId, 'timeout');
|
|
2560
4003
|
this.scheduleSave();
|
|
2561
4004
|
}
|
|
2562
4005
|
|
|
2563
4006
|
private collectDue(accountId: string, maxBatch: number): Array<Record<string, unknown>> {
|
|
2564
|
-
const due: Array<Record<string, unknown>> = [];
|
|
2565
|
-
const t = now();
|
|
2566
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
|
+
});
|
|
2567
4016
|
|
|
2568
|
-
for (const entry of
|
|
2569
|
-
if (entry.accountId !== key) continue;
|
|
2570
|
-
if (entry.nextAttemptAt > t) continue;
|
|
2571
|
-
|
|
2572
|
-
const nextAttempt = entry.retryCount + 1;
|
|
2573
|
-
if (nextAttempt > MAX_RETRY) {
|
|
2574
|
-
this.moveToDeadLetter(entry, 'retry-limit');
|
|
2575
|
-
continue;
|
|
2576
|
-
}
|
|
2577
|
-
|
|
2578
|
-
entry.retryCount = nextAttempt;
|
|
2579
|
-
entry.lastAttemptAt = t;
|
|
2580
|
-
entry.nextAttemptAt = t + backoffMs(nextAttempt);
|
|
4017
|
+
for (const entry of result.updatedEntries) {
|
|
2581
4018
|
this.outbox.set(entry.messageId, entry);
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
_meta: {
|
|
2586
|
-
retryCount: entry.retryCount,
|
|
2587
|
-
nextAttemptAt: entry.nextAttemptAt,
|
|
2588
|
-
},
|
|
2589
|
-
});
|
|
2590
|
-
|
|
2591
|
-
if (due.length >= maxBatch) break;
|
|
4019
|
+
}
|
|
4020
|
+
for (const entry of result.deadLetterEntries) {
|
|
4021
|
+
this.moveToDeadLetter(entry, entry.lastError || 'retry-limit');
|
|
2592
4022
|
}
|
|
2593
4023
|
|
|
2594
|
-
if (
|
|
2595
|
-
return
|
|
4024
|
+
if (result.duePayloads.length) this.scheduleSave();
|
|
4025
|
+
return result.duePayloads;
|
|
2596
4026
|
}
|
|
2597
4027
|
|
|
2598
4028
|
private async payloadMediaToBase64(
|
|
@@ -2610,6 +4040,301 @@ class BncrBridgeRuntime {
|
|
|
2610
4040
|
};
|
|
2611
4041
|
}
|
|
2612
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
|
+
|
|
2613
4338
|
private async sleepMs(ms: number): Promise<void> {
|
|
2614
4339
|
await new Promise<void>((resolve) => setTimeout(resolve, Math.max(0, Number(ms || 0))));
|
|
2615
4340
|
}
|
|
@@ -2619,6 +4344,10 @@ class BncrBridgeRuntime {
|
|
|
2619
4344
|
chunkIndex: number;
|
|
2620
4345
|
timeoutMs?: number;
|
|
2621
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.
|
|
2622
4351
|
const { transferId, chunkIndex } = params;
|
|
2623
4352
|
const timeoutMs = Math.max(
|
|
2624
4353
|
1_000,
|
|
@@ -2656,6 +4385,10 @@ class BncrBridgeRuntime {
|
|
|
2656
4385
|
transferId: string;
|
|
2657
4386
|
timeoutMs?: number;
|
|
2658
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.
|
|
2659
4392
|
const { transferId } = params;
|
|
2660
4393
|
const timeoutMs = Math.max(2_000, Math.min(Number(params.timeoutMs || 60_000), 120_000));
|
|
2661
4394
|
const started = now();
|
|
@@ -2693,23 +4426,20 @@ class BncrBridgeRuntime {
|
|
|
2693
4426
|
mediaUrl: string;
|
|
2694
4427
|
mediaLocalRoots?: readonly string[];
|
|
2695
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.
|
|
2696
4434
|
mode: 'base64' | 'chunk';
|
|
2697
4435
|
mimeType?: string;
|
|
2698
4436
|
fileName?: string;
|
|
2699
4437
|
mediaBase64?: string;
|
|
2700
4438
|
path?: string;
|
|
2701
4439
|
}> {
|
|
2702
|
-
const loaded = await this.
|
|
2703
|
-
localRoots: params.mediaLocalRoots,
|
|
2704
|
-
maxBytes: 50 * 1024 * 1024,
|
|
2705
|
-
});
|
|
2706
|
-
|
|
2707
|
-
const size = loaded.buffer.byteLength;
|
|
2708
|
-
const mimeType = loaded.contentType;
|
|
2709
|
-
const fileName = resolveOutboundFileName({
|
|
4440
|
+
const { loaded, size, mimeType, fileName } = await this.loadOutboundTransferMedia({
|
|
2710
4441
|
mediaUrl: params.mediaUrl,
|
|
2711
|
-
|
|
2712
|
-
mimeType,
|
|
4442
|
+
mediaLocalRoots: params.mediaLocalRoots,
|
|
2713
4443
|
});
|
|
2714
4444
|
|
|
2715
4445
|
if (!FILE_FORCE_CHUNK && size <= FILE_INLINE_THRESHOLD) {
|
|
@@ -2724,44 +4454,31 @@ class BncrBridgeRuntime {
|
|
|
2724
4454
|
const ctx = this.gatewayContext;
|
|
2725
4455
|
const owner = this.resolveOutboxPushOwner(params.accountId);
|
|
2726
4456
|
const recentInboundReachable = this.hasRecentInboundReachability(params.accountId);
|
|
2727
|
-
const directConnIds = this.resolvePushConnIds(params.accountId);
|
|
2728
|
-
const recentConnIds = recentInboundReachable
|
|
2729
|
-
? this.resolveRecentInboundConnIds(params.accountId)
|
|
2730
|
-
: new Set<string>();
|
|
2731
4457
|
const accountId = normalizeAccountId(params.accountId);
|
|
2732
|
-
const
|
|
2733
|
-
|
|
2734
|
-
|
|
2735
|
-
|
|
2736
|
-
|
|
2737
|
-
|
|
2738
|
-
|
|
2739
|
-
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
|
|
2745
|
-
|
|
2746
|
-
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
activeConnectionKey,
|
|
2750
|
-
ownerConnId: owner?.connId || null,
|
|
2751
|
-
ownerClientId: owner?.clientId || null,
|
|
2752
|
-
directConnIds: Array.from(directConnIds),
|
|
2753
|
-
recentInboundReachable,
|
|
2754
|
-
recentConnIds: Array.from(recentConnIds),
|
|
2755
|
-
accountConnections,
|
|
2756
|
-
}),
|
|
2757
|
-
{ debugOnly: true },
|
|
2758
|
-
);
|
|
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
|
+
});
|
|
2759
4475
|
if (!ctx) throw new Error('gateway context unavailable');
|
|
2760
4476
|
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
|
|
2764
|
-
|
|
4477
|
+
const connIds = this.selectTransferConnIds({
|
|
4478
|
+
directConnIds: routeDiagnostics.directConnIds,
|
|
4479
|
+
recentConnIds: routeDiagnostics.recentConnIds,
|
|
4480
|
+
recentInboundReachable,
|
|
4481
|
+
});
|
|
2765
4482
|
if (!connIds.size) throw new Error('no active bncr client for file chunk transfer');
|
|
2766
4483
|
|
|
2767
4484
|
const transferId = randomUUID();
|
|
@@ -2769,63 +4486,50 @@ class BncrBridgeRuntime {
|
|
|
2769
4486
|
const totalChunks = Math.ceil(size / chunkSize);
|
|
2770
4487
|
const fileSha256 = createHash('sha256').update(loaded.buffer).digest('hex');
|
|
2771
4488
|
|
|
2772
|
-
this.
|
|
2773
|
-
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
ownerConnId: owner?.connId || null,
|
|
2787
|
-
ownerClientId: owner?.clientId || null,
|
|
2788
|
-
}),
|
|
2789
|
-
{ debugOnly: true },
|
|
2790
|
-
);
|
|
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
|
+
});
|
|
2791
4503
|
|
|
2792
|
-
const st
|
|
4504
|
+
const st = this.buildInitialFileSendTransferState({
|
|
2793
4505
|
transferId,
|
|
2794
|
-
accountId:
|
|
4506
|
+
accountId: params.accountId,
|
|
2795
4507
|
sessionKey: params.sessionKey,
|
|
2796
4508
|
route: params.route,
|
|
2797
4509
|
fileName,
|
|
2798
|
-
mimeType
|
|
4510
|
+
mimeType,
|
|
2799
4511
|
fileSize: size,
|
|
2800
4512
|
chunkSize,
|
|
2801
4513
|
totalChunks,
|
|
2802
4514
|
fileSha256,
|
|
2803
|
-
startedAt: now(),
|
|
2804
|
-
status: 'init',
|
|
2805
|
-
ackedChunks: new Set(),
|
|
2806
|
-
failedChunks: new Map(),
|
|
2807
4515
|
ownerConnId: owner?.connId,
|
|
2808
4516
|
ownerClientId: owner?.clientId,
|
|
2809
|
-
};
|
|
4517
|
+
});
|
|
2810
4518
|
this.fileSendTransfers.set(transferId, st);
|
|
2811
4519
|
|
|
2812
4520
|
ctx.broadcastToConnIds(
|
|
2813
4521
|
BNCR_FILE_INIT_EVENT,
|
|
2814
|
-
{
|
|
4522
|
+
this.buildFileTransferInitPayload({
|
|
2815
4523
|
transferId,
|
|
2816
|
-
direction: 'oc2bncr',
|
|
2817
4524
|
sessionKey: params.sessionKey,
|
|
2818
|
-
|
|
2819
|
-
groupId: params.route.groupId,
|
|
2820
|
-
userId: params.route.userId,
|
|
4525
|
+
route: params.route,
|
|
2821
4526
|
fileName,
|
|
2822
4527
|
mimeType,
|
|
2823
4528
|
fileSize: size,
|
|
2824
4529
|
chunkSize,
|
|
2825
4530
|
totalChunks,
|
|
2826
4531
|
fileSha256,
|
|
2827
|
-
|
|
2828
|
-
},
|
|
4532
|
+
}),
|
|
2829
4533
|
connIds,
|
|
2830
4534
|
);
|
|
2831
4535
|
|
|
@@ -2853,20 +4557,15 @@ class BncrBridgeRuntime {
|
|
|
2853
4557
|
connIds,
|
|
2854
4558
|
);
|
|
2855
4559
|
|
|
2856
|
-
this.
|
|
2857
|
-
|
|
2858
|
-
|
|
2859
|
-
|
|
2860
|
-
|
|
2861
|
-
|
|
2862
|
-
|
|
2863
|
-
|
|
2864
|
-
|
|
2865
|
-
size: slice.byteLength,
|
|
2866
|
-
connIds: Array.from(connIds),
|
|
2867
|
-
}),
|
|
2868
|
-
{ debugOnly: true },
|
|
2869
|
-
);
|
|
4560
|
+
this.logFileTransferChunkSend({
|
|
4561
|
+
transferId,
|
|
4562
|
+
accountId,
|
|
4563
|
+
chunkIndex: idx,
|
|
4564
|
+
attempt,
|
|
4565
|
+
offset: start,
|
|
4566
|
+
size: slice.byteLength,
|
|
4567
|
+
connIds,
|
|
4568
|
+
});
|
|
2870
4569
|
|
|
2871
4570
|
try {
|
|
2872
4571
|
await this.waitChunkAck({
|
|
@@ -2874,33 +4573,23 @@ class BncrBridgeRuntime {
|
|
|
2874
4573
|
chunkIndex: idx,
|
|
2875
4574
|
timeoutMs: FILE_TRANSFER_ACK_TTL_MS,
|
|
2876
4575
|
});
|
|
2877
|
-
this.
|
|
2878
|
-
|
|
2879
|
-
|
|
2880
|
-
|
|
2881
|
-
|
|
2882
|
-
|
|
2883
|
-
chunkIndex: idx,
|
|
2884
|
-
attempt,
|
|
2885
|
-
}),
|
|
2886
|
-
{ debugOnly: true },
|
|
2887
|
-
);
|
|
4576
|
+
this.logFileTransferChunkAck({
|
|
4577
|
+
transferId,
|
|
4578
|
+
accountId,
|
|
4579
|
+
chunkIndex: idx,
|
|
4580
|
+
attempt,
|
|
4581
|
+
});
|
|
2888
4582
|
ok = true;
|
|
2889
4583
|
break;
|
|
2890
4584
|
} catch (err) {
|
|
2891
4585
|
lastErr = err;
|
|
2892
|
-
this.
|
|
2893
|
-
|
|
2894
|
-
|
|
2895
|
-
|
|
2896
|
-
|
|
2897
|
-
|
|
2898
|
-
|
|
2899
|
-
attempt,
|
|
2900
|
-
error: asString((err as Error)?.message || err),
|
|
2901
|
-
}),
|
|
2902
|
-
{ debugOnly: true },
|
|
2903
|
-
);
|
|
4586
|
+
this.logFileTransferChunkAckFail({
|
|
4587
|
+
transferId,
|
|
4588
|
+
accountId,
|
|
4589
|
+
chunkIndex: idx,
|
|
4590
|
+
attempt,
|
|
4591
|
+
error: err,
|
|
4592
|
+
});
|
|
2904
4593
|
await this.sleepMs(150 * attempt);
|
|
2905
4594
|
}
|
|
2906
4595
|
}
|
|
@@ -2931,29 +4620,19 @@ class BncrBridgeRuntime {
|
|
|
2931
4620
|
connIds,
|
|
2932
4621
|
);
|
|
2933
4622
|
|
|
2934
|
-
this.
|
|
2935
|
-
|
|
2936
|
-
|
|
2937
|
-
|
|
2938
|
-
|
|
2939
|
-
accountId,
|
|
2940
|
-
connIds: Array.from(connIds),
|
|
2941
|
-
}),
|
|
2942
|
-
{ debugOnly: true },
|
|
2943
|
-
);
|
|
4623
|
+
this.logFileTransferCompleteSend({
|
|
4624
|
+
transferId,
|
|
4625
|
+
accountId,
|
|
4626
|
+
connIds,
|
|
4627
|
+
});
|
|
2944
4628
|
|
|
2945
4629
|
const done = await this.waitCompleteAck({ transferId, timeoutMs: 60_000 });
|
|
2946
4630
|
|
|
2947
|
-
this.
|
|
2948
|
-
|
|
2949
|
-
|
|
2950
|
-
|
|
2951
|
-
|
|
2952
|
-
accountId,
|
|
2953
|
-
payload: done,
|
|
2954
|
-
}),
|
|
2955
|
-
{ debugOnly: true },
|
|
2956
|
-
);
|
|
4631
|
+
this.logFileTransferCompleteAck({
|
|
4632
|
+
transferId,
|
|
4633
|
+
accountId,
|
|
4634
|
+
payload: done,
|
|
4635
|
+
});
|
|
2957
4636
|
|
|
2958
4637
|
return {
|
|
2959
4638
|
mode: 'chunk',
|
|
@@ -2967,81 +4646,101 @@ class BncrBridgeRuntime {
|
|
|
2967
4646
|
accountId: string;
|
|
2968
4647
|
sessionKey: string;
|
|
2969
4648
|
route: BncrRoute;
|
|
2970
|
-
payload:
|
|
2971
|
-
text?: string;
|
|
2972
|
-
mediaUrl?: string;
|
|
2973
|
-
mediaUrls?: string[];
|
|
2974
|
-
asVoice?: boolean;
|
|
2975
|
-
audioAsVoice?: boolean;
|
|
2976
|
-
kind?: 'tool' | 'block' | 'final';
|
|
2977
|
-
replyToId?: string;
|
|
2978
|
-
};
|
|
4649
|
+
payload: ReplyPayloadInput;
|
|
2979
4650
|
mediaLocalRoots?: readonly string[];
|
|
2980
4651
|
}) {
|
|
2981
4652
|
const { accountId, sessionKey, route, payload, mediaLocalRoots } = params;
|
|
4653
|
+
const normalized = normalizeReplyPayload(payload, { asString });
|
|
2982
4654
|
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
|
|
2986
|
-
|
|
2987
|
-
|
|
2988
|
-
|
|
2989
|
-
|
|
2990
|
-
|
|
2991
|
-
|
|
2992
|
-
this.
|
|
2993
|
-
|
|
2994
|
-
|
|
2995
|
-
|
|
2996
|
-
|
|
2997
|
-
|
|
2998
|
-
|
|
2999
|
-
text: first ? asString(payload.text || '') : '',
|
|
3000
|
-
asVoice: payload.asVoice,
|
|
3001
|
-
audioAsVoice: payload.audioAsVoice,
|
|
3002
|
-
kind: payload.kind,
|
|
3003
|
-
replyToId: asString(payload.replyToId || '').trim() || undefined,
|
|
4655
|
+
enqueueNormalizedReplyPayload(
|
|
4656
|
+
{
|
|
4657
|
+
accountId,
|
|
4658
|
+
sessionKey,
|
|
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),
|
|
3004
4671
|
}),
|
|
3005
|
-
|
|
3006
|
-
|
|
3007
|
-
|
|
3008
|
-
return;
|
|
3009
|
-
}
|
|
4672
|
+
},
|
|
4673
|
+
);
|
|
4674
|
+
}
|
|
3010
4675
|
|
|
3011
|
-
|
|
3012
|
-
|
|
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
|
+
}
|
|
3013
4688
|
|
|
3014
|
-
|
|
3015
|
-
|
|
3016
|
-
|
|
3017
|
-
|
|
3018
|
-
|
|
3019
|
-
|
|
3020
|
-
|
|
3021
|
-
|
|
3022
|
-
|
|
3023
|
-
|
|
3024
|
-
|
|
3025
|
-
|
|
3026
|
-
|
|
3027
|
-
|
|
3028
|
-
|
|
3029
|
-
|
|
3030
|
-
|
|
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
|
+
});
|
|
4703
|
+
|
|
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,
|
|
3031
4712
|
},
|
|
3032
|
-
|
|
3033
|
-
|
|
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),
|
|
4719
|
+
}),
|
|
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
|
+
}
|
|
3034
4730
|
|
|
3035
|
-
|
|
3036
|
-
|
|
3037
|
-
|
|
3038
|
-
|
|
3039
|
-
|
|
3040
|
-
|
|
3041
|
-
|
|
3042
|
-
|
|
3043
|
-
|
|
3044
|
-
|
|
4731
|
+
private enqueueReplyMediaEntries(params: ReplyMediaEntriesParams) {
|
|
4732
|
+
let first = true;
|
|
4733
|
+
const currentTime = now();
|
|
4734
|
+
|
|
4735
|
+
for (const mediaUrl of params.payload.mediaList) {
|
|
4736
|
+
this.enqueueSingleReplyMediaEntry({
|
|
4737
|
+
params,
|
|
4738
|
+
mediaUrl,
|
|
4739
|
+
first,
|
|
4740
|
+
currentTime,
|
|
4741
|
+
});
|
|
4742
|
+
first = false;
|
|
4743
|
+
}
|
|
3045
4744
|
}
|
|
3046
4745
|
|
|
3047
4746
|
handleConnect = async ({ params, respond, client, context }: GatewayRequestHandlerOptions) => {
|
|
@@ -3049,6 +4748,9 @@ class BncrBridgeRuntime {
|
|
|
3049
4748
|
const accountId = normalizeAccountId(asString(params?.accountId || ''));
|
|
3050
4749
|
const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
|
|
3051
4750
|
const clientId = asString((params as any)?.clientId || '').trim() || undefined;
|
|
4751
|
+
const outboundReady = (params as any)?.outboundReady === true;
|
|
4752
|
+
const preferredForOutbound = (params as any)?.preferredForOutbound === true;
|
|
4753
|
+
const inboundOnly = (params as any)?.inboundOnly === true;
|
|
3052
4754
|
|
|
3053
4755
|
this.logInfo(
|
|
3054
4756
|
'connection',
|
|
@@ -3057,14 +4759,23 @@ class BncrBridgeRuntime {
|
|
|
3057
4759
|
accountId,
|
|
3058
4760
|
connId,
|
|
3059
4761
|
clientId,
|
|
4762
|
+
outboundReady,
|
|
4763
|
+
preferredForOutbound,
|
|
4764
|
+
inboundOnly,
|
|
3060
4765
|
hasContext: Boolean(context),
|
|
3061
4766
|
})}`,
|
|
3062
4767
|
{ debugOnly: true },
|
|
3063
4768
|
);
|
|
3064
4769
|
|
|
3065
|
-
this.
|
|
3066
|
-
|
|
3067
|
-
|
|
4770
|
+
this.refreshLiveConnectionState({
|
|
4771
|
+
accountId,
|
|
4772
|
+
connId,
|
|
4773
|
+
clientId,
|
|
4774
|
+
outboundReady,
|
|
4775
|
+
preferredForOutbound,
|
|
4776
|
+
inboundOnly,
|
|
4777
|
+
context,
|
|
4778
|
+
});
|
|
3068
4779
|
this.incrementCounter(this.connectEventsByAccount, accountId);
|
|
3069
4780
|
const lease = this.acceptConnection();
|
|
3070
4781
|
|
|
@@ -3093,113 +4804,41 @@ class BncrBridgeRuntime {
|
|
|
3093
4804
|
now: now(),
|
|
3094
4805
|
});
|
|
3095
4806
|
|
|
3096
|
-
// WS 一旦在线,立即尝试把离线期间积压队列直推出去
|
|
3097
|
-
this.flushPushQueue(
|
|
3098
|
-
|
|
3099
|
-
|
|
3100
|
-
|
|
3101
|
-
|
|
3102
|
-
|
|
3103
|
-
const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
|
|
3104
|
-
const clientId = asString((params as any)?.clientId || '').trim() || undefined;
|
|
3105
|
-
const messageId = asString(params?.messageId || '').trim();
|
|
3106
|
-
const staleObserved = this.observeLease('ack', params ?? {});
|
|
3107
|
-
|
|
3108
|
-
this.logInfo(
|
|
3109
|
-
'outbox',
|
|
3110
|
-
`ack ${JSON.stringify({
|
|
3111
|
-
accountId,
|
|
3112
|
-
messageId,
|
|
3113
|
-
ok: params?.ok !== false,
|
|
3114
|
-
fatal: params?.fatal === true,
|
|
3115
|
-
error: asString(params?.error || ''),
|
|
3116
|
-
stale: staleObserved.stale,
|
|
3117
|
-
})}`,
|
|
3118
|
-
{ debugOnly: true },
|
|
3119
|
-
);
|
|
3120
|
-
if (!messageId) {
|
|
3121
|
-
respond(false, { error: 'messageId required' });
|
|
3122
|
-
return;
|
|
3123
|
-
}
|
|
3124
|
-
|
|
3125
|
-
const entry = this.outbox.get(messageId);
|
|
3126
|
-
if (!entry) {
|
|
3127
|
-
respond(true, { ok: true, message: 'already-acked-or-missing', stale: staleObserved.stale });
|
|
3128
|
-
return;
|
|
3129
|
-
}
|
|
3130
|
-
|
|
3131
|
-
if (entry.accountId !== accountId) {
|
|
3132
|
-
respond(false, { error: 'account mismatch' });
|
|
3133
|
-
return;
|
|
3134
|
-
}
|
|
3135
|
-
|
|
3136
|
-
if (staleObserved.stale) {
|
|
3137
|
-
const sameConn = !!entry.lastPushConnId && entry.lastPushConnId === connId;
|
|
3138
|
-
const sameClient =
|
|
3139
|
-
!entry.lastPushConnId &&
|
|
3140
|
-
!!entry.lastPushClientId &&
|
|
3141
|
-
!!clientId &&
|
|
3142
|
-
entry.lastPushClientId === clientId;
|
|
3143
|
-
if (!(sameConn || sameClient)) {
|
|
3144
|
-
this.logWarn(
|
|
3145
|
-
'stale',
|
|
3146
|
-
`ignore kind=ack accountId=${accountId} connId=${connId} clientId=${clientId || '-'} messageId=${messageId} reason=owner-mismatch lastPushConnId=${entry.lastPushConnId || '-'} lastPushClientId=${entry.lastPushClientId || '-'}`,
|
|
3147
|
-
{ debugOnly: true },
|
|
3148
|
-
);
|
|
3149
|
-
respond(true, { ok: true, stale: true, ignored: true });
|
|
3150
|
-
return;
|
|
3151
|
-
}
|
|
3152
|
-
} else {
|
|
3153
|
-
this.rememberGatewayContext(context);
|
|
3154
|
-
this.markSeen(accountId, connId, clientId);
|
|
3155
|
-
}
|
|
3156
|
-
this.lastAckAtGlobal = now();
|
|
3157
|
-
this.incrementCounter(this.ackEventsByAccount, accountId);
|
|
3158
|
-
|
|
3159
|
-
const ok = params?.ok !== false;
|
|
3160
|
-
const fatal = params?.fatal === true;
|
|
3161
|
-
|
|
3162
|
-
if (ok) {
|
|
3163
|
-
this.outbox.delete(messageId);
|
|
3164
|
-
this.scheduleSave();
|
|
3165
|
-
this.resolveMessageAck(messageId, 'acked');
|
|
3166
|
-
respond(
|
|
3167
|
-
true,
|
|
3168
|
-
staleObserved.stale ? { ok: true, stale: true, staleAccepted: true } : { ok: true },
|
|
3169
|
-
);
|
|
3170
|
-
this.flushPushQueue(accountId);
|
|
3171
|
-
return;
|
|
3172
|
-
}
|
|
3173
|
-
|
|
3174
|
-
if (fatal) {
|
|
3175
|
-
this.moveToDeadLetter(entry, asString(params?.error || 'fatal-ack'));
|
|
3176
|
-
respond(
|
|
3177
|
-
true,
|
|
3178
|
-
staleObserved.stale
|
|
3179
|
-
? { ok: true, movedToDeadLetter: true, stale: true, staleAccepted: true }
|
|
3180
|
-
: { ok: true, movedToDeadLetter: true },
|
|
3181
|
-
);
|
|
3182
|
-
return;
|
|
3183
|
-
}
|
|
4807
|
+
// WS 一旦在线,立即尝试把离线期间积压队列直推出去
|
|
4808
|
+
this.flushPushQueue({
|
|
4809
|
+
accountId,
|
|
4810
|
+
trigger: OUTBOUND_FLUSH_TRIGGER.CONNECT,
|
|
4811
|
+
reason: OUTBOUND_FLUSH_REASON.WS_ONLINE,
|
|
4812
|
+
});
|
|
4813
|
+
};
|
|
3184
4814
|
|
|
3185
|
-
|
|
3186
|
-
|
|
3187
|
-
|
|
3188
|
-
|
|
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;
|
|
3189
4823
|
|
|
3190
|
-
|
|
3191
|
-
|
|
3192
|
-
|
|
3193
|
-
|
|
3194
|
-
: { ok: true, willRetry: true },
|
|
3195
|
-
);
|
|
4824
|
+
const { accountId } = prepared;
|
|
4825
|
+
this.lastAckAtGlobal = now();
|
|
4826
|
+
this.incrementCounter(this.ackEventsByAccount, accountId);
|
|
4827
|
+
this.handleAckOutcome({ params, respond, ...prepared });
|
|
3196
4828
|
};
|
|
3197
4829
|
|
|
3198
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.
|
|
3199
4835
|
await this.syncDebugFlag();
|
|
3200
4836
|
const accountId = normalizeAccountId(asString(params?.accountId || ''));
|
|
3201
4837
|
const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
|
|
3202
4838
|
const clientId = asString((params as any)?.clientId || '').trim() || undefined;
|
|
4839
|
+
const outboundReady = (params as any)?.outboundReady === true;
|
|
4840
|
+
const preferredForOutbound = (params as any)?.preferredForOutbound === true;
|
|
4841
|
+
const inboundOnly = (params as any)?.inboundOnly === true;
|
|
3203
4842
|
if (
|
|
3204
4843
|
this.shouldIgnoreStaleEvent({
|
|
3205
4844
|
kind: 'activity',
|
|
@@ -3220,13 +4859,22 @@ class BncrBridgeRuntime {
|
|
|
3220
4859
|
accountId,
|
|
3221
4860
|
connId,
|
|
3222
4861
|
clientId,
|
|
4862
|
+
outboundReady,
|
|
4863
|
+
preferredForOutbound,
|
|
4864
|
+
inboundOnly,
|
|
3223
4865
|
hasContext: Boolean(context),
|
|
3224
4866
|
})}`,
|
|
3225
4867
|
{ debugOnly: true },
|
|
3226
4868
|
);
|
|
3227
|
-
this.
|
|
3228
|
-
|
|
3229
|
-
|
|
4869
|
+
this.refreshLiveConnectionState({
|
|
4870
|
+
accountId,
|
|
4871
|
+
connId,
|
|
4872
|
+
clientId,
|
|
4873
|
+
outboundReady,
|
|
4874
|
+
preferredForOutbound,
|
|
4875
|
+
inboundOnly,
|
|
4876
|
+
context,
|
|
4877
|
+
});
|
|
3230
4878
|
this.incrementCounter(this.activityEventsByAccount, accountId);
|
|
3231
4879
|
|
|
3232
4880
|
// 轻量活动心跳:仅刷新在线活跃状态,不承担拉取职责。
|
|
@@ -3239,7 +4887,11 @@ class BncrBridgeRuntime {
|
|
|
3239
4887
|
deadLetter: this.deadLetter.filter((v) => v.accountId === accountId).length,
|
|
3240
4888
|
now: now(),
|
|
3241
4889
|
});
|
|
3242
|
-
this.flushPushQueue(
|
|
4890
|
+
this.flushPushQueue({
|
|
4891
|
+
accountId,
|
|
4892
|
+
trigger: OUTBOUND_FLUSH_TRIGGER.ACTIVITY,
|
|
4893
|
+
reason: OUTBOUND_FLUSH_REASON.ACTIVITY_HEARTBEAT,
|
|
4894
|
+
});
|
|
3243
4895
|
};
|
|
3244
4896
|
|
|
3245
4897
|
handleDiagnostics = async ({ params, respond }: GatewayRequestHandlerOptions) => {
|
|
@@ -3247,37 +4899,27 @@ class BncrBridgeRuntime {
|
|
|
3247
4899
|
const cfg = this.api.runtime.config.current();
|
|
3248
4900
|
const runtime = this.getAccountRuntimeSnapshot(accountId);
|
|
3249
4901
|
const diagnostics = this.buildExtendedDiagnostics(accountId);
|
|
3250
|
-
const permissions = buildBncrPermissionSummary(cfg ?? {});
|
|
3251
|
-
const probe = probeBncrAccount({
|
|
3252
|
-
accountId,
|
|
3253
|
-
connected: Boolean(runtime?.connected),
|
|
3254
|
-
pending: Number(runtime?.meta?.pending ?? 0),
|
|
3255
|
-
deadLetter: Number(runtime?.meta?.deadLetter ?? 0),
|
|
3256
|
-
activeConnections: this.activeConnectionCount(accountId),
|
|
3257
|
-
invalidOutboxSessionKeys: this.countInvalidOutboxSessionKeys(accountId),
|
|
3258
|
-
legacyAccountResidue: this.countLegacyAccountResidue(accountId),
|
|
3259
|
-
lastActivityAt: runtime?.meta?.lastActivityAt ?? null,
|
|
3260
|
-
structure: {
|
|
3261
|
-
coreComplete: true,
|
|
3262
|
-
inboundComplete: true,
|
|
3263
|
-
outboundComplete: true,
|
|
3264
|
-
},
|
|
3265
|
-
});
|
|
3266
4902
|
|
|
3267
|
-
respond(
|
|
3268
|
-
|
|
3269
|
-
|
|
3270
|
-
|
|
3271
|
-
|
|
3272
|
-
|
|
3273
|
-
|
|
3274
|
-
|
|
3275
|
-
|
|
3276
|
-
|
|
3277
|
-
|
|
3278
|
-
|
|
3279
|
-
|
|
3280
|
-
|
|
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
|
+
);
|
|
3281
4923
|
};
|
|
3282
4924
|
|
|
3283
4925
|
handleFileInit = async ({ params, respond, client, context }: GatewayRequestHandlerOptions) => {
|
|
@@ -3296,9 +4938,12 @@ class BncrBridgeRuntime {
|
|
|
3296
4938
|
respond(true, { ok: true, stale: true, ignored: true });
|
|
3297
4939
|
return;
|
|
3298
4940
|
}
|
|
3299
|
-
this.
|
|
3300
|
-
|
|
3301
|
-
|
|
4941
|
+
this.refreshAcceptedFileTransferLiveState({
|
|
4942
|
+
accountId,
|
|
4943
|
+
connId,
|
|
4944
|
+
clientId,
|
|
4945
|
+
context,
|
|
4946
|
+
});
|
|
3302
4947
|
|
|
3303
4948
|
const transferId = asString(params?.transferId || '').trim();
|
|
3304
4949
|
const sessionKey = asString(params?.sessionKey || '').trim();
|
|
@@ -3406,9 +5051,12 @@ class BncrBridgeRuntime {
|
|
|
3406
5051
|
return;
|
|
3407
5052
|
}
|
|
3408
5053
|
} else {
|
|
3409
|
-
this.
|
|
3410
|
-
|
|
3411
|
-
|
|
5054
|
+
this.refreshAcceptedFileTransferLiveState({
|
|
5055
|
+
accountId,
|
|
5056
|
+
connId,
|
|
5057
|
+
clientId,
|
|
5058
|
+
context,
|
|
5059
|
+
});
|
|
3412
5060
|
}
|
|
3413
5061
|
|
|
3414
5062
|
try {
|
|
@@ -3493,9 +5141,12 @@ class BncrBridgeRuntime {
|
|
|
3493
5141
|
return;
|
|
3494
5142
|
}
|
|
3495
5143
|
} else {
|
|
3496
|
-
this.
|
|
3497
|
-
|
|
3498
|
-
|
|
5144
|
+
this.refreshAcceptedFileTransferLiveState({
|
|
5145
|
+
accountId,
|
|
5146
|
+
connId,
|
|
5147
|
+
clientId,
|
|
5148
|
+
context,
|
|
5149
|
+
});
|
|
3499
5150
|
}
|
|
3500
5151
|
|
|
3501
5152
|
try {
|
|
@@ -3596,9 +5247,12 @@ class BncrBridgeRuntime {
|
|
|
3596
5247
|
return;
|
|
3597
5248
|
}
|
|
3598
5249
|
} else {
|
|
3599
|
-
this.
|
|
3600
|
-
|
|
3601
|
-
|
|
5250
|
+
this.refreshAcceptedFileTransferLiveState({
|
|
5251
|
+
accountId,
|
|
5252
|
+
connId,
|
|
5253
|
+
clientId,
|
|
5254
|
+
context,
|
|
5255
|
+
});
|
|
3602
5256
|
}
|
|
3603
5257
|
|
|
3604
5258
|
st.status = 'aborted';
|
|
@@ -3688,9 +5342,12 @@ class BncrBridgeRuntime {
|
|
|
3688
5342
|
return;
|
|
3689
5343
|
}
|
|
3690
5344
|
} else {
|
|
3691
|
-
this.
|
|
3692
|
-
|
|
3693
|
-
|
|
5345
|
+
this.refreshAcceptedFileTransferLiveState({
|
|
5346
|
+
accountId,
|
|
5347
|
+
connId,
|
|
5348
|
+
clientId,
|
|
5349
|
+
context,
|
|
5350
|
+
});
|
|
3694
5351
|
}
|
|
3695
5352
|
|
|
3696
5353
|
if (st) {
|
|
@@ -3750,6 +5407,10 @@ class BncrBridgeRuntime {
|
|
|
3750
5407
|
};
|
|
3751
5408
|
|
|
3752
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".
|
|
3753
5414
|
await this.syncDebugFlag();
|
|
3754
5415
|
const parsed = parseBncrInboundParams(params);
|
|
3755
5416
|
const {
|
|
@@ -3772,6 +5433,9 @@ class BncrBridgeRuntime {
|
|
|
3772
5433
|
} = parsed;
|
|
3773
5434
|
const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
|
|
3774
5435
|
const clientId = asString((params as any)?.clientId || '').trim() || undefined;
|
|
5436
|
+
const outboundReady = (params as any)?.outboundReady === true;
|
|
5437
|
+
const preferredForOutbound = (params as any)?.preferredForOutbound === true;
|
|
5438
|
+
const inboundOnly = (params as any)?.inboundOnly === true;
|
|
3775
5439
|
if (
|
|
3776
5440
|
this.shouldIgnoreStaleEvent({
|
|
3777
5441
|
kind: 'inbound',
|
|
@@ -3781,91 +5445,69 @@ class BncrBridgeRuntime {
|
|
|
3781
5445
|
clientId,
|
|
3782
5446
|
})
|
|
3783
5447
|
) {
|
|
3784
|
-
respond(
|
|
3785
|
-
|
|
3786
|
-
|
|
3787
|
-
|
|
3788
|
-
|
|
3789
|
-
|
|
3790
|
-
|
|
5448
|
+
respond(
|
|
5449
|
+
true,
|
|
5450
|
+
buildInboundResponsePayload({
|
|
5451
|
+
kind: 'stale-ignored',
|
|
5452
|
+
accountId,
|
|
5453
|
+
msgId: msgId ?? null,
|
|
5454
|
+
}),
|
|
5455
|
+
);
|
|
3791
5456
|
return;
|
|
3792
5457
|
}
|
|
3793
|
-
this.
|
|
3794
|
-
|
|
3795
|
-
|
|
5458
|
+
this.refreshLiveConnectionState({
|
|
5459
|
+
accountId,
|
|
5460
|
+
connId,
|
|
5461
|
+
clientId,
|
|
5462
|
+
outboundReady,
|
|
5463
|
+
preferredForOutbound,
|
|
5464
|
+
inboundOnly,
|
|
5465
|
+
context,
|
|
5466
|
+
});
|
|
3796
5467
|
this.logInfo(
|
|
3797
5468
|
'inbound',
|
|
3798
|
-
`lifecycle ${JSON.stringify(
|
|
3799
|
-
|
|
3800
|
-
|
|
3801
|
-
|
|
3802
|
-
|
|
3803
|
-
|
|
3804
|
-
|
|
3805
|
-
|
|
3806
|
-
|
|
3807
|
-
|
|
3808
|
-
.
|
|
3809
|
-
.
|
|
3810
|
-
|
|
3811
|
-
|
|
3812
|
-
|
|
3813
|
-
|
|
3814
|
-
|
|
3815
|
-
|
|
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
|
+
)}`,
|
|
3816
5492
|
{ debugOnly: true },
|
|
3817
5493
|
);
|
|
3818
5494
|
this.lastInboundAtGlobal = now();
|
|
3819
5495
|
this.incrementCounter(this.inboundEventsByAccount, accountId);
|
|
3820
5496
|
|
|
3821
|
-
if (!platform || (!userId && !groupId)) {
|
|
3822
|
-
respond(false, { error: 'platform/groupId/userId required' });
|
|
3823
|
-
return;
|
|
3824
|
-
}
|
|
3825
|
-
if (this.markInboundDedupSeen(dedupKey)) {
|
|
3826
|
-
respond(true, {
|
|
3827
|
-
accepted: true,
|
|
3828
|
-
duplicated: true,
|
|
3829
|
-
accountId,
|
|
3830
|
-
msgId: msgId ?? null,
|
|
3831
|
-
});
|
|
3832
|
-
return;
|
|
3833
|
-
}
|
|
3834
|
-
|
|
3835
5497
|
const cfg = this.api.runtime.config.current();
|
|
3836
|
-
const gate = checkBncrMessageGate({
|
|
3837
|
-
parsed,
|
|
3838
|
-
cfg,
|
|
3839
|
-
account: resolveAccount(cfg, accountId),
|
|
3840
|
-
});
|
|
3841
|
-
if (!gate.allowed) {
|
|
3842
|
-
respond(true, {
|
|
3843
|
-
accepted: false,
|
|
3844
|
-
accountId,
|
|
3845
|
-
msgId: msgId ?? null,
|
|
3846
|
-
reason: gate.reason,
|
|
3847
|
-
});
|
|
3848
|
-
return;
|
|
3849
|
-
}
|
|
3850
|
-
|
|
3851
5498
|
const canonicalAgentId = this.ensureCanonicalAgentId({
|
|
3852
5499
|
cfg,
|
|
3853
5500
|
accountId,
|
|
3854
5501
|
peer,
|
|
3855
5502
|
channelId: CHANNEL_ID,
|
|
3856
5503
|
});
|
|
3857
|
-
const
|
|
3858
|
-
|
|
3859
|
-
|
|
3860
|
-
|
|
3861
|
-
|
|
3862
|
-
|
|
3863
|
-
const
|
|
3864
|
-
normalizeInboundSessionKey(sessionKeyfromroute, route, canonicalAgentId) ||
|
|
3865
|
-
resolvedRoute.sessionKey;
|
|
3866
|
-
const taskSessionKey = withTaskSessionKey(baseSessionKey, extracted.taskKey);
|
|
3867
|
-
const sessionKey = taskSessionKey || baseSessionKey;
|
|
3868
|
-
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;
|
|
3869
5511
|
this.logInfo(
|
|
3870
5512
|
'inbound',
|
|
3871
5513
|
JSON.stringify({
|
|
@@ -3878,7 +5520,7 @@ class BncrBridgeRuntime {
|
|
|
3878
5520
|
msgType,
|
|
3879
5521
|
textLen: inboundText.length,
|
|
3880
5522
|
textPreview: inboundText.slice(0, 120),
|
|
3881
|
-
hasMedia
|
|
5523
|
+
hasMedia,
|
|
3882
5524
|
}),
|
|
3883
5525
|
{ debugOnly: true },
|
|
3884
5526
|
);
|
|
@@ -3887,17 +5529,24 @@ class BncrBridgeRuntime {
|
|
|
3887
5529
|
route,
|
|
3888
5530
|
msgType,
|
|
3889
5531
|
text: inboundText,
|
|
3890
|
-
hasMedia
|
|
5532
|
+
hasMedia,
|
|
3891
5533
|
});
|
|
3892
5534
|
|
|
3893
|
-
respond(
|
|
3894
|
-
|
|
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({
|
|
3895
5546
|
accountId,
|
|
3896
|
-
|
|
3897
|
-
|
|
3898
|
-
taskKey: extracted.taskKey ?? null,
|
|
5547
|
+
trigger: OUTBOUND_FLUSH_TRIGGER.INBOUND,
|
|
5548
|
+
reason: OUTBOUND_FLUSH_REASON.INBOUND_ACCEPTED,
|
|
3899
5549
|
});
|
|
3900
|
-
this.flushPushQueue(accountId);
|
|
3901
5550
|
|
|
3902
5551
|
void dispatchBncrInbound({
|
|
3903
5552
|
api: this.api,
|
|
@@ -3937,30 +5586,37 @@ class BncrBridgeRuntime {
|
|
|
3937
5586
|
this.lastOutboundByAccount.get(accountId) ||
|
|
3938
5587
|
previous?.lastEventAt ||
|
|
3939
5588
|
null;
|
|
3940
|
-
|
|
5589
|
+
const healthSig = JSON.stringify({
|
|
5590
|
+
bridge: this.bridgeId,
|
|
5591
|
+
accountId,
|
|
5592
|
+
connected,
|
|
5593
|
+
onlineByConn,
|
|
5594
|
+
recentInboundReachable,
|
|
5595
|
+
activeConnectionKey: this.activeConnectionByAccount.get(accountId) || null,
|
|
5596
|
+
activeConnections: Array.from(this.connections.values())
|
|
5597
|
+
.filter((c) => c.accountId === accountId)
|
|
5598
|
+
.map((c) => ({
|
|
5599
|
+
connId: c.connId,
|
|
5600
|
+
clientId: c.clientId,
|
|
5601
|
+
inboundOnly: c.inboundOnly === true,
|
|
5602
|
+
outboundReady: c.outboundReady === true,
|
|
5603
|
+
preferredForOutbound: c.preferredForOutbound === true,
|
|
5604
|
+
})),
|
|
5605
|
+
});
|
|
5606
|
+
const conns = Array.from(this.connections.values()).filter((c) => c.accountId === accountId).length;
|
|
5607
|
+
this.logInfoDedup(
|
|
3941
5608
|
'health',
|
|
3942
|
-
`status-tick ${
|
|
3943
|
-
|
|
3944
|
-
accountId
|
|
3945
|
-
|
|
3946
|
-
|
|
3947
|
-
recentInboundReachable,
|
|
3948
|
-
lastActivityAt: this.lastActivityByAccount.get(accountId) || null,
|
|
3949
|
-
lastInboundAt: this.lastInboundByAccount.get(accountId) || null,
|
|
3950
|
-
lastOutboundAt: this.lastOutboundByAccount.get(accountId) || null,
|
|
3951
|
-
chosenLastEventAt: lastActAt,
|
|
3952
|
-
activeConnectionKey: this.activeConnectionByAccount.get(accountId) || null,
|
|
3953
|
-
activeConnections: Array.from(this.connections.values())
|
|
3954
|
-
.filter((c) => c.accountId === accountId)
|
|
3955
|
-
.map((c) => ({
|
|
3956
|
-
connId: c.connId,
|
|
3957
|
-
clientId: c.clientId,
|
|
3958
|
-
connectedAt: c.connectedAt,
|
|
3959
|
-
lastSeenAt: c.lastSeenAt,
|
|
3960
|
-
})),
|
|
3961
|
-
})}`,
|
|
3962
|
-
{ debugOnly: true },
|
|
5609
|
+
`status-tick ${accountId}|changed|${connected ? 'linked' : 'configured'}|onlineByConn=${onlineByConn}|recentInboundReachable=${recentInboundReachable}|conns=${conns}`,
|
|
5610
|
+
{
|
|
5611
|
+
key: `health-status-tick:${accountId}`,
|
|
5612
|
+
sig: healthSig,
|
|
5613
|
+
},
|
|
3963
5614
|
);
|
|
5615
|
+
this.logInfoDedup('health', `status-tick ${healthSig}`, {
|
|
5616
|
+
key: `health-status-tick-debug:${accountId}`,
|
|
5617
|
+
sig: healthSig,
|
|
5618
|
+
debugOnly: true,
|
|
5619
|
+
});
|
|
3964
5620
|
|
|
3965
5621
|
ctx.setStatus?.({
|
|
3966
5622
|
...previous,
|
|
@@ -3996,6 +5652,7 @@ class BncrBridgeRuntime {
|
|
|
3996
5652
|
`status-worker finished ${JSON.stringify({ bridge: this.bridgeId, accountId, reason })}`,
|
|
3997
5653
|
{ debugOnly: true },
|
|
3998
5654
|
);
|
|
5655
|
+
this.logInfo('health', `status-worker finished ${accountId}|${reason}`);
|
|
3999
5656
|
resolve();
|
|
4000
5657
|
};
|
|
4001
5658
|
|
|
@@ -4027,38 +5684,72 @@ class BncrBridgeRuntime {
|
|
|
4027
5684
|
`status-stop ${JSON.stringify({ bridge: this.bridgeId, accountId, cleared })}`,
|
|
4028
5685
|
{ debugOnly: true },
|
|
4029
5686
|
);
|
|
5687
|
+
this.logInfo('health', `status-stop ${accountId}|cleared=${cleared}`);
|
|
4030
5688
|
};
|
|
4031
5689
|
|
|
4032
|
-
|
|
4033
|
-
|
|
4034
|
-
|
|
4035
|
-
|
|
4036
|
-
|
|
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
|
+
}) {
|
|
4037
5703
|
this.logInfo(
|
|
4038
5704
|
'outbound',
|
|
4039
|
-
`send-entry
|
|
4040
|
-
accountId,
|
|
4041
|
-
to,
|
|
4042
|
-
text:
|
|
4043
|
-
mediaUrl:
|
|
4044
|
-
|
|
4045
|
-
|
|
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 || ''),
|
|
4046
5715
|
rawCtx: {
|
|
4047
|
-
to: ctx?.to,
|
|
4048
|
-
accountId: ctx?.accountId,
|
|
4049
|
-
threadId: ctx?.threadId,
|
|
4050
|
-
replyToId: ctx?.replyToId,
|
|
5716
|
+
to: args.ctx?.to,
|
|
5717
|
+
accountId: args.ctx?.accountId,
|
|
5718
|
+
threadId: args.ctx?.threadId,
|
|
5719
|
+
replyToId: args.ctx?.replyToId,
|
|
4051
5720
|
},
|
|
4052
5721
|
})}`,
|
|
4053
5722
|
{ debugOnly: true },
|
|
4054
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
|
+
});
|
|
4055
5746
|
|
|
4056
5747
|
return sendBncrText({
|
|
4057
5748
|
channelId: CHANNEL_ID,
|
|
4058
5749
|
accountId,
|
|
4059
5750
|
to,
|
|
4060
5751
|
text: asString(ctx.text || ''),
|
|
4061
|
-
replyToId
|
|
5752
|
+
replyToId,
|
|
4062
5753
|
mediaLocalRoots: ctx.mediaLocalRoots,
|
|
4063
5754
|
resolveVerifiedTarget: (to, accountId) => this.resolveVerifiedTarget(to, accountId),
|
|
4064
5755
|
rememberSessionRoute: (sessionKey, accountId, route) =>
|
|
@@ -4074,28 +5765,21 @@ class BncrBridgeRuntime {
|
|
|
4074
5765
|
const to = asString(ctx.to || '').trim();
|
|
4075
5766
|
const asVoice = ctx?.asVoice === true;
|
|
4076
5767
|
const audioAsVoice = ctx?.audioAsVoice === true;
|
|
5768
|
+
const replyToId = this.resolveChannelSendReplyToId(ctx);
|
|
4077
5769
|
|
|
4078
|
-
this.
|
|
4079
|
-
'
|
|
4080
|
-
|
|
4081
|
-
|
|
4082
|
-
|
|
5770
|
+
this.logChannelSendEntry({
|
|
5771
|
+
kind: 'media',
|
|
5772
|
+
accountId,
|
|
5773
|
+
to,
|
|
5774
|
+
ctx,
|
|
5775
|
+
payload: {
|
|
4083
5776
|
text: asString(ctx?.text || ''),
|
|
4084
5777
|
mediaUrl: asString(ctx?.mediaUrl || ''),
|
|
4085
5778
|
mediaUrls: Array.isArray(ctx?.mediaUrls) ? ctx.mediaUrls : undefined,
|
|
4086
5779
|
asVoice,
|
|
4087
5780
|
audioAsVoice,
|
|
4088
|
-
|
|
4089
|
-
|
|
4090
|
-
rawCtx: {
|
|
4091
|
-
to: ctx?.to,
|
|
4092
|
-
accountId: ctx?.accountId,
|
|
4093
|
-
threadId: ctx?.threadId,
|
|
4094
|
-
replyToId: ctx?.replyToId,
|
|
4095
|
-
},
|
|
4096
|
-
})}`,
|
|
4097
|
-
{ debugOnly: true },
|
|
4098
|
-
);
|
|
5781
|
+
},
|
|
5782
|
+
});
|
|
4099
5783
|
|
|
4100
5784
|
return sendBncrMedia({
|
|
4101
5785
|
channelId: CHANNEL_ID,
|
|
@@ -4103,9 +5787,10 @@ class BncrBridgeRuntime {
|
|
|
4103
5787
|
to,
|
|
4104
5788
|
text: asString(ctx.text || ''),
|
|
4105
5789
|
mediaUrl: asString(ctx.mediaUrl || ''),
|
|
5790
|
+
mediaUrls: Array.isArray(ctx?.mediaUrls) ? ctx.mediaUrls : undefined,
|
|
4106
5791
|
asVoice,
|
|
4107
5792
|
audioAsVoice,
|
|
4108
|
-
replyToId
|
|
5793
|
+
replyToId,
|
|
4109
5794
|
mediaLocalRoots: ctx.mediaLocalRoots,
|
|
4110
5795
|
resolveVerifiedTarget: (to, accountId) => this.resolveVerifiedTarget(to, accountId),
|
|
4111
5796
|
rememberSessionRoute: (sessionKey, accountId, route) =>
|
|
@@ -4375,53 +6060,13 @@ export function createBncrChannelPlugin(getBridge: () => BncrBridgeRuntime) {
|
|
|
4375
6060
|
buildAccountSnapshot: async ({ account, runtime }: any) => {
|
|
4376
6061
|
const runtimeBridge = getBridge();
|
|
4377
6062
|
const rt = runtime || runtimeBridge.getAccountRuntimeSnapshot(account?.accountId);
|
|
4378
|
-
|
|
4379
|
-
|
|
4380
|
-
|
|
4381
|
-
const deadLetter = Number(rt?.deadLetter ?? meta.deadLetter ?? 0);
|
|
4382
|
-
const lastSessionKey = rt?.lastSessionKey ?? meta.lastSessionKey ?? null;
|
|
4383
|
-
const lastSessionScope = rt?.lastSessionScope ?? meta.lastSessionScope ?? null;
|
|
4384
|
-
const lastSessionAt = rt?.lastSessionAt ?? meta.lastSessionAt ?? null;
|
|
4385
|
-
const lastSessionAgo = rt?.lastSessionAgo ?? meta.lastSessionAgo ?? '-';
|
|
4386
|
-
const lastActivityAt = rt?.lastActivityAt ?? meta.lastActivityAt ?? null;
|
|
4387
|
-
const lastActivityAgo = rt?.lastActivityAgo ?? meta.lastActivityAgo ?? '-';
|
|
4388
|
-
const lastInboundAt = rt?.lastInboundAt ?? meta.lastInboundAt ?? null;
|
|
4389
|
-
const lastInboundAgo = rt?.lastInboundAgo ?? meta.lastInboundAgo ?? '-';
|
|
4390
|
-
const lastOutboundAt = rt?.lastOutboundAt ?? meta.lastOutboundAt ?? null;
|
|
4391
|
-
const lastOutboundAgo = rt?.lastOutboundAgo ?? meta.lastOutboundAgo ?? '-';
|
|
4392
|
-
const diagnostics = rt?.diagnostics ?? meta.diagnostics ?? null;
|
|
4393
|
-
// 右侧状态字段统一:离线时也显示 Status(避免出现 configured 文案)
|
|
4394
|
-
const normalizedMode = rt?.mode === 'linked' ? 'linked' : 'Status';
|
|
4395
|
-
|
|
4396
|
-
const displayName = resolveDefaultDisplayName(account?.name, account?.accountId);
|
|
4397
|
-
|
|
4398
|
-
return {
|
|
4399
|
-
accountId: account.accountId,
|
|
4400
|
-
// default 名不可隐藏时,统一展示稳定默认值
|
|
4401
|
-
name: displayName,
|
|
4402
|
-
enabled: account.enabled !== false,
|
|
4403
|
-
configured: true,
|
|
4404
|
-
linked: Boolean(rt?.connected),
|
|
4405
|
-
running: rt?.running ?? false,
|
|
4406
|
-
connected: rt?.connected ?? false,
|
|
4407
|
-
lastEventAt: rt?.lastEventAt ?? null,
|
|
4408
|
-
lastError: rt?.lastError ?? null,
|
|
4409
|
-
mode: normalizedMode,
|
|
4410
|
-
pending,
|
|
4411
|
-
deadLetter,
|
|
6063
|
+
return buildAccountStatusSnapshot({
|
|
6064
|
+
account,
|
|
6065
|
+
runtime: rt,
|
|
4412
6066
|
healthSummary: runtimeBridge.getStatusHeadline(account?.accountId),
|
|
4413
|
-
|
|
4414
|
-
|
|
4415
|
-
|
|
4416
|
-
lastSessionAgo,
|
|
4417
|
-
lastActivityAt,
|
|
4418
|
-
lastActivityAgo,
|
|
4419
|
-
lastInboundAt,
|
|
4420
|
-
lastInboundAgo,
|
|
4421
|
-
lastOutboundAt,
|
|
4422
|
-
lastOutboundAgo,
|
|
4423
|
-
diagnostics,
|
|
4424
|
-
};
|
|
6067
|
+
// default 名不可隐藏时,统一展示稳定默认值
|
|
6068
|
+
displayName: resolveDefaultDisplayName(account?.name, account?.accountId),
|
|
6069
|
+
});
|
|
4425
6070
|
},
|
|
4426
6071
|
resolveAccountState: ({ enabled, configured, account, cfg, runtime }: any) => {
|
|
4427
6072
|
if (!enabled) return 'disabled';
|