@xmoxmo/bncr 0.2.4 → 0.2.5

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