@xmoxmo/bncr 0.2.7 → 0.2.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/channel.ts CHANGED
@@ -12,7 +12,6 @@ import {
12
12
  listAccountIds,
13
13
  normalizeAccountId,
14
14
  resolveAccount,
15
- resolveDefaultDisplayName,
16
15
  } from './core/accounts.ts';
17
16
  import { BncrConfigSchema } from './core/config-schema.ts';
18
17
  import {
@@ -67,6 +66,13 @@ import { buildDownlinkHealth as buildDownlinkHealthFromRuntime } from './core/do
67
66
  import { buildExtendedDiagnostics as buildExtendedDiagnosticsFromRuntime } from './core/extended-diagnostics.ts';
68
67
  import { observeLeaseState, matchesTransferOwner as matchesTransferOwnerFromRuntime } from './core/lease-state.ts';
69
68
  import { emitBncrLog, emitBncrLogLine } from './core/logging.ts';
69
+ import { buildFileAckKey } from './core/file-ack.ts';
70
+ import {
71
+ buildFileTransferAbortPayload,
72
+ buildFileTransferChunkPayload,
73
+ buildFileTransferCompletePayload,
74
+ buildFileTransferInitPayload,
75
+ } from './core/file-transfer-payloads.ts';
70
76
  import { resolveBncrChannelPolicy, resolveBncrConfigWarnings } from './core/policy.ts';
71
77
  import {
72
78
  getOpenClawRuntimeConfig,
@@ -79,23 +85,21 @@ import {
79
85
  } from './openclaw/media-runtime.ts';
80
86
  import { resolveOpenClawAgentRoute } from './openclaw/routing-runtime.ts';
81
87
  import {
82
- applyOpenClawAccountNameToChannelSection,
83
- createOpenClawDefaultChannelRuntimeState,
84
88
  extractOpenClawToolSend,
85
89
  openClawJsonResult,
86
90
  readOpenClawBooleanParam,
87
91
  readOpenClawJsonFileWithFallback,
88
92
  readOpenClawStringParam,
89
- setOpenClawAccountEnabledInConfigSection,
90
93
  writeOpenClawJsonFileAtomically,
91
94
  } from './openclaw/sdk-helpers.ts';
92
95
  import {
96
+ appendBoundedRegisterTrace,
97
+ buildRegisterDriftSnapshot,
98
+ buildRegisterTraceEntry,
93
99
  buildRegisterTraceSummary as buildRegisterTraceSummaryFromEntries,
94
- classifyRegisterTrace as classifyRegisterTraceFromStack,
95
100
  } from './core/register-trace.ts';
96
101
  import {
97
102
  buildAccountRuntimeSnapshot,
98
- buildAccountStatusSnapshot,
99
103
  buildIntegratedDiagnostics as buildIntegratedDiagnosticsFromRuntime,
100
104
  buildStatusHeadlineFromRuntime,
101
105
  buildStatusMetaFromRuntime,
@@ -103,11 +107,9 @@ import {
103
107
  import {
104
108
  buildCanonicalBncrSessionKey,
105
109
  formatDisplayScope,
106
- formatTargetDisplay,
107
110
  isLowerHex,
108
111
  normalizeInboundSessionKey,
109
112
  normalizeStoredSessionKey,
110
- parseExplicitTarget,
111
113
  parseRouteFromDisplayScope,
112
114
  parseRouteFromHexScope,
113
115
  parseRouteFromScope,
@@ -321,11 +323,47 @@ import {
321
323
  } from './messaging/outbound/retry-policy.ts';
322
324
  import { sendBncrMedia, sendBncrText } from './messaging/outbound/send.ts';
323
325
  import { buildBncrDurableQueuedResult } from './messaging/outbound/durable-queue-adapter.ts';
324
- import { resolveBncrOutboundSessionRoute } from './messaging/outbound/session-route.ts';
326
+ import { BNCR_CHANNEL_CAPABILITIES } from './plugin/capabilities.ts';
327
+ import { BNCR_CONFIG_SURFACE } from './plugin/config.ts';
328
+ import { createBncrGatewayRuntime } from './plugin/gateway-runtime.ts';
329
+ import { BNCR_GATEWAY_METHODS } from './plugin/gateway-methods.ts';
330
+ import { BNCR_CHANNEL_META } from './plugin/meta.ts';
331
+ import { createBncrMessagingSurface } from './plugin/messaging.ts';
332
+ import { createBncrMessageSend } from './plugin/message-send.ts';
333
+ import { BNCR_MESSAGE_RECEIVE_POLICY } from './plugin/message-policy.ts';
334
+ import { createBncrOutboundRuntime } from './plugin/outbound.ts';
335
+ import { BNCR_SETUP_SURFACE } from './plugin/setup.ts';
336
+ import { createBncrStatusSurface } from './plugin/status.ts';
337
+ import {
338
+ clearAllBncrStatusWorkers,
339
+ clearBncrStatusWorker,
340
+ startBncrStatusWorker,
341
+ stopBncrStatusWorker,
342
+ type ChannelAccountWorkerHandle,
343
+ } from './runtime/status-worker.ts';
344
+ import {
345
+ buildBncrRuntimeAckStrategy,
346
+ computeBncrRecommendedAckTimeoutMs,
347
+ computeBncrRecommendedAckTimeoutReason,
348
+ } from './runtime/outbound-ack-timeout.ts';
349
+ import {
350
+ buildBncrRuntimeFlags,
351
+ buildBncrRuntimeStatusInput,
352
+ resolveBncrOutboundAckRequired,
353
+ } from './runtime/outbound-flags.ts';
354
+ import { buildRuntimeStatusSnapshots } from './runtime/status-snapshots.ts';
325
355
  import {
326
- looksLikeBncrExplicitTarget,
327
- resolveBncrOutboundTarget,
328
- } from './messaging/outbound/target-resolver.ts';
356
+ applyBncrPushFailureDecisionToEntry,
357
+ applyBncrRetryRerouteDecisionToEntry,
358
+ buildBncrAckOkTelemetryPatch,
359
+ buildBncrAckRetryEntryPatch,
360
+ buildBncrOutboxFailureEntryPatch,
361
+ buildBncrOutboxPushSuccessEntryPatch,
362
+ } from './runtime/outbox-transitions.ts';
363
+ import {
364
+ pruneLogDedupeState as pruneLogDedupeStateFromRuntime,
365
+ shouldEmitDedupLog as shouldEmitDedupLogFromRuntime,
366
+ } from './runtime/log-dedupe.ts';
329
367
  const BRIDGE_VERSION = 2;
330
368
  const BNCR_PUSH_EVENT = 'plugin.bncr.push';
331
369
  const BNCR_FILE_INIT_EVENT = 'plugin.bncr.file.init';
@@ -351,8 +389,6 @@ const ADAPTIVE_ACK_TIMEOUT_LOG_THROTTLE_MS = 5 * 60 * 1000;
351
389
  const OUTBOUND_READY_TTL_MS = 30_000;
352
390
  const PREFERRED_OUTBOUND_TTL_MS = 12_000;
353
391
  const FILE_FORCE_CHUNK = true; // 统一走 WS 分块,保留 base64 仅作兜底
354
- const LOG_DEDUPE_STATE_TTL_MS = 10 * 60 * 1000;
355
- const LOG_DEDUPE_STATE_MAX_ENTRIES = 1_000;
356
392
  const FILE_INLINE_THRESHOLD = 5 * 1024 * 1024; // fallback 阈值(仅 FILE_FORCE_CHUNK=false 时生效)
357
393
  const FILE_CHUNK_SIZE = 256 * 1024; // 256KB
358
394
  const INBOUND_FILE_TRANSFER_MAX_BYTES = 50 * 1024 * 1024;
@@ -389,12 +425,6 @@ type FileSendTransferState = {
389
425
  error?: string;
390
426
  };
391
427
 
392
- type ChannelAccountWorkerHandle = {
393
- timer: NodeJS.Timeout;
394
- finish: (reason: string) => void;
395
- cleanupAbortListener?: () => void;
396
- };
397
-
398
428
  type FileRecvTransferState = {
399
429
  transferId: string;
400
430
  accountId: string;
@@ -837,27 +867,17 @@ class BncrBridgeRuntime {
837
867
  }
838
868
 
839
869
  private pruneLogDedupeState(currentTime = now()) {
840
- for (const [key, entry] of this.logDedupeState.entries()) {
841
- if (currentTime - entry.at > LOG_DEDUPE_STATE_TTL_MS) {
842
- this.logDedupeState.delete(key);
843
- }
844
- }
845
-
846
- while (this.logDedupeState.size > LOG_DEDUPE_STATE_MAX_ENTRIES) {
847
- const oldestKey = this.logDedupeState.keys().next().value;
848
- if (!oldestKey) break;
849
- this.logDedupeState.delete(oldestKey);
850
- }
870
+ pruneLogDedupeStateFromRuntime(this.logDedupeState, currentTime);
851
871
  }
852
872
 
853
873
  private shouldEmitDedupLog(key: string, sig: string, windowMs = 5 * 60 * 1000) {
854
- const t = now();
855
- this.pruneLogDedupeState(t);
856
- const prev = this.logDedupeState.get(key) || null;
857
- if (prev && prev.sig === sig && t - prev.at < windowMs) return false;
858
- this.logDedupeState.set(key, { at: t, sig });
859
- this.pruneLogDedupeState(t);
860
- return true;
874
+ return shouldEmitDedupLogFromRuntime({
875
+ state: this.logDedupeState,
876
+ key,
877
+ sig,
878
+ nowMs: now(),
879
+ windowMs,
880
+ });
861
881
  }
862
882
 
863
883
  private logInfoDedup(
@@ -954,39 +974,64 @@ class BncrBridgeRuntime {
954
974
  );
955
975
  }
956
976
 
977
+ private buildStatusWorkerRuntime() {
978
+ return {
979
+ workers: this.channelAccountWorkers,
980
+ bridgeId: this.bridgeId,
981
+ hooks: {
982
+ isOnline: (accountId: string) => this.isOnline(accountId),
983
+ hasRecentInboundReachability: (accountId: string) =>
984
+ this.hasRecentInboundReachability(accountId),
985
+ getLastActivityAt: (accountId: string, previous: Record<string, any>) =>
986
+ this.lastActivityByAccount.get(accountId) ||
987
+ this.lastInboundByAccount.get(accountId) ||
988
+ this.lastOutboundByAccount.get(accountId) ||
989
+ previous?.lastEventAt ||
990
+ null,
991
+ getActiveConnectionKey: (accountId: string) =>
992
+ this.activeConnectionByAccount.get(accountId) || null,
993
+ getActiveConnections: (accountId: string) =>
994
+ Array.from(this.connections.values())
995
+ .filter((c) => c.accountId === accountId)
996
+ .map((c) => ({
997
+ connId: c.connId,
998
+ clientId: c.clientId,
999
+ inboundOnly: c.inboundOnly === true,
1000
+ outboundReady: c.outboundReady === true,
1001
+ preferredForOutbound: c.preferredForOutbound === true,
1002
+ })),
1003
+ buildStatusMeta: (accountId: string) => this.buildStatusMeta(accountId),
1004
+ logInfo: (scope: string | undefined, message: string, options?: { debugOnly?: boolean }) =>
1005
+ this.logInfo(scope, message, options),
1006
+ logInfoDedup: (
1007
+ scope: string | undefined,
1008
+ message: string,
1009
+ options: { key: string; sig: string; debugOnly?: boolean; windowMs?: number },
1010
+ ) => this.logInfoDedup(scope, message, options),
1011
+ },
1012
+ };
1013
+ }
1014
+
957
1015
  private clearChannelAccountWorker(accountId: string, reason: string) {
958
- const worker = this.channelAccountWorkers.get(accountId);
959
- if (!worker) return false;
960
- worker.finish(reason);
961
- this.logInfo(
962
- 'health',
963
- `status-worker cleared ${JSON.stringify({ bridge: this.bridgeId, accountId, reason })}`,
964
- { debugOnly: true },
965
- );
966
- return true;
1016
+ return clearBncrStatusWorker(this.buildStatusWorkerRuntime(), accountId, reason);
967
1017
  }
968
1018
 
969
1019
  private clearAllChannelAccountWorkers(reason: string) {
970
- for (const accountId of Array.from(this.channelAccountWorkers.keys())) {
971
- this.clearChannelAccountWorker(accountId, reason);
972
- }
1020
+ clearAllBncrStatusWorkers(this.buildStatusWorkerRuntime(), reason);
973
1021
  }
974
1022
 
975
1023
  private captureDriftSnapshot(
976
1024
  summary: ReturnType<BncrBridgeRuntime['buildRegisterTraceSummary']>,
977
1025
  ) {
978
- this.lastDriftSnapshot = {
1026
+ this.lastDriftSnapshot = buildRegisterDriftSnapshot({
979
1027
  capturedAt: now(),
980
1028
  registerCount: this.registerCount,
981
1029
  apiGeneration: this.apiGeneration,
982
- postWarmupRegisterCount: summary.postWarmupRegisterCount,
1030
+ summary,
983
1031
  apiInstanceId: this.lastApiInstanceId,
984
1032
  registryFingerprint: this.lastRegistryFingerprint,
985
- dominantBucket: summary.dominantBucket,
986
- sourceBuckets: { ...summary.sourceBuckets },
987
- traceWindowSize: this.registerTraceRecent.length,
988
- traceRecent: this.registerTraceRecent.map((trace) => ({ ...trace })),
989
- };
1033
+ traceRecent: this.registerTraceRecent,
1034
+ });
990
1035
  this.scheduleSave();
991
1036
  }
992
1037
 
@@ -1026,9 +1071,7 @@ class BncrBridgeRuntime {
1026
1071
  .map((line) => line.trim())
1027
1072
  .filter(Boolean)
1028
1073
  .join(' <- ');
1029
- const stackBucket = classifyRegisterTraceFromStack(stack);
1030
-
1031
- const trace = {
1074
+ const trace = buildRegisterTraceEntry({
1032
1075
  ts,
1033
1076
  bridgeId: this.bridgeId,
1034
1077
  gatewayPid: this.gatewayPid,
@@ -1040,11 +1083,8 @@ class BncrBridgeRuntime {
1040
1083
  source: this.pluginSource,
1041
1084
  pluginVersion: this.pluginVersion,
1042
1085
  stack,
1043
- stackBucket,
1044
- };
1045
- this.registerTraceRecent.push(trace);
1046
- if (this.registerTraceRecent.length > 12)
1047
- this.registerTraceRecent.splice(0, this.registerTraceRecent.length - 12);
1086
+ });
1087
+ appendBoundedRegisterTrace(this.registerTraceRecent, trace, 12);
1048
1088
 
1049
1089
  const summary = this.buildRegisterTraceSummary();
1050
1090
  if (summary.postWarmupRegisterCount > 0) this.captureDriftSnapshot(summary);
@@ -1147,11 +1187,29 @@ class BncrBridgeRuntime {
1147
1187
  return matchesTransferOwnerFromRuntime(params);
1148
1188
  }
1149
1189
 
1190
+ private buildRuntimeSurfaceDiagnostics() {
1191
+ const channelRuntime = (this.api as any)?.runtime?.channel;
1192
+ const surfaces = {
1193
+ inbound: Boolean(channelRuntime?.inbound),
1194
+ media: Boolean(channelRuntime?.media),
1195
+ reply: Boolean(channelRuntime?.reply),
1196
+ routing: Boolean(channelRuntime?.routing),
1197
+ session: Boolean(channelRuntime?.session),
1198
+ };
1199
+ return {
1200
+ channel: surfaces,
1201
+ missing: Object.entries(surfaces)
1202
+ .filter(([, present]) => !present)
1203
+ .map(([name]) => name),
1204
+ };
1205
+ }
1206
+
1150
1207
  private buildExtendedDiagnostics(accountId: string) {
1151
1208
  const acc = normalizeAccountId(accountId);
1152
1209
  const diagnostics = this.buildIntegratedDiagnostics(acc) as Record<string, any>;
1153
1210
  return buildExtendedDiagnosticsFromRuntime({
1154
1211
  diagnostics,
1212
+ runtimeSurface: this.buildRuntimeSurfaceDiagnostics(),
1155
1213
  register: {
1156
1214
  bridgeId: this.bridgeId,
1157
1215
  gatewayPid: this.gatewayPid,
@@ -2733,19 +2791,18 @@ class BncrBridgeRuntime {
2733
2791
  outboundReady: true,
2734
2792
  preferredForOutbound: true,
2735
2793
  });
2736
- const ackAt = now();
2794
+ const telemetryPatch = buildBncrAckOkTelemetryPatch({
2795
+ entry: args.entry,
2796
+ ackAt: now(),
2797
+ defaultAckTimeoutMs: PUSH_ACK_TIMEOUT_MS,
2798
+ });
2799
+ const { ackAt, ackQueueLatencyMs, ackPushLatencyMs, lateAccepted } = telemetryPatch;
2737
2800
  this.lastAckOkByAccount.set(args.accountId, ackAt);
2738
- const ackQueueLatencyMs = Math.max(0, ackAt - finiteNumberOr(args.entry.createdAt, ackAt));
2739
- const ackPushLatencyMs =
2740
- typeof args.entry.lastPushAt === 'number'
2741
- ? Math.max(0, ackAt - args.entry.lastPushAt)
2742
- : null;
2743
2801
  this.lastAckQueueLatencyMsByAccount.set(args.accountId, ackQueueLatencyMs);
2744
2802
  if (typeof ackPushLatencyMs === 'number') {
2745
2803
  this.lastAckPushLatencyMsByAccount.set(args.accountId, ackPushLatencyMs);
2746
2804
  }
2747
- const lateAccepted = args.entry.awaitingRetryPush === true;
2748
- if (lateAccepted) {
2805
+ if (telemetryPatch.shouldResetAdaptiveAckRecovery) {
2749
2806
  this.adaptiveAckRecoveryOkCountByAccount.set(args.accountId, 0);
2750
2807
  this.lateAckOkCountByAccount.set(
2751
2808
  args.accountId,
@@ -2758,7 +2815,7 @@ class BncrBridgeRuntime {
2758
2815
  }
2759
2816
  args.entry.awaitingRetryPush = false;
2760
2817
  args.entry.lastError = undefined;
2761
- } else if (typeof ackPushLatencyMs === 'number' && ackPushLatencyMs <= PUSH_ACK_TIMEOUT_MS) {
2818
+ } else if (telemetryPatch.shouldIncrementAdaptiveAckRecovery) {
2762
2819
  this.adaptiveAckRecoveryOkCountByAccount.set(
2763
2820
  args.accountId,
2764
2821
  this.getCounter(this.adaptiveAckRecoveryOkCountByAccount, args.accountId) + 1,
@@ -2800,16 +2857,18 @@ class BncrBridgeRuntime {
2800
2857
  clientId?: string;
2801
2858
  error: string;
2802
2859
  }) {
2803
- args.entry.nextAttemptAt = now() + 1_000;
2804
- args.entry.lastError = args.error;
2805
- args.entry.awaitingRetryPush = true;
2806
- this.outbox.set(args.messageId, args.entry);
2860
+ const nextEntry = buildBncrAckRetryEntryPatch({
2861
+ entry: args.entry,
2862
+ error: args.error,
2863
+ nextAttemptAt: now() + 1_000,
2864
+ });
2865
+ this.outbox.set(args.messageId, nextEntry);
2807
2866
  this.scheduleSave();
2808
2867
  this.logOutboxAckSummary('outbox ack retry', {
2809
2868
  messageId: args.messageId,
2810
2869
  connId: args.connId,
2811
2870
  clientId: args.clientId,
2812
- err: args.entry.lastError,
2871
+ err: nextEntry.lastError,
2813
2872
  });
2814
2873
  }
2815
2874
 
@@ -3028,8 +3087,12 @@ class BncrBridgeRuntime {
3028
3087
  entry: OutboxEntry;
3029
3088
  lastError: string;
3030
3089
  }) {
3031
- args.entry.lastError = args.lastError;
3032
- this.outbox.set(args.entry.messageId, args.entry);
3090
+ const nextEntry = buildBncrOutboxFailureEntryPatch({
3091
+ entry: args.entry,
3092
+ lastError: args.lastError,
3093
+ });
3094
+ Object.assign(args.entry, nextEntry);
3095
+ this.outbox.set(nextEntry.messageId, args.entry);
3033
3096
  }
3034
3097
 
3035
3098
  private recordOutboxPushFailure(args: {
@@ -3038,8 +3101,12 @@ class BncrBridgeRuntime {
3038
3101
  fallbackError: string;
3039
3102
  persist?: boolean;
3040
3103
  }) {
3041
- args.entry.lastError = asString((args.error as any)?.message || args.error || args.fallbackError);
3042
- this.outbox.set(args.entry.messageId, args.entry);
3104
+ const nextEntry = buildBncrOutboxFailureEntryPatch({
3105
+ entry: args.entry,
3106
+ lastError: asString((args.error as any)?.message || args.error || args.fallbackError),
3107
+ });
3108
+ Object.assign(args.entry, nextEntry);
3109
+ this.outbox.set(nextEntry.messageId, args.entry);
3043
3110
  if (args.persist) this.scheduleSave();
3044
3111
  }
3045
3112
 
@@ -3050,23 +3117,19 @@ class BncrBridgeRuntime {
3050
3117
  ownerClientId?: string;
3051
3118
  clearLastError?: boolean;
3052
3119
  }) {
3053
- const connIds = Array.from(args.connIds);
3054
- args.entry.lastPushAt = now();
3055
- args.entry.lastPushConnId =
3056
- args.ownerConnId || (connIds.length === 1 ? connIds[0] : undefined);
3057
- args.entry.lastPushClientId = args.ownerClientId;
3058
- args.entry.awaitingRetryPush = false;
3059
- if (!Array.isArray(args.entry.routeAttemptConnIds)) args.entry.routeAttemptConnIds = [];
3060
- if (
3061
- args.entry.lastPushConnId &&
3062
- !args.entry.routeAttemptConnIds.includes(args.entry.lastPushConnId)
3063
- ) {
3064
- args.entry.routeAttemptConnIds.push(args.entry.lastPushConnId);
3065
- }
3066
- if (args.clearLastError) args.entry.lastError = undefined;
3067
- this.outbox.set(args.entry.messageId, args.entry);
3068
- this.lastOutboundByAccount.set(args.entry.accountId, args.entry.lastPushAt);
3069
- this.markActivity(args.entry.accountId, args.entry.lastPushAt);
3120
+ const pushedAt = now();
3121
+ const nextEntry = buildBncrOutboxPushSuccessEntryPatch({
3122
+ entry: args.entry,
3123
+ connIds: args.connIds,
3124
+ pushedAt,
3125
+ ownerConnId: args.ownerConnId,
3126
+ ownerClientId: args.ownerClientId,
3127
+ clearLastError: args.clearLastError,
3128
+ });
3129
+ Object.assign(args.entry, nextEntry);
3130
+ this.outbox.set(nextEntry.messageId, args.entry);
3131
+ this.lastOutboundByAccount.set(nextEntry.accountId, pushedAt);
3132
+ this.markActivity(nextEntry.accountId, pushedAt);
3070
3133
  this.scheduleSave();
3071
3134
  }
3072
3135
 
@@ -3101,41 +3164,19 @@ class BncrBridgeRuntime {
3101
3164
  }
3102
3165
 
3103
3166
  private isOutboundAckRequired(accountId?: string) {
3104
- try {
3105
- const cfg = getOpenClawRuntimeConfig(this.api);
3106
- const channelCfg = (cfg as any)?.channels?.[CHANNEL_ID];
3107
- const accountCfg =
3108
- accountId && channelCfg?.accounts && typeof channelCfg.accounts === 'object'
3109
- ? (channelCfg.accounts as Record<string, any>)[normalizeAccountId(accountId)]
3110
- : null;
3111
- const scoped = accountCfg?.outboundRequireAck;
3112
- const global = channelCfg?.outboundRequireAck;
3113
- if (typeof scoped === 'boolean') return scoped;
3114
- if (typeof global === 'boolean') return global;
3115
- return true;
3116
- } catch {
3117
- return true;
3118
- }
3167
+ return resolveBncrOutboundAckRequired({ api: this.api, accountId });
3119
3168
  }
3120
3169
 
3121
3170
  private buildRuntimeFlags(accountId?: string) {
3122
- let ackPolicySource: 'channel' | 'default' = 'default';
3123
- try {
3124
- const cfg = getOpenClawRuntimeConfig(this.api);
3125
- const global = (cfg as any)?.channels?.[CHANNEL_ID]?.outboundRequireAck;
3126
- if (typeof global === 'boolean') ackPolicySource = 'channel';
3127
- } catch {
3128
- // keep default source
3129
- }
3130
- return {
3131
- outboundRequireAck: this.isOutboundAckRequired(accountId),
3132
- ackPolicySource,
3133
- messageAckTimeoutMs: this.resolveMessageAckTimeoutMs(accountId),
3171
+ return buildBncrRuntimeFlags({
3172
+ api: this.api,
3173
+ accountId,
3174
+ resolveMessageAckTimeoutMs: (acc?: string) => this.resolveMessageAckTimeoutMs(acc),
3134
3175
  adaptiveAckTimeoutEnabled: ADAPTIVE_ACK_TIMEOUT_DEFAULT_ENABLED,
3135
3176
  defaultMessageAckTimeoutMs: PUSH_ACK_TIMEOUT_MS,
3136
3177
  fileAckTimeoutMs: FILE_ACK_TIMEOUT_MS,
3137
3178
  debugVerbose: BNCR_DEBUG_VERBOSE,
3138
- };
3179
+ });
3139
3180
  }
3140
3181
 
3141
3182
  private async flushPushQueue(args?: {
@@ -3356,14 +3397,8 @@ class BncrBridgeRuntime {
3356
3397
  continue;
3357
3398
  }
3358
3399
 
3359
- entry.routeAttemptConnIds = decision.attemptedConnIds;
3360
- entry.fastReroutePending = decision.fastReroutePending;
3361
- entry.retryCount = decision.nextRetryCount;
3362
- entry.lastAttemptAt = decision.lastAttemptAt;
3363
- entry.nextAttemptAt = decision.nextAttemptAt;
3364
- entry.lastError = decision.lastError;
3365
- entry.routeAttemptRound = decision.routeAttemptRound;
3366
- this.outbox.set(entry.messageId, entry);
3400
+ const nextEntry = applyBncrRetryRerouteDecisionToEntry(entry, decision);
3401
+ this.outbox.set(entry.messageId, nextEntry);
3367
3402
  this.scheduleSave();
3368
3403
  if (requireAck) {
3369
3404
  this.lastAckTimeoutByAccount.set(acc, now());
@@ -3377,7 +3412,7 @@ class BncrBridgeRuntime {
3377
3412
  localNextDelay = updateMinOutboxDelay(localNextDelay, wait);
3378
3413
  this.logOutboxAckReroute({
3379
3414
  accountId: acc,
3380
- entry,
3415
+ entry: nextEntry,
3381
3416
  requireAck,
3382
3417
  currentConnId,
3383
3418
  availableConnIds,
@@ -3408,11 +3443,8 @@ class BncrBridgeRuntime {
3408
3443
  continue;
3409
3444
  }
3410
3445
 
3411
- entry.retryCount = decision.nextRetryCount;
3412
- entry.lastAttemptAt = decision.lastAttemptAt;
3413
- entry.nextAttemptAt = decision.nextAttemptAt;
3414
- entry.lastError = decision.lastError;
3415
- this.outbox.set(entry.messageId, entry);
3446
+ const nextEntry = applyBncrPushFailureDecisionToEntry(entry, decision);
3447
+ this.outbox.set(entry.messageId, nextEntry);
3416
3448
  this.scheduleSave();
3417
3449
 
3418
3450
  const wait = computeOutboxRetryWait(decision.nextAttemptAt, t);
@@ -3966,9 +3998,7 @@ class BncrBridgeRuntime {
3966
3998
  }
3967
3999
 
3968
4000
  private fileAckKey(transferId: string, stage: string, chunkIndex?: number): string {
3969
- const n = Number(chunkIndex);
3970
- const idx = Number.isInteger(n) && n >= 0 ? String(n) : '-';
3971
- return `${transferId}|${stage}|${idx}`;
4001
+ return buildFileAckKey({ transferId, stage, chunkIndex });
3972
4002
  }
3973
4003
 
3974
4004
  private fileAckOwnerInfo(transferId: string) {
@@ -4197,30 +4227,6 @@ class BncrBridgeRuntime {
4197
4227
  }
4198
4228
 
4199
4229
 
4200
- private buildRuntimeQueueSnapshot(accountId: string) {
4201
- const pending = Array.from(this.outbox.values()).filter((v) => v.accountId === accountId).length;
4202
- const deadLetter = this.deadLetter.filter((v) => v.accountId === accountId).length;
4203
- const sessionRoutesCount = Array.from(this.sessionRoutes.values()).filter(
4204
- (v) => v.accountId === accountId,
4205
- ).length;
4206
- return {
4207
- pending,
4208
- deadLetter,
4209
- sessionRoutesCount,
4210
- invalidOutboxSessionKeys: this.countInvalidOutboxSessionKeys(accountId),
4211
- legacyAccountResidue: this.countLegacyAccountResidue(accountId),
4212
- };
4213
- }
4214
-
4215
- private buildRuntimeEventCounters(accountId: string) {
4216
- return {
4217
- connectEvents: this.getCounter(this.connectEventsByAccount, accountId),
4218
- inboundEvents: this.getCounter(this.inboundEventsByAccount, accountId),
4219
- activityEvents: this.getCounter(this.activityEventsByAccount, accountId),
4220
- ackEvents: this.getCounter(this.ackEventsByAccount, accountId),
4221
- };
4222
- }
4223
-
4224
4230
  private computeRecommendedAckTimeoutReason(args: {
4225
4231
  lateAckOkCount: number;
4226
4232
  recentAckTimeoutCount: number;
@@ -4230,26 +4236,15 @@ class BncrBridgeRuntime {
4230
4236
  recommendedAckTimeoutMs?: number;
4231
4237
  nowMs?: number;
4232
4238
  }) {
4233
- if (args.recentAckTimeoutCount <= 0) return 'no-timeout-evidence';
4234
- if (args.lateAckOkCount <= 0) return 'no-late-ack-evidence';
4235
- if (typeof args.lastLateAckPushLatencyMs !== 'number') return 'missing-latency';
4236
- const lastLateAckOkAt = typeof args.lastLateAckOkAt === 'number' ? args.lastLateAckOkAt : null;
4237
- const nowMs = typeof args.nowMs === 'number' ? args.nowMs : now();
4238
- if (
4239
- typeof lastLateAckOkAt === 'number' &&
4240
- lastLateAckOkAt > 0 &&
4241
- nowMs - lastLateAckOkAt > ADAPTIVE_ACK_TIMEOUT_OBSERVATION_TTL_MS
4242
- ) {
4243
- return 'late-ack-expired';
4244
- }
4245
- if (
4246
- typeof args.adaptiveAckRecoveryOkCount === 'number' &&
4247
- args.adaptiveAckRecoveryOkCount >= ADAPTIVE_ACK_TIMEOUT_RECOVERY_OK_THRESHOLD
4248
- ) {
4249
- return 'recovered';
4250
- }
4251
- if (args.recommendedAckTimeoutMs === RECOMMENDED_ACK_TIMEOUT_MAX_MS) return 'capped-max';
4252
- return 'late-ack-observed';
4239
+ return computeBncrRecommendedAckTimeoutReason({
4240
+ ...args,
4241
+ nowMs: typeof args.nowMs === 'number' ? args.nowMs : now(),
4242
+ defaultAckTimeoutMs: PUSH_ACK_TIMEOUT_MS,
4243
+ minAckTimeoutMs: RECOMMENDED_ACK_TIMEOUT_MIN_MS,
4244
+ maxAckTimeoutMs: RECOMMENDED_ACK_TIMEOUT_MAX_MS,
4245
+ lateAckObservationTtlMs: ADAPTIVE_ACK_TIMEOUT_OBSERVATION_TTL_MS,
4246
+ recoveryOkThreshold: ADAPTIVE_ACK_TIMEOUT_RECOVERY_OK_THRESHOLD,
4247
+ });
4253
4248
  }
4254
4249
 
4255
4250
  private computeRecommendedAckTimeoutMs(args: {
@@ -4260,29 +4255,15 @@ class BncrBridgeRuntime {
4260
4255
  adaptiveAckRecoveryOkCount?: number;
4261
4256
  nowMs?: number;
4262
4257
  }) {
4263
- const lastLateAckOkAt = typeof args.lastLateAckOkAt === 'number' ? args.lastLateAckOkAt : null;
4264
- const nowMs = typeof args.nowMs === 'number' ? args.nowMs : now();
4265
- const lateAckExpired =
4266
- typeof lastLateAckOkAt === 'number' &&
4267
- lastLateAckOkAt > 0 &&
4268
- nowMs - lastLateAckOkAt > ADAPTIVE_ACK_TIMEOUT_OBSERVATION_TTL_MS;
4269
- const recovered =
4270
- typeof args.adaptiveAckRecoveryOkCount === 'number' &&
4271
- args.adaptiveAckRecoveryOkCount >= ADAPTIVE_ACK_TIMEOUT_RECOVERY_OK_THRESHOLD;
4272
- if (
4273
- args.lateAckOkCount <= 0 ||
4274
- args.recentAckTimeoutCount <= 0 ||
4275
- typeof args.lastLateAckPushLatencyMs !== 'number' ||
4276
- lateAckExpired ||
4277
- recovered
4278
- ) {
4279
- return PUSH_ACK_TIMEOUT_MS;
4280
- }
4281
- const recommended = Math.ceil(args.lastLateAckPushLatencyMs * 1.25);
4282
- return Math.min(
4283
- RECOMMENDED_ACK_TIMEOUT_MAX_MS,
4284
- Math.max(RECOMMENDED_ACK_TIMEOUT_MIN_MS, recommended),
4285
- );
4258
+ return computeBncrRecommendedAckTimeoutMs({
4259
+ ...args,
4260
+ nowMs: typeof args.nowMs === 'number' ? args.nowMs : now(),
4261
+ defaultAckTimeoutMs: PUSH_ACK_TIMEOUT_MS,
4262
+ minAckTimeoutMs: RECOMMENDED_ACK_TIMEOUT_MIN_MS,
4263
+ maxAckTimeoutMs: RECOMMENDED_ACK_TIMEOUT_MAX_MS,
4264
+ lateAckObservationTtlMs: ADAPTIVE_ACK_TIMEOUT_OBSERVATION_TTL_MS,
4265
+ recoveryOkThreshold: ADAPTIVE_ACK_TIMEOUT_RECOVERY_OK_THRESHOLD,
4266
+ });
4286
4267
  }
4287
4268
 
4288
4269
  private maybeLogAdaptiveAckTimeout(args: {
@@ -4412,44 +4393,41 @@ class BncrBridgeRuntime {
4412
4393
  }
4413
4394
 
4414
4395
  private buildRuntimeAckStrategy(ackObservability: Record<string, any>) {
4415
- const currentMs = finiteNumberOr(ackObservability.currentAckTimeoutMs, PUSH_ACK_TIMEOUT_MS);
4416
- const defaultMs = finiteNumberOr(ackObservability.defaultAckTimeoutMs, PUSH_ACK_TIMEOUT_MS);
4417
- const reason = asString(ackObservability.recommendedAckTimeoutReason || 'unknown') || 'unknown';
4418
- return {
4419
- mode: ackObservability.adaptiveAckTimeoutEnabled === true ? 'adaptive' : 'fixed',
4420
- currentMs,
4421
- defaultMs,
4422
- maxMs: RECOMMENDED_ACK_TIMEOUT_MAX_MS,
4423
- reason,
4424
- active: currentMs > defaultMs,
4425
- lastLateAckAgeMs: ackObservability.lastLateAckAgeMs ?? null,
4426
- lateAckObservationTtlMs: ackObservability.lateAckObservationTtlMs ?? null,
4427
- recovered: ackObservability.adaptiveAckRecovered === true,
4428
- };
4429
- }
4430
-
4431
- private buildRuntimeActivitySnapshot(accountId: string) {
4432
- return {
4433
- activeConnections: this.activeConnectionCount(accountId),
4434
- lastSession: this.lastSessionByAccount.get(accountId) || null,
4435
- lastActivityAt: this.lastActivityByAccount.get(accountId) || null,
4436
- lastInboundAt: this.lastInboundByAccount.get(accountId) || null,
4437
- lastOutboundAt: this.lastOutboundByAccount.get(accountId) || null,
4438
- };
4396
+ return buildBncrRuntimeAckStrategy({
4397
+ ackObservability,
4398
+ defaultAckTimeoutMs: PUSH_ACK_TIMEOUT_MS,
4399
+ maxAckTimeoutMs: RECOMMENDED_ACK_TIMEOUT_MAX_MS,
4400
+ });
4439
4401
  }
4440
4402
 
4441
4403
  private buildRuntimeStatusInput(accountId: string, overrides: { running?: boolean } = {}) {
4442
4404
  const acc = normalizeAccountId(accountId);
4443
- return {
4405
+ const snapshots = buildRuntimeStatusSnapshots({
4406
+ accountId: acc,
4407
+ outboxEntries: this.outbox.values(),
4408
+ deadLetterEntries: this.deadLetter,
4409
+ sessionRouteEntries: this.sessionRoutes.values(),
4410
+ countInvalidOutboxSessionKeys: (snapshotAccountId) =>
4411
+ this.countInvalidOutboxSessionKeys(snapshotAccountId),
4412
+ countLegacyAccountResidue: (snapshotAccountId) => this.countLegacyAccountResidue(snapshotAccountId),
4413
+ connectEventsByAccount: this.connectEventsByAccount,
4414
+ inboundEventsByAccount: this.inboundEventsByAccount,
4415
+ activityEventsByAccount: this.activityEventsByAccount,
4416
+ ackEventsByAccount: this.ackEventsByAccount,
4417
+ activeConnectionCount: (snapshotAccountId) => this.activeConnectionCount(snapshotAccountId),
4418
+ lastSessionByAccount: this.lastSessionByAccount,
4419
+ lastActivityByAccount: this.lastActivityByAccount,
4420
+ lastInboundByAccount: this.lastInboundByAccount,
4421
+ lastOutboundByAccount: this.lastOutboundByAccount,
4422
+ });
4423
+ return buildBncrRuntimeStatusInput({
4444
4424
  accountId: acc,
4445
4425
  connected: this.isOnline(acc),
4446
- ...this.buildRuntimeQueueSnapshot(acc),
4447
- ...this.buildRuntimeEventCounters(acc),
4448
- ...this.buildRuntimeActivitySnapshot(acc),
4426
+ ...snapshots,
4449
4427
  startedAt: this.startedAt,
4450
4428
  running: overrides.running,
4451
4429
  channelRoot: path.join(process.cwd(), 'plugins', 'bncr'),
4452
- };
4430
+ });
4453
4431
  }
4454
4432
 
4455
4433
  private buildStatusMeta(accountId: string) {
@@ -4825,34 +4803,6 @@ class BncrBridgeRuntime {
4825
4803
  );
4826
4804
  }
4827
4805
 
4828
- private buildFileTransferInitPayload(args: {
4829
- transferId: string;
4830
- sessionKey: string;
4831
- route: BncrRoute;
4832
- fileName: string;
4833
- mimeType?: string;
4834
- fileSize: number;
4835
- chunkSize: number;
4836
- totalChunks: number;
4837
- fileSha256: string;
4838
- }) {
4839
- return {
4840
- transferId: args.transferId,
4841
- direction: 'oc2bncr' as const,
4842
- sessionKey: args.sessionKey,
4843
- platform: args.route.platform,
4844
- groupId: args.route.groupId,
4845
- userId: args.route.userId,
4846
- fileName: args.fileName,
4847
- mimeType: args.mimeType,
4848
- fileSize: args.fileSize,
4849
- chunkSize: args.chunkSize,
4850
- totalChunks: args.totalChunks,
4851
- fileSha256: args.fileSha256,
4852
- ts: now(),
4853
- };
4854
- }
4855
-
4856
4806
  private buildInitialFileSendTransferState(args: {
4857
4807
  transferId: string;
4858
4808
  accountId: string;
@@ -5043,7 +4993,7 @@ class BncrBridgeRuntime {
5043
4993
 
5044
4994
  ctx.broadcastToConnIds(
5045
4995
  BNCR_FILE_INIT_EVENT,
5046
- this.buildFileTransferInitPayload({
4996
+ buildFileTransferInitPayload({
5047
4997
  transferId,
5048
4998
  sessionKey: params.sessionKey,
5049
4999
  route: params.route,
@@ -5053,6 +5003,7 @@ class BncrBridgeRuntime {
5053
5003
  chunkSize,
5054
5004
  totalChunks,
5055
5005
  fileSha256,
5006
+ ts: now(),
5056
5007
  }),
5057
5008
  connIds,
5058
5009
  );
@@ -5069,7 +5020,7 @@ class BncrBridgeRuntime {
5069
5020
  for (let attempt = 1; attempt <= 3; attempt++) {
5070
5021
  ctx.broadcastToConnIds(
5071
5022
  BNCR_FILE_CHUNK_EVENT,
5072
- {
5023
+ buildFileTransferChunkPayload({
5073
5024
  transferId,
5074
5025
  chunkIndex: idx,
5075
5026
  offset: start,
@@ -5077,7 +5028,7 @@ class BncrBridgeRuntime {
5077
5028
  chunkSha256,
5078
5029
  base64: slice.toString('base64'),
5079
5030
  ts: now(),
5080
- },
5031
+ }),
5081
5032
  connIds,
5082
5033
  );
5083
5034
 
@@ -5125,11 +5076,11 @@ class BncrBridgeRuntime {
5125
5076
  this.fileSendTransfers.set(transferId, st);
5126
5077
  ctx.broadcastToConnIds(
5127
5078
  BNCR_FILE_ABORT_EVENT,
5128
- {
5079
+ buildFileTransferAbortPayload({
5129
5080
  transferId,
5130
5081
  reason: st.error,
5131
5082
  ts: now(),
5132
- },
5083
+ }),
5133
5084
  connIds,
5134
5085
  );
5135
5086
  throw new Error(st.error);
@@ -5138,10 +5089,10 @@ class BncrBridgeRuntime {
5138
5089
 
5139
5090
  ctx.broadcastToConnIds(
5140
5091
  BNCR_FILE_COMPLETE_EVENT,
5141
- {
5092
+ buildFileTransferCompletePayload({
5142
5093
  transferId,
5143
5094
  ts: now(),
5144
- },
5095
+ }),
5145
5096
  connIds,
5146
5097
  );
5147
5098
 
@@ -5575,6 +5526,17 @@ class BncrBridgeRuntime {
5575
5526
  respond(false, { error: 'transfer not found' });
5576
5527
  return;
5577
5528
  }
5529
+ if (st.status === 'completed') {
5530
+ respond(true, {
5531
+ ok: true,
5532
+ transferId,
5533
+ status: 'completed',
5534
+ path: st.completedPath,
5535
+ ignored: true,
5536
+ terminal: true,
5537
+ });
5538
+ return;
5539
+ }
5578
5540
  if (chunkIndex >= st.totalChunks) {
5579
5541
  respond(false, { error: `chunkIndex out of range index=${chunkIndex} total=${st.totalChunks}` });
5580
5542
  return;
@@ -5778,6 +5740,17 @@ class BncrBridgeRuntime {
5778
5740
  respond(true, { ok: true, transferId, message: 'not-found' });
5779
5741
  return;
5780
5742
  }
5743
+ if (st.status === 'completed') {
5744
+ respond(true, {
5745
+ ok: true,
5746
+ transferId,
5747
+ status: 'completed',
5748
+ path: st.completedPath,
5749
+ ignored: true,
5750
+ terminal: true,
5751
+ });
5752
+ return;
5753
+ }
5781
5754
 
5782
5755
  const staleObserved = this.observeLease('file.abort', params ?? {});
5783
5756
  if (staleObserved.stale) {
@@ -5874,6 +5847,30 @@ class BncrBridgeRuntime {
5874
5847
  ? 'file.abort'
5875
5848
  : 'file.complete';
5876
5849
  const staleObserved = this.observeLease(staleKind, params ?? {});
5850
+ if (st?.status === 'completed' || st?.status === 'aborted') {
5851
+ respond(
5852
+ true,
5853
+ staleObserved.stale
5854
+ ? {
5855
+ ok: true,
5856
+ transferId,
5857
+ stage,
5858
+ state: st.status,
5859
+ stale: true,
5860
+ ignored: true,
5861
+ terminal: true,
5862
+ }
5863
+ : {
5864
+ ok: true,
5865
+ transferId,
5866
+ stage,
5867
+ state: st.status,
5868
+ ignored: true,
5869
+ terminal: true,
5870
+ },
5871
+ );
5872
+ return;
5873
+ }
5877
5874
  if (staleObserved.stale) {
5878
5875
  const sameConn = !!st?.ownerConnId && st.ownerConnId === connId;
5879
5876
  const sameClient =
@@ -6130,126 +6127,11 @@ class BncrBridgeRuntime {
6130
6127
  };
6131
6128
 
6132
6129
  channelStartAccount = async (ctx: any) => {
6133
- const accountId = normalizeAccountId(ctx.accountId);
6134
- this.clearChannelAccountWorker(accountId, 'start-replace');
6135
-
6136
- const tick = () => {
6137
- const previous = ctx.getStatus?.() || {};
6138
- const onlineByConn = this.isOnline(accountId);
6139
- const recentInboundReachable = this.hasRecentInboundReachability(accountId);
6140
- const connected = onlineByConn || recentInboundReachable;
6141
- const lastActAt =
6142
- this.lastActivityByAccount.get(accountId) ||
6143
- this.lastInboundByAccount.get(accountId) ||
6144
- this.lastOutboundByAccount.get(accountId) ||
6145
- previous?.lastEventAt ||
6146
- null;
6147
- const healthSig = JSON.stringify({
6148
- bridge: this.bridgeId,
6149
- accountId,
6150
- connected,
6151
- onlineByConn,
6152
- recentInboundReachable,
6153
- activeConnectionKey: this.activeConnectionByAccount.get(accountId) || null,
6154
- activeConnections: Array.from(this.connections.values())
6155
- .filter((c) => c.accountId === accountId)
6156
- .map((c) => ({
6157
- connId: c.connId,
6158
- clientId: c.clientId,
6159
- inboundOnly: c.inboundOnly === true,
6160
- outboundReady: c.outboundReady === true,
6161
- preferredForOutbound: c.preferredForOutbound === true,
6162
- })),
6163
- });
6164
- const conns = Array.from(this.connections.values()).filter((c) => c.accountId === accountId).length;
6165
- this.logInfoDedup(
6166
- 'health',
6167
- `status-tick ${accountId}|changed|${connected ? 'linked' : 'configured'}|onlineByConn=${onlineByConn}|recentInboundReachable=${recentInboundReachable}|conns=${conns}`,
6168
- {
6169
- key: `health-status-tick:${accountId}`,
6170
- sig: healthSig,
6171
- },
6172
- );
6173
- this.logInfoDedup('health', `status-tick ${healthSig}`, {
6174
- key: `health-status-tick-debug:${accountId}`,
6175
- sig: healthSig,
6176
- debugOnly: true,
6177
- });
6178
-
6179
- ctx.setStatus?.({
6180
- ...previous,
6181
- accountId,
6182
- running: true,
6183
- connected,
6184
- lastEventAt: lastActAt,
6185
- // 状态映射:在线=linked,离线=configured
6186
- mode: connected ? 'linked' : 'configured',
6187
- lastError: previous?.lastError ?? null,
6188
- meta: this.buildStatusMeta(accountId),
6189
- });
6190
- };
6191
-
6192
- tick();
6193
- const timer = setInterval(tick, 5_000);
6194
- let worker!: ChannelAccountWorkerHandle;
6195
- const done = new Promise<void>((resolve) => {
6196
- let settled = false;
6197
- const finish = (reason: string) => {
6198
- if (settled) return;
6199
- settled = true;
6200
- const activeWorker = this.channelAccountWorkers.get(accountId);
6201
- if (activeWorker === worker) {
6202
- this.channelAccountWorkers.delete(accountId);
6203
- }
6204
- clearInterval(timer);
6205
- worker.cleanupAbortListener?.();
6206
- worker.cleanupAbortListener = undefined;
6207
- this.logInfo(
6208
- 'health',
6209
- `status-worker finished ${JSON.stringify({ bridge: this.bridgeId, accountId, reason })}`,
6210
- { debugOnly: true },
6211
- );
6212
- this.logInfo('health', `status-worker finished ${accountId}|${reason}`);
6213
- resolve();
6214
- };
6215
-
6216
- worker = { timer, finish };
6217
- this.channelAccountWorkers.set(accountId, worker);
6218
-
6219
- const onAbort = () => finish('abort');
6220
- const abortSignal = ctx.abortSignal;
6221
-
6222
- if (abortSignal?.aborted) {
6223
- onAbort();
6224
- return;
6225
- }
6226
-
6227
- abortSignal?.addEventListener?.('abort', onAbort, { once: true });
6228
- if (abortSignal?.removeEventListener) {
6229
- worker.cleanupAbortListener = () => abortSignal.removeEventListener('abort', onAbort);
6230
- }
6231
- });
6232
- await done;
6130
+ await startBncrStatusWorker(this.buildStatusWorkerRuntime(), ctx);
6233
6131
  };
6234
6132
 
6235
6133
  channelStopAccount = async (ctx: any) => {
6236
- const accountId = normalizeAccountId(ctx?.accountId);
6237
- const cleared = this.clearChannelAccountWorker(accountId, 'explicit-stop');
6238
- const previous = ctx?.getStatus?.() || {};
6239
- ctx?.setStatus?.({
6240
- ...previous,
6241
- accountId,
6242
- running: false,
6243
- restartPending: false,
6244
- lastStopAt: Date.now(),
6245
- meta: this.buildStatusMeta(accountId),
6246
- });
6247
- this.logInfo(
6248
- 'health',
6249
- `status-stop ${JSON.stringify({ bridge: this.bridgeId, accountId, cleared })}`,
6250
- { debugOnly: true },
6251
- );
6252
- this.logInfo('health', `status-stop ${accountId}|cleared=${cleared}`);
6134
+ await stopBncrStatusWorker(this.buildStatusWorkerRuntime(), ctx);
6253
6135
  };
6254
6136
 
6255
6137
  private logChannelSendEntry(args: {
@@ -6504,231 +6386,21 @@ export function createBncrChannelPlugin(getBridge: () => BncrBridgeRuntime) {
6504
6386
 
6505
6387
  const plugin = {
6506
6388
  id: CHANNEL_ID,
6507
- meta: {
6508
- id: CHANNEL_ID,
6509
- label: 'Bncr',
6510
- selectionLabel: 'Bncr Client',
6511
- docsPath: '/channels/bncr',
6512
- blurb: 'Bncr Channel.',
6513
- aliases: ['bncr'],
6514
- },
6389
+ meta: BNCR_CHANNEL_META,
6515
6390
  actions: messageActions,
6516
6391
  message: {
6517
- receive: {
6518
- defaultAckPolicy: 'manual' as const,
6519
- supportedAckPolicies: ['manual'] as const,
6520
- },
6521
- send: {
6522
- text: async (ctx: any) => getBridge().channelMessageSendText(ctx),
6523
- media: async (ctx: any) => getBridge().channelMessageSendMedia(ctx),
6524
- payload: async (ctx: any) => getBridge().channelMessageSendPayload(ctx),
6525
- },
6526
- },
6527
- capabilities: {
6528
- chatTypes: ['direct'] as ChatType[],
6529
- media: true,
6530
- reply: true,
6531
- nativeCommands: true,
6532
- },
6533
- messaging: {
6534
- // 接收任意标签输入;不在 normalize 阶段做格式门槛,统一下沉到发送前验证。
6535
- normalizeTarget: (raw: string) => {
6536
- const input = asString(raw).trim();
6537
- return input || undefined;
6538
- },
6539
- parseExplicitTarget: ({ raw, accountId, cfg }: any) => {
6540
- const resolvedAccountId = normalizeAccountId(
6541
- asString(accountId || BNCR_DEFAULT_ACCOUNT_ID),
6542
- );
6543
- const runtimeBridge = getBridge();
6544
- const canonicalAgentId =
6545
- runtimeBridge.canonicalAgentId ||
6546
- runtimeBridge.ensureCanonicalAgentId({ cfg, accountId: resolvedAccountId });
6547
- return parseExplicitTarget(asString(raw).trim(), { canonicalAgentId });
6548
- },
6549
- formatTargetDisplay: ({ target }: any) => {
6550
- return formatTargetDisplay(target);
6551
- },
6552
- resolveSessionTarget: ({ id, accountId, cfg }: any) => {
6553
- const raw = asString(id).trim();
6554
- if (!raw) return undefined;
6555
- const resolvedAccountId = normalizeAccountId(
6556
- asString(accountId || BNCR_DEFAULT_ACCOUNT_ID),
6557
- );
6558
- const runtimeBridge = getBridge();
6559
- const canonicalAgentId =
6560
- runtimeBridge.canonicalAgentId ||
6561
- runtimeBridge.ensureCanonicalAgentId({ cfg, accountId: resolvedAccountId });
6562
-
6563
- let parsed = parseExplicitTarget(raw, { canonicalAgentId });
6564
- if (!parsed) {
6565
- const route = runtimeBridge.resolveRouteBySession(raw, resolvedAccountId);
6566
- if (route) {
6567
- parsed = parseExplicitTarget(formatDisplayScope(route), { canonicalAgentId });
6568
- }
6569
- }
6570
- return parsed?.displayScope || undefined;
6571
- },
6572
- resolveOutboundSessionRoute: (params: any) => {
6573
- const accountId = normalizeAccountId(
6574
- asString(params?.accountId || BNCR_DEFAULT_ACCOUNT_ID),
6575
- );
6576
- const runtimeBridge = getBridge();
6577
- const canonicalAgentId =
6578
- runtimeBridge.canonicalAgentId ||
6579
- runtimeBridge.ensureCanonicalAgentId({ cfg: params?.cfg, accountId });
6580
- return resolveBncrOutboundSessionRoute({
6581
- ...params,
6582
- canonicalAgentId,
6583
- resolveRouteBySession: (raw: string, acc: string) =>
6584
- runtimeBridge.resolveRouteBySession(raw, acc),
6585
- });
6586
- },
6587
- targetResolver: {
6588
- looksLikeId: (raw: string, normalized?: string) => {
6589
- return looksLikeBncrExplicitTarget(asString(normalized || raw).trim());
6590
- },
6591
- resolveTarget: async ({ accountId, input, normalized }) => {
6592
- const runtimeBridge = getBridge();
6593
- const resolved = resolveBncrOutboundTarget({
6594
- target: asString(normalized || input).trim(),
6595
- accountId: normalizeAccountId(asString(accountId || BNCR_DEFAULT_ACCOUNT_ID)),
6596
- resolveRouteBySession: (raw: string, acc: string) =>
6597
- runtimeBridge.resolveRouteBySession(raw, acc),
6598
- });
6599
- if (!resolved) return null;
6600
- return {
6601
- to: resolved.displayScope,
6602
- kind: resolved.kind,
6603
- display: resolved.displayScope,
6604
- source: 'normalized' as const,
6605
- };
6606
- },
6607
- hint: 'Standard to=Bncr:<platform>:<group>:<user> or Bncr:<platform>:<user>; sessionKey keeps existing strict/legacy compatibility, canonical sessionKey=agent:<agentId>:bncr:direct:<hex>',
6608
- },
6392
+ receive: BNCR_MESSAGE_RECEIVE_POLICY,
6393
+ send: createBncrMessageSend(getBridge),
6609
6394
  },
6395
+ capabilities: BNCR_CHANNEL_CAPABILITIES,
6396
+ messaging: createBncrMessagingSurface(getBridge),
6610
6397
  configSchema: BncrConfigSchema,
6611
- config: {
6612
- listAccountIds,
6613
- resolveAccount,
6614
- setAccountEnabled: ({ cfg, accountId, enabled }: any) =>
6615
- setOpenClawAccountEnabledInConfigSection({
6616
- cfg,
6617
- sectionKey: CHANNEL_ID,
6618
- accountId,
6619
- enabled,
6620
- allowTopLevel: true,
6621
- }),
6622
- isEnabled: (account: any, cfg: any) => {
6623
- const policy = resolveBncrChannelPolicy(cfg?.channels?.[CHANNEL_ID] || {});
6624
- return policy.enabled !== false && account?.enabled !== false;
6625
- },
6626
- isConfigured: () => true,
6627
- describeAccount: (account: any) => {
6628
- const displayName = resolveDefaultDisplayName(account?.name, account?.accountId);
6629
- return {
6630
- accountId: account.accountId,
6631
- name: displayName,
6632
- enabled: account.enabled !== false,
6633
- configured: true,
6634
- };
6635
- },
6636
- },
6637
- setup: {
6638
- applyAccountName: ({ cfg, accountId, name }: any) =>
6639
- applyOpenClawAccountNameToChannelSection({
6640
- cfg,
6641
- channelKey: CHANNEL_ID,
6642
- accountId,
6643
- name,
6644
- alwaysUseAccounts: true,
6645
- }),
6646
- applyAccountConfig: ({ cfg, accountId }: any) => {
6647
- const next = { ...(cfg || {}) } as any;
6648
- next.channels = next.channels || {};
6649
- next.channels[CHANNEL_ID] = next.channels[CHANNEL_ID] || {};
6650
- next.channels[CHANNEL_ID].accounts = next.channels[CHANNEL_ID].accounts || {};
6651
- next.channels[CHANNEL_ID].accounts[accountId] = {
6652
- ...(next.channels[CHANNEL_ID].accounts[accountId] || {}),
6653
- enabled: true,
6654
- };
6655
- return next;
6656
- },
6657
- },
6658
- outbound: {
6659
- deliveryMode: 'gateway' as const,
6660
- sendText: async (ctx: any) => getBridge().channelSendText(ctx),
6661
- sendMedia: async (ctx: any) => getBridge().channelSendMedia(ctx),
6662
- replyAction: async (ctx: any) =>
6663
- sendBncrReplyAction({
6664
- accountId: normalizeAccountId(ctx?.accountId),
6665
- to: asString(ctx?.to || '').trim(),
6666
- text: asString(ctx?.text || ''),
6667
- replyToMessageId:
6668
- asString(ctx?.replyToId || ctx?.replyToMessageId || '').trim() || undefined,
6669
- sendText: async ({ accountId, to, text }) =>
6670
- getBridge().channelSendText({ accountId, to, text }),
6671
- }),
6672
- deleteAction: async (ctx: any) =>
6673
- deleteBncrMessageAction({
6674
- accountId: normalizeAccountId(ctx?.accountId),
6675
- targetMessageId: asString(ctx?.messageId || ctx?.targetMessageId || '').trim(),
6676
- }),
6677
- reactAction: async (ctx: any) =>
6678
- reactBncrMessageAction({
6679
- accountId: normalizeAccountId(ctx?.accountId),
6680
- targetMessageId: asString(ctx?.messageId || ctx?.targetMessageId || '').trim(),
6681
- emoji: asString(ctx?.emoji || '').trim(),
6682
- }),
6683
- editAction: async (ctx: any) =>
6684
- editBncrMessageAction({
6685
- accountId: normalizeAccountId(ctx?.accountId),
6686
- targetMessageId: asString(ctx?.messageId || ctx?.targetMessageId || '').trim(),
6687
- text: asString(ctx?.text || ''),
6688
- }),
6689
- },
6690
- status: {
6691
- defaultRuntime: createOpenClawDefaultChannelRuntimeState(BNCR_DEFAULT_ACCOUNT_ID, {
6692
- mode: 'ws-offline',
6693
- }),
6694
- buildChannelSummary: async ({ defaultAccountId }: any) => {
6695
- return getBridge().getChannelSummary(defaultAccountId || BNCR_DEFAULT_ACCOUNT_ID);
6696
- },
6697
- buildAccountSnapshot: async ({ account, runtime }: any) => {
6698
- const runtimeBridge = getBridge();
6699
- const rt = runtime || runtimeBridge.getAccountRuntimeSnapshot(account?.accountId);
6700
- return buildAccountStatusSnapshot({
6701
- account,
6702
- runtime: rt,
6703
- healthSummary: runtimeBridge.getStatusHeadline(account?.accountId),
6704
- // default 名不可隐藏时,统一展示稳定默认值
6705
- displayName: resolveDefaultDisplayName(account?.name, account?.accountId),
6706
- });
6707
- },
6708
- resolveAccountState: ({ enabled, configured, account, cfg, runtime }: any) => {
6709
- if (!enabled) return 'disabled';
6710
- const resolved = resolveAccount(cfg, account?.accountId);
6711
- if (!(resolved.enabled && configured)) return 'not configured';
6712
- const rt = runtime || getBridge().getAccountRuntimeSnapshot(account?.accountId);
6713
- return rt?.connected ? 'linked' : 'configured';
6714
- },
6715
- },
6716
- gatewayMethods: [
6717
- 'bncr.connect',
6718
- 'bncr.inbound',
6719
- 'bncr.activity',
6720
- 'bncr.ack',
6721
- 'bncr.diagnostics',
6722
- 'bncr.file.init',
6723
- 'bncr.file.chunk',
6724
- 'bncr.file.complete',
6725
- 'bncr.file.abort',
6726
- 'bncr.file.ack',
6727
- ],
6728
- gateway: {
6729
- startAccount: async (ctx: any) => getBridge().channelStartAccount(ctx),
6730
- stopAccount: async (ctx: any) => getBridge().channelStopAccount(ctx),
6731
- },
6398
+ config: BNCR_CONFIG_SURFACE,
6399
+ setup: BNCR_SETUP_SURFACE,
6400
+ outbound: createBncrOutboundRuntime(getBridge),
6401
+ status: createBncrStatusSurface(getBridge),
6402
+ gatewayMethods: BNCR_GATEWAY_METHODS,
6403
+ gateway: createBncrGatewayRuntime(getBridge),
6732
6404
  };
6733
6405
 
6734
6406
  return plugin;