@xmoxmo/bncr 0.2.1 → 0.2.3

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
@@ -73,8 +73,13 @@ import {
73
73
  resolveBncrOutboundTarget,
74
74
  } from './messaging/outbound/target-resolver.ts';
75
75
  const BRIDGE_VERSION = 2;
76
- const BNCR_PUSH_EVENT = 'bncr.push';
76
+ const BNCR_PUSH_EVENT = 'plugin.bncr.push';
77
+ const BNCR_FILE_INIT_EVENT = 'plugin.bncr.file.init';
78
+ const BNCR_FILE_CHUNK_EVENT = 'plugin.bncr.file.chunk';
79
+ const BNCR_FILE_COMPLETE_EVENT = 'plugin.bncr.file.complete';
80
+ const BNCR_FILE_ABORT_EVENT = 'plugin.bncr.file.abort';
77
81
  const CONNECT_TTL_MS = 120_000;
82
+ const RECENT_INBOUND_SEND_WINDOW_MS = 60_000;
78
83
  const MAX_RETRY = 10;
79
84
  const PUSH_DRAIN_INTERVAL_MS = 500;
80
85
  const PUSH_ACK_TIMEOUT_MS = 30_000;
@@ -103,6 +108,8 @@ type FileSendTransferState = {
103
108
  status: 'init' | 'transferring' | 'completed' | 'aborted';
104
109
  ackedChunks: Set<number>;
105
110
  failedChunks: Map<number, string>;
111
+ ownerConnId?: string;
112
+ ownerClientId?: string;
106
113
  completedPath?: string;
107
114
  error?: string;
108
115
  };
@@ -122,10 +129,18 @@ type FileRecvTransferState = {
122
129
  status: 'init' | 'transferring' | 'completed' | 'aborted';
123
130
  bufferByChunk: Map<number, Buffer>;
124
131
  receivedChunks: Set<number>;
132
+ ownerConnId?: string;
133
+ ownerClientId?: string;
125
134
  completedPath?: string;
126
135
  error?: string;
127
136
  };
128
137
 
138
+ type FileAckPayloadState = {
139
+ payload: Record<string, unknown>;
140
+ ok: boolean;
141
+ at: number;
142
+ };
143
+
129
144
  type ChatType = 'direct' | 'group' | (string & {});
130
145
 
131
146
  type ChannelMessageActionAdapter = {
@@ -181,6 +196,56 @@ type PersistedState = {
181
196
  } | null;
182
197
  };
183
198
 
199
+ type NormalizedBncrSendParams = {
200
+ to: string;
201
+ accountId: string;
202
+ message: string;
203
+ caption: string;
204
+ mediaUrl?: string;
205
+ asVoice: boolean;
206
+ audioAsVoice: boolean;
207
+ };
208
+
209
+ function normalizeBncrSendParams(input: {
210
+ params: unknown;
211
+ accountId: string;
212
+ }): NormalizedBncrSendParams {
213
+ const paramsObj = isPlainObject(input.params) ? input.params : {};
214
+ const to = readStringParam(paramsObj, 'to', { required: true });
215
+ const resolvedAccountId = normalizeAccountId(
216
+ readStringParam(paramsObj, 'accountId') ?? input.accountId,
217
+ );
218
+
219
+ const message = readStringParam(paramsObj, 'message', { allowEmpty: true }) ?? '';
220
+ const caption = readStringParam(paramsObj, 'caption', { allowEmpty: true }) ?? '';
221
+ const mediaUrl =
222
+ readStringParam(paramsObj, 'media', { trim: false }) ??
223
+ readStringParam(paramsObj, 'path', { trim: false }) ??
224
+ readStringParam(paramsObj, 'filePath', { trim: false }) ??
225
+ readStringParam(paramsObj, 'mediaUrl', { trim: false });
226
+ const asVoice = readBooleanParam(paramsObj, 'asVoice') ?? false;
227
+ const audioAsVoice = readBooleanParam(paramsObj, 'audioAsVoice') ?? false;
228
+
229
+ if (asVoice && !mediaUrl) throw new Error('send voice requires media path');
230
+
231
+ const normalizedMessage = mediaUrl ? '' : message || caption || '';
232
+ const normalizedCaption = mediaUrl ? caption || message || '' : '';
233
+
234
+ if (!normalizedMessage.trim() && !normalizedCaption.trim() && !mediaUrl) {
235
+ throw new Error('send requires message or media');
236
+ }
237
+
238
+ return {
239
+ to,
240
+ accountId: resolvedAccountId,
241
+ message: normalizedMessage,
242
+ caption: normalizedCaption,
243
+ mediaUrl: mediaUrl || undefined,
244
+ asVoice,
245
+ audioAsVoice,
246
+ };
247
+ }
248
+
184
249
  function now() {
185
250
  return Date.now();
186
251
  }
@@ -191,6 +256,10 @@ function asString(v: unknown, fallback = ''): string {
191
256
  return String(v);
192
257
  }
193
258
 
259
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
260
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
261
+ }
262
+
194
263
  function backoffMs(retryCount: number): number {
195
264
  // 1s,2s,4s,8s... capped by retry count checks
196
265
  return Math.max(1_000, 1_000 * 2 ** Math.max(0, retryCount - 1));
@@ -340,6 +409,7 @@ class BncrBridgeRuntime {
340
409
  private lastActivityByAccount = new Map<string, number>();
341
410
  private lastInboundByAccount = new Map<string, number>();
342
411
  private lastOutboundByAccount = new Map<string, number>();
412
+ private channelAccountTimers = new Map<string, NodeJS.Timeout>();
343
413
  private canonicalAgentId: string | null = null;
344
414
  private canonicalAgentSource: 'startup' | 'runtime' | 'fallback-main' | null = null;
345
415
  private canonicalAgentResolvedAt: number | null = null;
@@ -354,7 +424,13 @@ class BncrBridgeRuntime {
354
424
  private saveTimer: NodeJS.Timeout | null = null;
355
425
  private pushTimer: NodeJS.Timeout | null = null;
356
426
  private pushDrainRunningAccounts = new Set<string>();
357
- private waiters = new Map<string, Array<() => void>>();
427
+ private messageAckWaiters = new Map<
428
+ string,
429
+ {
430
+ resolve: (result: 'acked' | 'timeout') => void;
431
+ timer: NodeJS.Timeout;
432
+ }
433
+ >();
358
434
  private gatewayContext: GatewayRequestHandlerOptions['context'] | null = null;
359
435
 
360
436
  // 文件互传状态(V1:尽力而为,重连不续传)
@@ -368,6 +444,7 @@ class BncrBridgeRuntime {
368
444
  timer: NodeJS.Timeout;
369
445
  }
370
446
  >();
447
+ private earlyFileAcks = new Map<string, FileAckPayloadState>();
371
448
 
372
449
  constructor(api: OpenClawPluginApi) {
373
450
  this.api = api;
@@ -426,6 +503,19 @@ class BncrBridgeRuntime {
426
503
  this.logInfo('outbound', [type, this.summarizeScope(entry.route), preview].join('|'));
427
504
  }
428
505
 
506
+ private clearChannelAccountWorker(accountId: string, reason: string) {
507
+ const timer = this.channelAccountTimers.get(accountId);
508
+ if (!timer) return false;
509
+ clearInterval(timer);
510
+ this.channelAccountTimers.delete(accountId);
511
+ this.logInfo(
512
+ 'health',
513
+ `status-worker cleared ${JSON.stringify({ bridge: this.bridgeId, accountId, reason })}`,
514
+ { debugOnly: true },
515
+ );
516
+ return true;
517
+ }
518
+
429
519
  private classifyRegisterTrace(stack: string) {
430
520
  if (
431
521
  stack.includes('prepareSecretsRuntimeSnapshot') ||
@@ -660,6 +750,45 @@ class BncrBridgeRuntime {
660
750
  return { stale: true, reason: 'mismatch' as const };
661
751
  }
662
752
 
753
+ private shouldIgnoreStaleEvent(params: {
754
+ kind:
755
+ | 'inbound'
756
+ | 'activity'
757
+ | 'ack'
758
+ | 'file.init'
759
+ | 'file.chunk'
760
+ | 'file.complete'
761
+ | 'file.abort';
762
+ payload: { leaseId?: string; connectionEpoch?: number };
763
+ accountId: string;
764
+ connId: string;
765
+ clientId?: string;
766
+ }) {
767
+ const observed = this.observeLease(params.kind, params.payload);
768
+ if (!observed.stale) return false;
769
+ this.logWarn(
770
+ 'stale',
771
+ `ignore kind=${params.kind} accountId=${params.accountId} connId=${params.connId} clientId=${params.clientId || '-'} reason=${observed.reason}`,
772
+ { debugOnly: true },
773
+ );
774
+ return true;
775
+ }
776
+
777
+ private matchesTransferOwner(params: {
778
+ ownerConnId?: string;
779
+ ownerClientId?: string;
780
+ connId: string;
781
+ clientId?: string;
782
+ }) {
783
+ const sameConn = !!params.ownerConnId && params.ownerConnId === params.connId;
784
+ const sameClient =
785
+ !params.ownerConnId &&
786
+ !!params.ownerClientId &&
787
+ !!params.clientId &&
788
+ params.ownerClientId === params.clientId;
789
+ return sameConn || sameClient;
790
+ }
791
+
663
792
  private buildExtendedDiagnostics(accountId: string) {
664
793
  const diagnostics = this.buildIntegratedDiagnostics(accountId) as Record<string, any>;
665
794
  return {
@@ -722,7 +851,7 @@ class BncrBridgeRuntime {
722
851
  this.statePath = path.join(ctx.stateDir, 'bncr-bridge-state.json');
723
852
  await this.loadState();
724
853
  try {
725
- const cfg = await this.api.runtime.config.loadConfig();
854
+ const cfg = this.api.runtime.config.current();
726
855
  this.initializeCanonicalAgentId(cfg);
727
856
  } catch {
728
857
  // ignore startup canonical agent initialization errors
@@ -750,6 +879,26 @@ class BncrBridgeRuntime {
750
879
  this.logInfo('debug', 'service stopped', { debugOnly: true });
751
880
  };
752
881
 
882
+ shutdown() {
883
+ if (this.saveTimer) {
884
+ clearTimeout(this.saveTimer);
885
+ this.saveTimer = null;
886
+ }
887
+ if (this.pushTimer) {
888
+ clearTimeout(this.pushTimer);
889
+ this.pushTimer = null;
890
+ }
891
+ for (const waiter of this.messageAckWaiters.values()) {
892
+ clearTimeout(waiter.timer);
893
+ }
894
+ this.messageAckWaiters.clear();
895
+ for (const waiter of this.fileAckWaiters.values()) {
896
+ clearTimeout(waiter.timer);
897
+ }
898
+ this.fileAckWaiters.clear();
899
+ this.earlyFileAcks.clear();
900
+ }
901
+
753
902
  private scheduleSave() {
754
903
  if (this.saveTimer) return;
755
904
  this.saveTimer = setTimeout(() => {
@@ -769,7 +918,7 @@ class BncrBridgeRuntime {
769
918
 
770
919
  private async refreshDebugFlagFromConfig(options?: { forceLog?: boolean }) {
771
920
  try {
772
- const cfg = await this.api.runtime.config.loadConfig();
921
+ const cfg = this.api.runtime.config.current();
773
922
  const raw = (cfg as any)?.channels?.[CHANNEL_ID]?.debug?.verbose;
774
923
  const next = typeof raw === 'boolean' ? raw : false;
775
924
  const changed = next !== BNCR_DEBUG_VERBOSE;
@@ -1170,18 +1319,32 @@ class BncrBridgeRuntime {
1170
1319
  await writeJsonFileAtomically(this.statePath, data);
1171
1320
  }
1172
1321
 
1173
- private wakeAccountWaiters(accountId: string) {
1174
- const key = normalizeAccountId(accountId);
1175
- const waits = this.waiters.get(key);
1176
- if (!waits?.length) return;
1177
- this.waiters.delete(key);
1178
- for (const resolve of waits) resolve();
1322
+ private resolveMessageAck(messageId: string, result: 'acked' | 'timeout' = 'acked') {
1323
+ const key = asString(messageId).trim();
1324
+ if (!key) return false;
1325
+ const waiter = this.messageAckWaiters.get(key);
1326
+ if (!waiter) return false;
1327
+ this.messageAckWaiters.delete(key);
1328
+ clearTimeout(waiter.timer);
1329
+ waiter.resolve(result);
1330
+ return true;
1179
1331
  }
1180
1332
 
1181
1333
  private rememberGatewayContext(context: GatewayRequestHandlerOptions['context']) {
1182
1334
  if (context) this.gatewayContext = context;
1183
1335
  }
1184
1336
 
1337
+ private resolveOutboxPushOwner(accountId: string): BncrConnection | null {
1338
+ const acc = normalizeAccountId(accountId);
1339
+ const t = now();
1340
+ const primaryKey = this.activeConnectionByAccount.get(acc);
1341
+ if (!primaryKey) return null;
1342
+ const primary = this.connections.get(primaryKey);
1343
+ if (!primary?.connId) return null;
1344
+ if (t - primary.lastSeenAt > CONNECT_TTL_MS) return null;
1345
+ return primary;
1346
+ }
1347
+
1185
1348
  private resolvePushConnIds(accountId: string): Set<string> {
1186
1349
  const acc = normalizeAccountId(accountId);
1187
1350
  const t = now();
@@ -1207,7 +1370,280 @@ class BncrBridgeRuntime {
1207
1370
  return connIds;
1208
1371
  }
1209
1372
 
1210
- private tryPushEntry(entry: OutboxEntry): boolean {
1373
+ private hasRecentInboundReachability(accountId: string): boolean {
1374
+ const acc = normalizeAccountId(accountId);
1375
+ const t = now();
1376
+ const lastInboundAt = this.lastInboundByAccount.get(acc) || 0;
1377
+ const lastActivityAt = this.lastActivityByAccount.get(acc) || 0;
1378
+ const lastReachableAt = Math.max(lastInboundAt, lastActivityAt);
1379
+ return lastReachableAt > 0 && t - lastReachableAt <= RECENT_INBOUND_SEND_WINDOW_MS;
1380
+ }
1381
+
1382
+ private resolveRecentInboundConnIds(accountId: string): Set<string> {
1383
+ const acc = normalizeAccountId(accountId);
1384
+ const t = now();
1385
+ const connIds = new Set<string>();
1386
+ if (!this.hasRecentInboundReachability(acc)) return connIds;
1387
+
1388
+ for (const c of this.connections.values()) {
1389
+ if (c.accountId !== acc) continue;
1390
+ if (!c.connId) continue;
1391
+ if (t - c.lastSeenAt > CONNECT_TTL_MS * 2) continue;
1392
+ connIds.add(c.connId);
1393
+ }
1394
+
1395
+ return connIds;
1396
+ }
1397
+
1398
+ private isRecentlyReachableConn(accountId: string, connId?: string, clientId?: string): boolean {
1399
+ const acc = normalizeAccountId(accountId);
1400
+ const cid = asString(connId || '').trim();
1401
+ const client = asString(clientId || '').trim() || undefined;
1402
+ if (!cid) return false;
1403
+
1404
+ const recentConnIds = this.resolveRecentInboundConnIds(acc);
1405
+ if (recentConnIds.has(cid)) return true;
1406
+
1407
+ const activeKey = this.activeConnectionByAccount.get(acc);
1408
+ if (!activeKey) return false;
1409
+ const active = this.connections.get(activeKey);
1410
+ if (!active?.connId) return false;
1411
+ if (active.connId !== cid) return false;
1412
+ if (client && active.clientId && active.clientId !== client) return false;
1413
+ return true;
1414
+ }
1415
+
1416
+ private tryAdoptTransferOwner(args: {
1417
+ accountId: string;
1418
+ transfer:
1419
+ | FileSendTransferState
1420
+ | FileRecvTransferState
1421
+ | undefined;
1422
+ connId: string;
1423
+ clientId?: string;
1424
+ }): boolean {
1425
+ const { accountId, transfer, connId, clientId } = args;
1426
+ if (!transfer) return false;
1427
+ if (!this.hasRecentInboundReachability(accountId)) return false;
1428
+ if (!this.isRecentlyReachableConn(accountId, connId, clientId)) return false;
1429
+
1430
+ transfer.ownerConnId = connId;
1431
+ transfer.ownerClientId = asString(clientId || '').trim() || undefined;
1432
+ return true;
1433
+ }
1434
+
1435
+ private isRetryableFileTransferError(error: unknown): boolean {
1436
+ const msg = asString((error as any)?.message || error || '')
1437
+ .trim()
1438
+ .toLowerCase();
1439
+ if (!msg) return true;
1440
+
1441
+ const retryableMarkers = [
1442
+ 'gateway context unavailable',
1443
+ 'no active bncr client for file chunk transfer',
1444
+ 'chunk ack timeout',
1445
+ 'complete ack timeout',
1446
+ 'transfer state missing',
1447
+ 'transfer aborted',
1448
+ 'temporarily unavailable',
1449
+ 'timeout',
1450
+ 'econn',
1451
+ 'socket',
1452
+ 'network',
1453
+ ];
1454
+
1455
+ return retryableMarkers.some((marker) => msg.includes(marker));
1456
+ }
1457
+
1458
+ private buildFileTransferOutboxEntry(params: {
1459
+ accountId: string;
1460
+ sessionKey: string;
1461
+ route: BncrRoute;
1462
+ mediaUrl: string;
1463
+ mediaLocalRoots?: readonly string[];
1464
+ text?: string;
1465
+ asVoice?: boolean;
1466
+ audioAsVoice?: boolean;
1467
+ kind?: 'tool' | 'block' | 'final';
1468
+ replyToId?: string;
1469
+ }): OutboxEntry {
1470
+ const messageId = randomUUID();
1471
+ return {
1472
+ messageId,
1473
+ accountId: normalizeAccountId(params.accountId),
1474
+ sessionKey: params.sessionKey,
1475
+ route: params.route,
1476
+ payload: {
1477
+ type: 'message.outbound',
1478
+ sessionKey: params.sessionKey,
1479
+ _meta: {
1480
+ kind: 'file-transfer',
1481
+ mediaUrl: params.mediaUrl,
1482
+ mediaLocalRoots: params.mediaLocalRoots ? Array.from(params.mediaLocalRoots) : undefined,
1483
+ text: asString(params.text || ''),
1484
+ asVoice: params.asVoice === true,
1485
+ audioAsVoice: params.audioAsVoice === true,
1486
+ finalEvent: BNCR_PUSH_EVENT,
1487
+ replyToId: asString(params.replyToId || '').trim() || undefined,
1488
+ messageKind: params.kind,
1489
+ },
1490
+ },
1491
+ createdAt: now(),
1492
+ retryCount: 0,
1493
+ nextAttemptAt: now(),
1494
+ };
1495
+ }
1496
+
1497
+ private async tryPushEntry(entry: OutboxEntry): Promise<boolean> {
1498
+ const meta = isPlainObject(entry.payload?._meta) ? entry.payload._meta : null;
1499
+ if (meta?.kind === 'file-transfer') {
1500
+ const ctx = this.gatewayContext;
1501
+ if (!ctx) {
1502
+ entry.lastError = 'gateway context unavailable';
1503
+ this.outbox.set(entry.messageId, entry);
1504
+ this.logInfo(
1505
+ 'outbox',
1506
+ `push-skip ${JSON.stringify({
1507
+ messageId: entry.messageId,
1508
+ accountId: entry.accountId,
1509
+ kind: 'file-transfer',
1510
+ reason: 'no-gateway-context',
1511
+ })}`,
1512
+ { debugOnly: true },
1513
+ );
1514
+ return false;
1515
+ }
1516
+
1517
+ const owner = this.resolveOutboxPushOwner(entry.accountId);
1518
+ let connIds = owner?.connId
1519
+ ? new Set([owner.connId])
1520
+ : this.resolvePushConnIds(entry.accountId);
1521
+ const recentInboundReachable = this.hasRecentInboundReachability(entry.accountId);
1522
+ if (!connIds.size && recentInboundReachable) {
1523
+ connIds = this.resolveRecentInboundConnIds(entry.accountId);
1524
+ }
1525
+ if (!connIds.size) {
1526
+ entry.lastError = 'no active bncr client for file chunk transfer';
1527
+ this.outbox.set(entry.messageId, entry);
1528
+ this.logInfo(
1529
+ 'outbox',
1530
+ `push-skip ${JSON.stringify({
1531
+ messageId: entry.messageId,
1532
+ accountId: entry.accountId,
1533
+ kind: 'file-transfer',
1534
+ reason: 'no-active-connection',
1535
+ recentInboundReachable,
1536
+ })}`,
1537
+ { debugOnly: true },
1538
+ );
1539
+ return false;
1540
+ }
1541
+
1542
+ const mediaUrl = asString(meta.mediaUrl || '').trim();
1543
+ if (!mediaUrl) {
1544
+ entry.lastError = 'file transfer mediaUrl missing';
1545
+ this.outbox.set(entry.messageId, entry);
1546
+ this.logInfo(
1547
+ 'outbox',
1548
+ `push-fail ${JSON.stringify({
1549
+ messageId: entry.messageId,
1550
+ accountId: entry.accountId,
1551
+ kind: 'file-transfer',
1552
+ error: entry.lastError,
1553
+ })}`,
1554
+ { debugOnly: true },
1555
+ );
1556
+ return false;
1557
+ }
1558
+
1559
+ try {
1560
+ const media = await this.transferMediaToBncrClient({
1561
+ accountId: entry.accountId,
1562
+ sessionKey: entry.sessionKey,
1563
+ route: entry.route,
1564
+ mediaUrl,
1565
+ mediaLocalRoots: Array.isArray(meta.mediaLocalRoots)
1566
+ ? meta.mediaLocalRoots.filter((v): v is string => typeof v === 'string')
1567
+ : undefined,
1568
+ });
1569
+ const wantsVoice = meta.asVoice === true || meta.audioAsVoice === true;
1570
+ const frame = buildBncrMediaOutboundFrame({
1571
+ messageId: entry.messageId,
1572
+ sessionKey: entry.sessionKey,
1573
+ route: entry.route,
1574
+ media,
1575
+ mediaUrl,
1576
+ mediaMsg: asString(meta.text || ''),
1577
+ fileName: resolveOutboundFileName({
1578
+ mediaUrl,
1579
+ fileName: media.fileName,
1580
+ mimeType: media.mimeType,
1581
+ }),
1582
+ hintedType: wantsVoice ? 'voice' : undefined,
1583
+ kind:
1584
+ meta.messageKind === 'tool' ||
1585
+ meta.messageKind === 'block' ||
1586
+ meta.messageKind === 'final'
1587
+ ? meta.messageKind
1588
+ : undefined,
1589
+ replyToId: asString(meta.replyToId || '').trim() || undefined,
1590
+ now: now(),
1591
+ });
1592
+
1593
+ ctx.broadcastToConnIds(
1594
+ BNCR_PUSH_EVENT,
1595
+ {
1596
+ ...frame,
1597
+ idempotencyKey: entry.messageId,
1598
+ },
1599
+ connIds,
1600
+ );
1601
+ entry.lastPushAt = now();
1602
+ entry.lastPushConnId =
1603
+ owner?.connId || (connIds.size === 1 ? Array.from(connIds)[0] : undefined);
1604
+ entry.lastPushClientId = owner?.clientId;
1605
+ entry.lastError = undefined;
1606
+ this.outbox.set(entry.messageId, entry);
1607
+ this.lastOutboundByAccount.set(entry.accountId, entry.lastPushAt);
1608
+ this.markActivity(entry.accountId, entry.lastPushAt);
1609
+ this.scheduleSave();
1610
+ this.logInfo(
1611
+ 'outbox',
1612
+ `push-ok ${JSON.stringify({
1613
+ messageId: entry.messageId,
1614
+ accountId: entry.accountId,
1615
+ kind: 'file-transfer',
1616
+ connIds: Array.from(connIds),
1617
+ ownerConnId: entry.lastPushConnId || '',
1618
+ ownerClientId: entry.lastPushClientId || '',
1619
+ recentInboundReachable,
1620
+ event: BNCR_PUSH_EVENT,
1621
+ })}`,
1622
+ { debugOnly: true },
1623
+ );
1624
+ return true;
1625
+ } catch (error) {
1626
+ entry.lastError = asString((error as any)?.message || error || 'file-transfer-error');
1627
+ this.outbox.set(entry.messageId, entry);
1628
+ this.scheduleSave();
1629
+ this.logInfo(
1630
+ 'outbox',
1631
+ `push-fail ${JSON.stringify({
1632
+ messageId: entry.messageId,
1633
+ accountId: entry.accountId,
1634
+ kind: 'file-transfer',
1635
+ retryable: this.isRetryableFileTransferError(error),
1636
+ error: entry.lastError,
1637
+ })}`,
1638
+ { debugOnly: true },
1639
+ );
1640
+ if (!this.isRetryableFileTransferError(error)) {
1641
+ this.moveToDeadLetter(entry, entry.lastError || 'file-transfer-failed');
1642
+ }
1643
+ return false;
1644
+ }
1645
+ }
1646
+
1211
1647
  const ctx = this.gatewayContext;
1212
1648
  if (!ctx) {
1213
1649
  this.logInfo(
@@ -1222,7 +1658,14 @@ class BncrBridgeRuntime {
1222
1658
  return false;
1223
1659
  }
1224
1660
 
1225
- const connIds = this.resolvePushConnIds(entry.accountId);
1661
+ const owner = this.resolveOutboxPushOwner(entry.accountId);
1662
+ let connIds = owner?.connId
1663
+ ? new Set([owner.connId])
1664
+ : this.resolvePushConnIds(entry.accountId);
1665
+ const recentInboundReachable = this.hasRecentInboundReachability(entry.accountId);
1666
+ if (!connIds.size && recentInboundReachable) {
1667
+ connIds = this.resolveRecentInboundConnIds(entry.accountId);
1668
+ }
1226
1669
  if (!connIds.size) {
1227
1670
  this.logInfo(
1228
1671
  'outbox',
@@ -1230,6 +1673,7 @@ class BncrBridgeRuntime {
1230
1673
  messageId: entry.messageId,
1231
1674
  accountId: entry.accountId,
1232
1675
  reason: 'no-active-connection',
1676
+ recentInboundReachable,
1233
1677
  })}`,
1234
1678
  { debugOnly: true },
1235
1679
  );
@@ -1243,18 +1687,26 @@ class BncrBridgeRuntime {
1243
1687
  };
1244
1688
 
1245
1689
  ctx.broadcastToConnIds(BNCR_PUSH_EVENT, payload, connIds);
1690
+ entry.lastPushAt = now();
1691
+ entry.lastPushConnId =
1692
+ owner?.connId || (connIds.size === 1 ? Array.from(connIds)[0] : undefined);
1693
+ entry.lastPushClientId = owner?.clientId;
1694
+ this.outbox.set(entry.messageId, entry);
1246
1695
  this.logInfo(
1247
1696
  'outbox',
1248
1697
  `push-ok ${JSON.stringify({
1249
1698
  messageId: entry.messageId,
1250
1699
  accountId: entry.accountId,
1251
1700
  connIds: Array.from(connIds),
1701
+ ownerConnId: entry.lastPushConnId || '',
1702
+ ownerClientId: entry.lastPushClientId || '',
1703
+ recentInboundReachable,
1252
1704
  event: BNCR_PUSH_EVENT,
1253
1705
  })}`,
1254
1706
  { debugOnly: true },
1255
1707
  );
1256
- this.lastOutboundByAccount.set(entry.accountId, now());
1257
- this.markActivity(entry.accountId);
1708
+ this.lastOutboundByAccount.set(entry.accountId, entry.lastPushAt);
1709
+ this.markActivity(entry.accountId, entry.lastPushAt);
1258
1710
  this.scheduleSave();
1259
1711
  return true;
1260
1712
  } catch (error) {
@@ -1282,6 +1734,42 @@ class BncrBridgeRuntime {
1282
1734
  }, delay);
1283
1735
  }
1284
1736
 
1737
+ private isOutboundAckRequired(accountId?: string) {
1738
+ try {
1739
+ const cfg = this.api.runtime.config.current();
1740
+ const channelCfg = (cfg as any)?.channels?.[CHANNEL_ID];
1741
+ const accountCfg =
1742
+ accountId && channelCfg?.accounts && typeof channelCfg.accounts === 'object'
1743
+ ? (channelCfg.accounts as Record<string, any>)[normalizeAccountId(accountId)]
1744
+ : null;
1745
+ const scoped = accountCfg?.outboundRequireAck;
1746
+ const global = channelCfg?.outboundRequireAck;
1747
+ if (typeof scoped === 'boolean') return scoped;
1748
+ if (typeof global === 'boolean') return global;
1749
+ return true;
1750
+ } catch {
1751
+ return true;
1752
+ }
1753
+ }
1754
+
1755
+ private buildRuntimeFlags(accountId?: string) {
1756
+ let ackPolicySource: 'channel' | 'default' = 'default';
1757
+ try {
1758
+ const cfg = this.api.runtime.config.current();
1759
+ const global = (cfg as any)?.channels?.[CHANNEL_ID]?.outboundRequireAck;
1760
+ if (typeof global === 'boolean') ackPolicySource = 'channel';
1761
+ } catch {
1762
+ // keep default source
1763
+ }
1764
+ return {
1765
+ outboundRequireAck: this.isOutboundAckRequired(accountId),
1766
+ ackPolicySource,
1767
+ messageAckTimeoutMs: PUSH_ACK_TIMEOUT_MS,
1768
+ fileAckTimeoutMs: FILE_ACK_TIMEOUT_MS,
1769
+ debugVerbose: BNCR_DEBUG_VERBOSE,
1770
+ };
1771
+ }
1772
+
1285
1773
  private async flushPushQueue(accountId?: string): Promise<void> {
1286
1774
  const filterAcc = accountId ? normalizeAccountId(accountId) : null;
1287
1775
  const targetAccounts = filterAcc
@@ -1307,12 +1795,14 @@ class BncrBridgeRuntime {
1307
1795
  for (const acc of targetAccounts) {
1308
1796
  if (!acc || this.pushDrainRunningAccounts.has(acc)) continue;
1309
1797
  const online = this.isOnline(acc);
1798
+ const recentInboundReachable = this.hasRecentInboundReachability(acc);
1310
1799
  this.logInfo(
1311
1800
  'outbox',
1312
1801
  `online ${JSON.stringify({
1313
1802
  bridge: this.bridgeId,
1314
1803
  accountId: acc,
1315
1804
  online,
1805
+ recentInboundReachable,
1316
1806
  connections: Array.from(this.connections.values()).map((c) => ({
1317
1807
  accountId: c.accountId,
1318
1808
  connId: c.connId,
@@ -1341,26 +1831,48 @@ class BncrBridgeRuntime {
1341
1831
  break;
1342
1832
  }
1343
1833
 
1344
- const onlineNow = this.isOnline(acc);
1345
- const pushed = this.tryPushEntry(entry);
1834
+ const onlineNow = this.isOnline(acc) || this.hasRecentInboundReachability(acc);
1835
+ const pushed = await this.tryPushEntry(entry);
1346
1836
  if (pushed) {
1347
- if (onlineNow) {
1348
- await this.waitForOutbound(acc, PUSH_ACK_TIMEOUT_MS);
1837
+ const requireAck = this.isOutboundAckRequired(acc);
1838
+ let ackResult: 'acked' | 'timeout' = requireAck ? 'timeout' : 'acked';
1839
+ if (onlineNow && requireAck) {
1840
+ ackResult = await this.waitForMessageAck(entry.messageId, PUSH_ACK_TIMEOUT_MS);
1349
1841
  }
1350
1842
 
1843
+ this.logInfo(
1844
+ 'outbox',
1845
+ `ack ${JSON.stringify({
1846
+ messageId: entry.messageId,
1847
+ accountId: entry.accountId,
1848
+ requireAck,
1849
+ ackResult,
1850
+ onlineNow,
1851
+ })}`,
1852
+ { debugOnly: true },
1853
+ );
1854
+
1351
1855
  if (!this.outbox.has(entry.messageId)) {
1352
1856
  await this.sleepMs(PUSH_DRAIN_INTERVAL_MS);
1353
1857
  continue;
1354
1858
  }
1355
1859
 
1860
+ if (onlineNow && (!requireAck || ackResult !== 'timeout')) {
1861
+ await this.sleepMs(PUSH_DRAIN_INTERVAL_MS);
1862
+ continue;
1863
+ }
1864
+
1356
1865
  entry.retryCount += 1;
1357
1866
  entry.lastAttemptAt = now();
1358
1867
  if (entry.retryCount > MAX_RETRY) {
1359
- this.moveToDeadLetter(entry, entry.lastError || 'push-ack-timeout');
1868
+ this.moveToDeadLetter(
1869
+ entry,
1870
+ entry.lastError || (requireAck ? 'push-ack-timeout' : 'push-delivery-unconfirmed'),
1871
+ );
1360
1872
  continue;
1361
1873
  }
1362
1874
  entry.nextAttemptAt = now() + backoffMs(entry.retryCount);
1363
- entry.lastError = entry.lastError || 'push-ack-timeout';
1875
+ entry.lastError = requireAck ? 'push-ack-timeout' : 'push-delivery-unconfirmed';
1364
1876
  this.outbox.set(entry.messageId, entry);
1365
1877
  this.scheduleSave();
1366
1878
 
@@ -1370,6 +1882,11 @@ class BncrBridgeRuntime {
1370
1882
  break;
1371
1883
  }
1372
1884
 
1885
+ if (!this.outbox.has(entry.messageId)) {
1886
+ await this.sleepMs(PUSH_DRAIN_INTERVAL_MS);
1887
+ continue;
1888
+ }
1889
+
1373
1890
  const nextAttempt = entry.retryCount + 1;
1374
1891
  if (nextAttempt > MAX_RETRY) {
1375
1892
  this.moveToDeadLetter(entry, entry.lastError || 'push-retry-limit');
@@ -1400,29 +1917,18 @@ class BncrBridgeRuntime {
1400
1917
  if (globalNextDelay != null) this.schedulePushDrain(globalNextDelay);
1401
1918
  }
1402
1919
 
1403
- private async waitForOutbound(accountId: string, waitMs: number): Promise<void> {
1404
- const key = normalizeAccountId(accountId);
1920
+ private async waitForMessageAck(messageId: string, waitMs: number): Promise<'acked' | 'timeout'> {
1921
+ const key = asString(messageId).trim();
1405
1922
  const timeoutMs = Math.max(0, Math.min(waitMs, 25_000));
1406
- if (!timeoutMs) return;
1923
+ if (!key || !timeoutMs) return 'timeout';
1407
1924
 
1408
- await new Promise<void>((resolve) => {
1925
+ return await new Promise<'acked' | 'timeout'>((resolve) => {
1409
1926
  const timer = setTimeout(() => {
1410
- const arr = this.waiters.get(key) || [];
1411
- this.waiters.set(
1412
- key,
1413
- arr.filter((fn) => fn !== done),
1414
- );
1415
- resolve();
1927
+ this.messageAckWaiters.delete(key);
1928
+ resolve('timeout');
1416
1929
  }, timeoutMs);
1417
1930
 
1418
- const done = () => {
1419
- clearTimeout(timer);
1420
- resolve();
1421
- };
1422
-
1423
- const arr = this.waiters.get(key) || [];
1424
- arr.push(done);
1425
- this.waiters.set(key, arr);
1931
+ this.messageAckWaiters.set(key, { resolve, timer });
1426
1932
  });
1427
1933
  }
1428
1934
 
@@ -1473,6 +1979,9 @@ class BncrBridgeRuntime {
1473
1979
  for (const [id, st] of this.fileRecvTransfers.entries()) {
1474
1980
  if (t - st.startedAt > FILE_TRANSFER_KEEP_MS) this.fileRecvTransfers.delete(id);
1475
1981
  }
1982
+ for (const [key, ack] of this.earlyFileAcks.entries()) {
1983
+ if (t - ack.at > FILE_TRANSFER_ACK_TTL_MS) this.earlyFileAcks.delete(key);
1984
+ }
1476
1985
  }
1477
1986
 
1478
1987
  private markSeen(accountId: string, connId: string, clientId?: string) {
@@ -1482,6 +1991,8 @@ class BncrBridgeRuntime {
1482
1991
  const key = this.connectionKey(acc, clientId);
1483
1992
  const t = now();
1484
1993
  const prev = this.connections.get(key);
1994
+ const previousActiveKey = this.activeConnectionByAccount.get(acc) || null;
1995
+ const previousActiveConn = previousActiveKey ? this.connections.get(previousActiveKey) || null : null;
1485
1996
 
1486
1997
  const nextConn: BncrConnection = {
1487
1998
  accountId: acc,
@@ -1508,6 +2019,27 @@ class BncrBridgeRuntime {
1508
2019
  const current = this.activeConnectionByAccount.get(acc);
1509
2020
  if (!current) {
1510
2021
  this.activeConnectionByAccount.set(acc, key);
2022
+ this.logInfo(
2023
+ 'connection',
2024
+ `seen:promote ${JSON.stringify({
2025
+ bridge: this.bridgeId,
2026
+ accountId: acc,
2027
+ reason: 'no-current-active',
2028
+ previousActiveKey,
2029
+ previousActiveConn,
2030
+ nextActiveKey: key,
2031
+ nextActiveConn: nextConn,
2032
+ activeConnections: Array.from(this.connections.values())
2033
+ .filter((c) => c.accountId === acc)
2034
+ .map((c) => ({
2035
+ connId: c.connId,
2036
+ clientId: c.clientId,
2037
+ connectedAt: c.connectedAt,
2038
+ lastSeenAt: c.lastSeenAt,
2039
+ })),
2040
+ })}`,
2041
+ { debugOnly: true },
2042
+ );
1511
2043
  return;
1512
2044
  }
1513
2045
 
@@ -1518,6 +2050,31 @@ class BncrBridgeRuntime {
1518
2050
  nextConn.connectedAt >= curConn.connectedAt
1519
2051
  ) {
1520
2052
  this.activeConnectionByAccount.set(acc, key);
2053
+ this.logInfo(
2054
+ 'connection',
2055
+ `seen:promote ${JSON.stringify({
2056
+ bridge: this.bridgeId,
2057
+ accountId: acc,
2058
+ reason: !curConn
2059
+ ? 'current-missing'
2060
+ : t - curConn.lastSeenAt > CONNECT_TTL_MS
2061
+ ? 'current-stale'
2062
+ : 'newer-or-equal-connectedAt',
2063
+ previousActiveKey,
2064
+ previousActiveConn,
2065
+ nextActiveKey: key,
2066
+ nextActiveConn: nextConn,
2067
+ activeConnections: Array.from(this.connections.values())
2068
+ .filter((c) => c.accountId === acc)
2069
+ .map((c) => ({
2070
+ connId: c.connId,
2071
+ clientId: c.clientId,
2072
+ connectedAt: c.connectedAt,
2073
+ lastSeenAt: c.lastSeenAt,
2074
+ })),
2075
+ })}`,
2076
+ { debugOnly: true },
2077
+ );
1521
2078
  }
1522
2079
  }
1523
2080
 
@@ -1681,9 +2238,61 @@ class BncrBridgeRuntime {
1681
2238
  Math.min(Number(params.timeoutMs || FILE_ACK_TIMEOUT_MS), 120_000),
1682
2239
  );
1683
2240
 
2241
+ const cached = this.earlyFileAcks.get(key);
2242
+ if (cached) {
2243
+ this.earlyFileAcks.delete(key);
2244
+ this.logInfo(
2245
+ 'file-ack-cache-hit',
2246
+ JSON.stringify({
2247
+ bridge: this.bridgeId,
2248
+ transferId,
2249
+ stage,
2250
+ chunkIndex:
2251
+ Number.isFinite(Number(params.chunkIndex)) ? Number(params.chunkIndex) : undefined,
2252
+ key,
2253
+ ok: cached.ok,
2254
+ payload: cached.payload,
2255
+ }),
2256
+ { debugOnly: true },
2257
+ );
2258
+ if (cached.ok) return Promise.resolve(cached.payload);
2259
+ return Promise.reject(
2260
+ new Error(
2261
+ asString(cached.payload?.errorMessage || cached.payload?.error || 'file ack failed'),
2262
+ ),
2263
+ );
2264
+ }
2265
+
2266
+ this.logInfo(
2267
+ 'file-ack-wait',
2268
+ JSON.stringify({
2269
+ bridge: this.bridgeId,
2270
+ transferId,
2271
+ stage,
2272
+ chunkIndex:
2273
+ Number.isFinite(Number(params.chunkIndex)) ? Number(params.chunkIndex) : undefined,
2274
+ key,
2275
+ timeoutMs,
2276
+ }),
2277
+ { debugOnly: true },
2278
+ );
2279
+
1684
2280
  return new Promise<Record<string, unknown>>((resolve, reject) => {
1685
2281
  const timer = setTimeout(() => {
1686
2282
  this.fileAckWaiters.delete(key);
2283
+ this.logWarn(
2284
+ 'file-ack-timeout',
2285
+ JSON.stringify({
2286
+ bridge: this.bridgeId,
2287
+ transferId,
2288
+ stage,
2289
+ chunkIndex:
2290
+ Number.isFinite(Number(params.chunkIndex)) ? Number(params.chunkIndex) : undefined,
2291
+ key,
2292
+ timeoutMs,
2293
+ }),
2294
+ { debugOnly: true },
2295
+ );
1687
2296
  reject(new Error(`file ack timeout: ${key}`));
1688
2297
  }, timeoutMs);
1689
2298
  this.fileAckWaiters.set(key, { resolve, reject, timer });
@@ -1701,9 +2310,45 @@ class BncrBridgeRuntime {
1701
2310
  const stage = asString(params.stage).trim();
1702
2311
  const key = this.fileAckKey(transferId, stage, params.chunkIndex);
1703
2312
  const waiter = this.fileAckWaiters.get(key);
1704
- if (!waiter) return false;
2313
+ if (!waiter) {
2314
+ this.earlyFileAcks.set(key, {
2315
+ payload: params.payload,
2316
+ ok: params.ok,
2317
+ at: now(),
2318
+ });
2319
+ this.logInfo(
2320
+ 'file-ack-early-cache',
2321
+ JSON.stringify({
2322
+ bridge: this.bridgeId,
2323
+ transferId,
2324
+ stage,
2325
+ chunkIndex:
2326
+ Number.isFinite(Number(params.chunkIndex)) ? Number(params.chunkIndex) : undefined,
2327
+ key,
2328
+ ok: params.ok,
2329
+ payload: params.payload,
2330
+ cached: true,
2331
+ }),
2332
+ { debugOnly: true },
2333
+ );
2334
+ return false;
2335
+ }
1705
2336
  this.fileAckWaiters.delete(key);
1706
2337
  clearTimeout(waiter.timer);
2338
+ this.logInfo(
2339
+ 'file-ack-resolve',
2340
+ JSON.stringify({
2341
+ bridge: this.bridgeId,
2342
+ transferId,
2343
+ stage,
2344
+ chunkIndex:
2345
+ Number.isFinite(Number(params.chunkIndex)) ? Number(params.chunkIndex) : undefined,
2346
+ key,
2347
+ ok: params.ok,
2348
+ payload: params.payload,
2349
+ }),
2350
+ { debugOnly: true },
2351
+ );
1707
2352
  if (params.ok) waiter.resolve(params.payload);
1708
2353
  else
1709
2354
  waiter.reject(
@@ -1723,7 +2368,17 @@ class BncrBridgeRuntime {
1723
2368
  if (!connIds.size || !this.gatewayContext) {
1724
2369
  throw new Error(`no active bncr connection for account=${accountId}`);
1725
2370
  }
1726
- this.gatewayContext.broadcastToConnIds(event, payload, connIds);
2371
+ const normalizedEvent =
2372
+ event === 'bncr.file.init'
2373
+ ? BNCR_FILE_INIT_EVENT
2374
+ : event === 'bncr.file.chunk'
2375
+ ? BNCR_FILE_CHUNK_EVENT
2376
+ : event === 'bncr.file.complete'
2377
+ ? BNCR_FILE_COMPLETE_EVENT
2378
+ : event === 'bncr.file.abort'
2379
+ ? BNCR_FILE_ABORT_EVENT
2380
+ : event;
2381
+ this.gatewayContext.broadcastToConnIds(normalizedEvent, payload, connIds);
1727
2382
  }
1728
2383
 
1729
2384
  private resolveInboundFileType(mimeType: string, fileName: string): string {
@@ -1890,7 +2545,6 @@ class BncrBridgeRuntime {
1890
2545
  this.logOutboundSummary(entry);
1891
2546
  this.outbox.set(entry.messageId, entry);
1892
2547
  this.scheduleSave();
1893
- this.wakeAccountWaiters(entry.accountId);
1894
2548
  this.flushPushQueue(entry.accountId);
1895
2549
  }
1896
2550
 
@@ -1902,6 +2556,7 @@ class BncrBridgeRuntime {
1902
2556
  this.deadLetter.push(dead);
1903
2557
  if (this.deadLetter.length > 1000) this.deadLetter = this.deadLetter.slice(-1000);
1904
2558
  this.outbox.delete(entry.messageId);
2559
+ this.resolveMessageAck(entry.messageId, 'timeout');
1905
2560
  this.scheduleSave();
1906
2561
  }
1907
2562
 
@@ -2067,9 +2722,46 @@ class BncrBridgeRuntime {
2067
2722
  }
2068
2723
 
2069
2724
  const ctx = this.gatewayContext;
2725
+ const owner = this.resolveOutboxPushOwner(params.accountId);
2726
+ const recentInboundReachable = this.hasRecentInboundReachability(params.accountId);
2727
+ const directConnIds = this.resolvePushConnIds(params.accountId);
2728
+ const recentConnIds = recentInboundReachable
2729
+ ? this.resolveRecentInboundConnIds(params.accountId)
2730
+ : new Set<string>();
2731
+ const accountId = normalizeAccountId(params.accountId);
2732
+ const activeConnectionKey = this.activeConnectionByAccount.get(accountId) || null;
2733
+ const accountConnections = Array.from(this.connections.values())
2734
+ .filter((c) => c.accountId === accountId)
2735
+ .map((c) => ({
2736
+ connId: c.connId,
2737
+ clientId: c.clientId,
2738
+ connectedAt: c.connectedAt,
2739
+ lastSeenAt: c.lastSeenAt,
2740
+ }));
2741
+ this.logInfo(
2742
+ 'file-chunk-diag',
2743
+ JSON.stringify({
2744
+ bridge: this.bridgeId,
2745
+ accountId,
2746
+ sessionKey: params.sessionKey,
2747
+ mediaUrl: params.mediaUrl,
2748
+ hasGatewayContext: Boolean(ctx),
2749
+ activeConnectionKey,
2750
+ ownerConnId: owner?.connId || null,
2751
+ ownerClientId: owner?.clientId || null,
2752
+ directConnIds: Array.from(directConnIds),
2753
+ recentInboundReachable,
2754
+ recentConnIds: Array.from(recentConnIds),
2755
+ accountConnections,
2756
+ }),
2757
+ { debugOnly: true },
2758
+ );
2070
2759
  if (!ctx) throw new Error('gateway context unavailable');
2071
2760
 
2072
- const connIds = this.resolvePushConnIds(params.accountId);
2761
+ let connIds = directConnIds;
2762
+ if (!connIds.size && recentInboundReachable) {
2763
+ connIds = recentConnIds;
2764
+ }
2073
2765
  if (!connIds.size) throw new Error('no active bncr client for file chunk transfer');
2074
2766
 
2075
2767
  const transferId = randomUUID();
@@ -2077,6 +2769,26 @@ class BncrBridgeRuntime {
2077
2769
  const totalChunks = Math.ceil(size / chunkSize);
2078
2770
  const fileSha256 = createHash('sha256').update(loaded.buffer).digest('hex');
2079
2771
 
2772
+ this.logInfo(
2773
+ 'file-transfer-start',
2774
+ JSON.stringify({
2775
+ bridge: this.bridgeId,
2776
+ transferId,
2777
+ accountId,
2778
+ sessionKey: params.sessionKey,
2779
+ mediaUrl: params.mediaUrl,
2780
+ fileName,
2781
+ mimeType,
2782
+ fileSize: size,
2783
+ chunkSize,
2784
+ totalChunks,
2785
+ connIds: Array.from(connIds),
2786
+ ownerConnId: owner?.connId || null,
2787
+ ownerClientId: owner?.clientId || null,
2788
+ }),
2789
+ { debugOnly: true },
2790
+ );
2791
+
2080
2792
  const st: FileSendTransferState = {
2081
2793
  transferId,
2082
2794
  accountId: normalizeAccountId(params.accountId),
@@ -2092,11 +2804,13 @@ class BncrBridgeRuntime {
2092
2804
  status: 'init',
2093
2805
  ackedChunks: new Set(),
2094
2806
  failedChunks: new Map(),
2807
+ ownerConnId: owner?.connId,
2808
+ ownerClientId: owner?.clientId,
2095
2809
  };
2096
2810
  this.fileSendTransfers.set(transferId, st);
2097
2811
 
2098
2812
  ctx.broadcastToConnIds(
2099
- 'bncr.file.init',
2813
+ BNCR_FILE_INIT_EVENT,
2100
2814
  {
2101
2815
  transferId,
2102
2816
  direction: 'oc2bncr',
@@ -2126,7 +2840,7 @@ class BncrBridgeRuntime {
2126
2840
  let lastErr: unknown = null;
2127
2841
  for (let attempt = 1; attempt <= 3; attempt++) {
2128
2842
  ctx.broadcastToConnIds(
2129
- 'bncr.file.chunk',
2843
+ BNCR_FILE_CHUNK_EVENT,
2130
2844
  {
2131
2845
  transferId,
2132
2846
  chunkIndex: idx,
@@ -2139,16 +2853,54 @@ class BncrBridgeRuntime {
2139
2853
  connIds,
2140
2854
  );
2141
2855
 
2856
+ this.logInfo(
2857
+ 'file-transfer-chunk-send',
2858
+ JSON.stringify({
2859
+ bridge: this.bridgeId,
2860
+ transferId,
2861
+ accountId,
2862
+ chunkIndex: idx,
2863
+ attempt,
2864
+ offset: start,
2865
+ size: slice.byteLength,
2866
+ connIds: Array.from(connIds),
2867
+ }),
2868
+ { debugOnly: true },
2869
+ );
2870
+
2142
2871
  try {
2143
2872
  await this.waitChunkAck({
2144
2873
  transferId,
2145
2874
  chunkIndex: idx,
2146
2875
  timeoutMs: FILE_TRANSFER_ACK_TTL_MS,
2147
2876
  });
2877
+ this.logInfo(
2878
+ 'file-transfer-chunk-ack',
2879
+ JSON.stringify({
2880
+ bridge: this.bridgeId,
2881
+ transferId,
2882
+ accountId,
2883
+ chunkIndex: idx,
2884
+ attempt,
2885
+ }),
2886
+ { debugOnly: true },
2887
+ );
2148
2888
  ok = true;
2149
2889
  break;
2150
2890
  } catch (err) {
2151
2891
  lastErr = err;
2892
+ this.logWarn(
2893
+ 'file-transfer-chunk-ack-fail',
2894
+ JSON.stringify({
2895
+ bridge: this.bridgeId,
2896
+ transferId,
2897
+ accountId,
2898
+ chunkIndex: idx,
2899
+ attempt,
2900
+ error: asString((err as Error)?.message || err),
2901
+ }),
2902
+ { debugOnly: true },
2903
+ );
2152
2904
  await this.sleepMs(150 * attempt);
2153
2905
  }
2154
2906
  }
@@ -2158,7 +2910,7 @@ class BncrBridgeRuntime {
2158
2910
  st.error = String((lastErr as any)?.message || lastErr || `chunk-${idx}-failed`);
2159
2911
  this.fileSendTransfers.set(transferId, st);
2160
2912
  ctx.broadcastToConnIds(
2161
- 'bncr.file.abort',
2913
+ BNCR_FILE_ABORT_EVENT,
2162
2914
  {
2163
2915
  transferId,
2164
2916
  reason: st.error,
@@ -2171,7 +2923,7 @@ class BncrBridgeRuntime {
2171
2923
  }
2172
2924
 
2173
2925
  ctx.broadcastToConnIds(
2174
- 'bncr.file.complete',
2926
+ BNCR_FILE_COMPLETE_EVENT,
2175
2927
  {
2176
2928
  transferId,
2177
2929
  ts: now(),
@@ -2179,8 +2931,30 @@ class BncrBridgeRuntime {
2179
2931
  connIds,
2180
2932
  );
2181
2933
 
2934
+ this.logInfo(
2935
+ 'file-transfer-complete-send',
2936
+ JSON.stringify({
2937
+ bridge: this.bridgeId,
2938
+ transferId,
2939
+ accountId,
2940
+ connIds: Array.from(connIds),
2941
+ }),
2942
+ { debugOnly: true },
2943
+ );
2944
+
2182
2945
  const done = await this.waitCompleteAck({ transferId, timeoutMs: 60_000 });
2183
2946
 
2947
+ this.logInfo(
2948
+ 'file-transfer-complete-ack',
2949
+ JSON.stringify({
2950
+ bridge: this.bridgeId,
2951
+ transferId,
2952
+ accountId,
2953
+ payload: done,
2954
+ }),
2955
+ { debugOnly: true },
2956
+ );
2957
+
2184
2958
  return {
2185
2959
  mode: 'chunk',
2186
2960
  mimeType,
@@ -2215,44 +2989,20 @@ class BncrBridgeRuntime {
2215
2989
  if (mediaList.length > 0) {
2216
2990
  let first = true;
2217
2991
  for (const mediaUrl of mediaList) {
2218
- const media = await this.transferMediaToBncrClient({
2219
- accountId,
2220
- sessionKey,
2221
- route,
2222
- mediaUrl,
2223
- mediaLocalRoots,
2224
- });
2225
- const messageId = randomUUID();
2226
- const mediaMsg = first ? asString(payload.text || '') : '';
2227
- const wantsVoice = payload.asVoice === true || payload.audioAsVoice === true;
2228
- const frame = buildBncrMediaOutboundFrame({
2229
- messageId,
2230
- sessionKey,
2231
- route,
2232
- media,
2233
- mediaUrl,
2234
- mediaMsg,
2235
- fileName: resolveOutboundFileName({
2992
+ this.enqueueOutbound(
2993
+ this.buildFileTransferOutboxEntry({
2994
+ accountId,
2995
+ sessionKey,
2996
+ route,
2236
2997
  mediaUrl,
2237
- fileName: media.fileName,
2238
- mimeType: media.mimeType,
2998
+ mediaLocalRoots,
2999
+ text: first ? asString(payload.text || '') : '',
3000
+ asVoice: payload.asVoice,
3001
+ audioAsVoice: payload.audioAsVoice,
3002
+ kind: payload.kind,
3003
+ replyToId: asString(payload.replyToId || '').trim() || undefined,
2239
3004
  }),
2240
- hintedType: wantsVoice ? 'voice' : undefined,
2241
- kind: payload.kind,
2242
- replyToId: asString(payload.replyToId || '').trim() || undefined,
2243
- now: now(),
2244
- });
2245
-
2246
- this.enqueueOutbound({
2247
- messageId,
2248
- accountId: normalizeAccountId(accountId),
2249
- sessionKey,
2250
- route,
2251
- payload: frame,
2252
- createdAt: now(),
2253
- retryCount: 0,
2254
- nextAttemptAt: now(),
2255
- });
3005
+ );
2256
3006
  first = false;
2257
3007
  }
2258
3008
  return;
@@ -2329,6 +3079,11 @@ class BncrBridgeRuntime {
2329
3079
  pending: Array.from(this.outbox.values()).filter((v) => v.accountId === accountId).length,
2330
3080
  deadLetter: this.deadLetter.filter((v) => v.accountId === accountId).length,
2331
3081
  diagnostics: this.buildExtendedDiagnostics(accountId),
3082
+ runtimeFlags: this.buildRuntimeFlags(accountId),
3083
+ waiters: {
3084
+ messageAck: this.messageAckWaiters.size,
3085
+ fileAck: this.fileAckWaiters.size,
3086
+ },
2332
3087
  leaseId: lease.leaseId,
2333
3088
  connectionEpoch: lease.connectionEpoch,
2334
3089
  protocolVersion: 2,
@@ -2347,13 +3102,9 @@ class BncrBridgeRuntime {
2347
3102
  const accountId = normalizeAccountId(asString(params?.accountId || ''));
2348
3103
  const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
2349
3104
  const clientId = asString((params as any)?.clientId || '').trim() || undefined;
2350
- this.rememberGatewayContext(context);
2351
- this.markSeen(accountId, connId, clientId);
2352
- this.observeLease('ack', params ?? {});
2353
- this.lastAckAtGlobal = now();
2354
- this.incrementCounter(this.ackEventsByAccount, accountId);
2355
-
2356
3105
  const messageId = asString(params?.messageId || '').trim();
3106
+ const staleObserved = this.observeLease('ack', params ?? {});
3107
+
2357
3108
  this.logInfo(
2358
3109
  'outbox',
2359
3110
  `ack ${JSON.stringify({
@@ -2362,6 +3113,7 @@ class BncrBridgeRuntime {
2362
3113
  ok: params?.ok !== false,
2363
3114
  fatal: params?.fatal === true,
2364
3115
  error: asString(params?.error || ''),
3116
+ stale: staleObserved.stale,
2365
3117
  })}`,
2366
3118
  { debugOnly: true },
2367
3119
  );
@@ -2372,7 +3124,7 @@ class BncrBridgeRuntime {
2372
3124
 
2373
3125
  const entry = this.outbox.get(messageId);
2374
3126
  if (!entry) {
2375
- respond(true, { ok: true, message: 'already-acked-or-missing' });
3127
+ respond(true, { ok: true, message: 'already-acked-or-missing', stale: staleObserved.stale });
2376
3128
  return;
2377
3129
  }
2378
3130
 
@@ -2381,20 +3133,52 @@ class BncrBridgeRuntime {
2381
3133
  return;
2382
3134
  }
2383
3135
 
3136
+ if (staleObserved.stale) {
3137
+ const sameConn = !!entry.lastPushConnId && entry.lastPushConnId === connId;
3138
+ const sameClient =
3139
+ !entry.lastPushConnId &&
3140
+ !!entry.lastPushClientId &&
3141
+ !!clientId &&
3142
+ entry.lastPushClientId === clientId;
3143
+ if (!(sameConn || sameClient)) {
3144
+ this.logWarn(
3145
+ 'stale',
3146
+ `ignore kind=ack accountId=${accountId} connId=${connId} clientId=${clientId || '-'} messageId=${messageId} reason=owner-mismatch lastPushConnId=${entry.lastPushConnId || '-'} lastPushClientId=${entry.lastPushClientId || '-'}`,
3147
+ { debugOnly: true },
3148
+ );
3149
+ respond(true, { ok: true, stale: true, ignored: true });
3150
+ return;
3151
+ }
3152
+ } else {
3153
+ this.rememberGatewayContext(context);
3154
+ this.markSeen(accountId, connId, clientId);
3155
+ }
3156
+ this.lastAckAtGlobal = now();
3157
+ this.incrementCounter(this.ackEventsByAccount, accountId);
3158
+
2384
3159
  const ok = params?.ok !== false;
2385
3160
  const fatal = params?.fatal === true;
2386
3161
 
2387
3162
  if (ok) {
2388
3163
  this.outbox.delete(messageId);
2389
3164
  this.scheduleSave();
2390
- this.wakeAccountWaiters(accountId);
2391
- respond(true, { ok: true });
3165
+ this.resolveMessageAck(messageId, 'acked');
3166
+ respond(
3167
+ true,
3168
+ staleObserved.stale ? { ok: true, stale: true, staleAccepted: true } : { ok: true },
3169
+ );
3170
+ this.flushPushQueue(accountId);
2392
3171
  return;
2393
3172
  }
2394
3173
 
2395
3174
  if (fatal) {
2396
3175
  this.moveToDeadLetter(entry, asString(params?.error || 'fatal-ack'));
2397
- respond(true, { ok: true, movedToDeadLetter: true });
3176
+ respond(
3177
+ true,
3178
+ staleObserved.stale
3179
+ ? { ok: true, movedToDeadLetter: true, stale: true, staleAccepted: true }
3180
+ : { ok: true, movedToDeadLetter: true },
3181
+ );
2398
3182
  return;
2399
3183
  }
2400
3184
 
@@ -2403,7 +3187,12 @@ class BncrBridgeRuntime {
2403
3187
  this.outbox.set(messageId, entry);
2404
3188
  this.scheduleSave();
2405
3189
 
2406
- respond(true, { ok: true, willRetry: true });
3190
+ respond(
3191
+ true,
3192
+ staleObserved.stale
3193
+ ? { ok: true, willRetry: true, stale: true, staleAccepted: true }
3194
+ : { ok: true, willRetry: true },
3195
+ );
2407
3196
  };
2408
3197
 
2409
3198
  handleActivity = async ({ params, respond, client, context }: GatewayRequestHandlerOptions) => {
@@ -2411,7 +3200,18 @@ class BncrBridgeRuntime {
2411
3200
  const accountId = normalizeAccountId(asString(params?.accountId || ''));
2412
3201
  const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
2413
3202
  const clientId = asString((params as any)?.clientId || '').trim() || undefined;
2414
- this.observeLease('activity', params ?? {});
3203
+ if (
3204
+ this.shouldIgnoreStaleEvent({
3205
+ kind: 'activity',
3206
+ payload: params ?? {},
3207
+ accountId,
3208
+ connId,
3209
+ clientId,
3210
+ })
3211
+ ) {
3212
+ respond(true, { accountId, ok: true, event: 'activity', stale: true, ignored: true });
3213
+ return;
3214
+ }
2415
3215
  this.lastActivityAtGlobal = now();
2416
3216
  this.logInfo(
2417
3217
  'activity',
@@ -2439,11 +3239,12 @@ class BncrBridgeRuntime {
2439
3239
  deadLetter: this.deadLetter.filter((v) => v.accountId === accountId).length,
2440
3240
  now: now(),
2441
3241
  });
3242
+ this.flushPushQueue(accountId);
2442
3243
  };
2443
3244
 
2444
3245
  handleDiagnostics = async ({ params, respond }: GatewayRequestHandlerOptions) => {
2445
3246
  const accountId = normalizeAccountId(asString(params?.accountId || ''));
2446
- const cfg = await this.api.runtime.config.loadConfig();
3247
+ const cfg = this.api.runtime.config.current();
2447
3248
  const runtime = this.getAccountRuntimeSnapshot(accountId);
2448
3249
  const diagnostics = this.buildExtendedDiagnostics(accountId);
2449
3250
  const permissions = buildBncrPermissionSummary(cfg ?? {});
@@ -2468,6 +3269,11 @@ class BncrBridgeRuntime {
2468
3269
  accountId,
2469
3270
  runtime,
2470
3271
  diagnostics,
3272
+ runtimeFlags: this.buildRuntimeFlags(accountId),
3273
+ waiters: {
3274
+ messageAck: this.messageAckWaiters.size,
3275
+ fileAck: this.fileAckWaiters.size,
3276
+ },
2471
3277
  permissions,
2472
3278
  probe,
2473
3279
  now: now(),
@@ -2478,9 +3284,20 @@ class BncrBridgeRuntime {
2478
3284
  const accountId = normalizeAccountId(asString(params?.accountId || ''));
2479
3285
  const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
2480
3286
  const clientId = asString((params as any)?.clientId || '').trim() || undefined;
3287
+ if (
3288
+ this.shouldIgnoreStaleEvent({
3289
+ kind: 'file.init',
3290
+ payload: params ?? {},
3291
+ accountId,
3292
+ connId,
3293
+ clientId,
3294
+ })
3295
+ ) {
3296
+ respond(true, { ok: true, stale: true, ignored: true });
3297
+ return;
3298
+ }
2481
3299
  this.rememberGatewayContext(context);
2482
3300
  this.markSeen(accountId, connId, clientId);
2483
- this.observeLease('file.init', params ?? {});
2484
3301
  this.markActivity(accountId);
2485
3302
 
2486
3303
  const transferId = asString(params?.transferId || '').trim();
@@ -2536,6 +3353,8 @@ class BncrBridgeRuntime {
2536
3353
  status: 'init',
2537
3354
  bufferByChunk: new Map(),
2538
3355
  receivedChunks: new Set(),
3356
+ ownerConnId: connId,
3357
+ ownerClientId: clientId,
2539
3358
  });
2540
3359
 
2541
3360
  respond(true, {
@@ -2549,10 +3368,6 @@ class BncrBridgeRuntime {
2549
3368
  const accountId = normalizeAccountId(asString(params?.accountId || ''));
2550
3369
  const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
2551
3370
  const clientId = asString((params as any)?.clientId || '').trim() || undefined;
2552
- this.rememberGatewayContext(context);
2553
- this.markSeen(accountId, connId, clientId);
2554
- this.observeLease('file.chunk', params ?? {});
2555
- this.markActivity(accountId);
2556
3371
 
2557
3372
  const transferId = asString(params?.transferId || '').trim();
2558
3373
  const chunkIndex = Number(params?.chunkIndex ?? -1);
@@ -2572,6 +3387,30 @@ class BncrBridgeRuntime {
2572
3387
  return;
2573
3388
  }
2574
3389
 
3390
+ const staleObserved = this.observeLease('file.chunk', params ?? {});
3391
+ if (staleObserved.stale) {
3392
+ if (
3393
+ !this.matchesTransferOwner({
3394
+ ownerConnId: st.ownerConnId,
3395
+ ownerClientId: st.ownerClientId,
3396
+ connId,
3397
+ clientId,
3398
+ })
3399
+ ) {
3400
+ this.logWarn(
3401
+ 'stale',
3402
+ `ignore kind=file.chunk accountId=${accountId} connId=${connId} clientId=${clientId || '-'} transferId=${transferId} reason=owner-mismatch ownerConnId=${st.ownerConnId || '-'} ownerClientId=${st.ownerClientId || '-'}`,
3403
+ { debugOnly: true },
3404
+ );
3405
+ respond(true, { ok: true, stale: true, ignored: true });
3406
+ return;
3407
+ }
3408
+ } else {
3409
+ this.rememberGatewayContext(context);
3410
+ this.markSeen(accountId, connId, clientId);
3411
+ this.markActivity(accountId);
3412
+ }
3413
+
2575
3414
  try {
2576
3415
  const buf = Buffer.from(base64, 'base64');
2577
3416
  if (size > 0 && buf.length !== size) {
@@ -2586,14 +3425,28 @@ class BncrBridgeRuntime {
2586
3425
  st.status = 'transferring';
2587
3426
  this.fileRecvTransfers.set(transferId, st);
2588
3427
 
2589
- respond(true, {
2590
- ok: true,
2591
- transferId,
2592
- chunkIndex,
2593
- offset,
2594
- received: st.receivedChunks.size,
2595
- totalChunks: st.totalChunks,
2596
- });
3428
+ respond(
3429
+ true,
3430
+ staleObserved.stale
3431
+ ? {
3432
+ ok: true,
3433
+ transferId,
3434
+ chunkIndex,
3435
+ offset,
3436
+ received: st.receivedChunks.size,
3437
+ totalChunks: st.totalChunks,
3438
+ stale: true,
3439
+ staleAccepted: true,
3440
+ }
3441
+ : {
3442
+ ok: true,
3443
+ transferId,
3444
+ chunkIndex,
3445
+ offset,
3446
+ received: st.receivedChunks.size,
3447
+ totalChunks: st.totalChunks,
3448
+ },
3449
+ );
2597
3450
  } catch (error) {
2598
3451
  respond(false, { error: String((error as any)?.message || error || 'chunk invalid') });
2599
3452
  }
@@ -2608,10 +3461,6 @@ class BncrBridgeRuntime {
2608
3461
  const accountId = normalizeAccountId(asString(params?.accountId || ''));
2609
3462
  const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
2610
3463
  const clientId = asString((params as any)?.clientId || '').trim() || undefined;
2611
- this.rememberGatewayContext(context);
2612
- this.markSeen(accountId, connId, clientId);
2613
- this.observeLease('file.complete', params ?? {});
2614
- this.markActivity(accountId);
2615
3464
 
2616
3465
  const transferId = asString(params?.transferId || '').trim();
2617
3466
  if (!transferId) {
@@ -2625,6 +3474,30 @@ class BncrBridgeRuntime {
2625
3474
  return;
2626
3475
  }
2627
3476
 
3477
+ const staleObserved = this.observeLease('file.complete', params ?? {});
3478
+ if (staleObserved.stale) {
3479
+ if (
3480
+ !this.matchesTransferOwner({
3481
+ ownerConnId: st.ownerConnId,
3482
+ ownerClientId: st.ownerClientId,
3483
+ connId,
3484
+ clientId,
3485
+ })
3486
+ ) {
3487
+ this.logWarn(
3488
+ 'stale',
3489
+ `ignore kind=file.complete accountId=${accountId} connId=${connId} clientId=${clientId || '-'} transferId=${transferId} reason=owner-mismatch ownerConnId=${st.ownerConnId || '-'} ownerClientId=${st.ownerClientId || '-'}`,
3490
+ { debugOnly: true },
3491
+ );
3492
+ respond(true, { ok: true, stale: true, ignored: true });
3493
+ return;
3494
+ }
3495
+ } else {
3496
+ this.rememberGatewayContext(context);
3497
+ this.markSeen(accountId, connId, clientId);
3498
+ this.markActivity(accountId);
3499
+ }
3500
+
2628
3501
  try {
2629
3502
  if (st.receivedChunks.size < st.totalChunks) {
2630
3503
  throw new Error(
@@ -2655,15 +3528,30 @@ class BncrBridgeRuntime {
2655
3528
  st.status = 'completed';
2656
3529
  this.fileRecvTransfers.set(transferId, st);
2657
3530
 
2658
- respond(true, {
2659
- ok: true,
2660
- transferId,
2661
- path: saved.path,
2662
- size: merged.length,
2663
- fileName: st.fileName,
2664
- mimeType: st.mimeType,
2665
- fileSha256: digest,
2666
- });
3531
+ respond(
3532
+ true,
3533
+ staleObserved.stale
3534
+ ? {
3535
+ ok: true,
3536
+ transferId,
3537
+ path: saved.path,
3538
+ size: merged.length,
3539
+ fileName: st.fileName,
3540
+ mimeType: st.mimeType,
3541
+ fileSha256: digest,
3542
+ stale: true,
3543
+ staleAccepted: true,
3544
+ }
3545
+ : {
3546
+ ok: true,
3547
+ transferId,
3548
+ path: saved.path,
3549
+ size: merged.length,
3550
+ fileName: st.fileName,
3551
+ mimeType: st.mimeType,
3552
+ fileSha256: digest,
3553
+ },
3554
+ );
2667
3555
  } catch (error) {
2668
3556
  st.status = 'aborted';
2669
3557
  st.error = String((error as any)?.message || error || 'complete failed');
@@ -2676,10 +3564,6 @@ class BncrBridgeRuntime {
2676
3564
  const accountId = normalizeAccountId(asString(params?.accountId || ''));
2677
3565
  const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
2678
3566
  const clientId = asString((params as any)?.clientId || '').trim() || undefined;
2679
- this.rememberGatewayContext(context);
2680
- this.markSeen(accountId, connId, clientId);
2681
- this.observeLease('file.abort', params ?? {});
2682
- this.markActivity(accountId);
2683
3567
 
2684
3568
  const transferId = asString(params?.transferId || '').trim();
2685
3569
  if (!transferId) {
@@ -2693,36 +3577,122 @@ class BncrBridgeRuntime {
2693
3577
  return;
2694
3578
  }
2695
3579
 
3580
+ const staleObserved = this.observeLease('file.abort', params ?? {});
3581
+ if (staleObserved.stale) {
3582
+ if (
3583
+ !this.matchesTransferOwner({
3584
+ ownerConnId: st.ownerConnId,
3585
+ ownerClientId: st.ownerClientId,
3586
+ connId,
3587
+ clientId,
3588
+ })
3589
+ ) {
3590
+ this.logWarn(
3591
+ 'stale',
3592
+ `ignore kind=file.abort accountId=${accountId} connId=${connId} clientId=${clientId || '-'} transferId=${transferId} reason=owner-mismatch ownerConnId=${st.ownerConnId || '-'} ownerClientId=${st.ownerClientId || '-'}`,
3593
+ { debugOnly: true },
3594
+ );
3595
+ respond(true, { ok: true, stale: true, ignored: true });
3596
+ return;
3597
+ }
3598
+ } else {
3599
+ this.rememberGatewayContext(context);
3600
+ this.markSeen(accountId, connId, clientId);
3601
+ this.markActivity(accountId);
3602
+ }
3603
+
2696
3604
  st.status = 'aborted';
2697
3605
  st.error = asString(params?.reason || 'aborted');
2698
3606
  this.fileRecvTransfers.set(transferId, st);
2699
3607
 
2700
- respond(true, {
2701
- ok: true,
2702
- transferId,
2703
- status: 'aborted',
2704
- });
3608
+ respond(
3609
+ true,
3610
+ staleObserved.stale
3611
+ ? {
3612
+ ok: true,
3613
+ transferId,
3614
+ status: 'aborted',
3615
+ stale: true,
3616
+ staleAccepted: true,
3617
+ }
3618
+ : {
3619
+ ok: true,
3620
+ transferId,
3621
+ status: 'aborted',
3622
+ },
3623
+ );
2705
3624
  };
2706
3625
 
2707
3626
  handleFileAck = async ({ params, respond, client, context }: GatewayRequestHandlerOptions) => {
2708
3627
  const accountId = normalizeAccountId(asString(params?.accountId || ''));
2709
3628
  const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
2710
3629
  const clientId = asString((params as any)?.clientId || '').trim() || undefined;
2711
- this.rememberGatewayContext(context);
2712
- this.markSeen(accountId, connId, clientId);
2713
- this.markActivity(accountId);
2714
3630
 
2715
3631
  const transferId = asString(params?.transferId || '').trim();
2716
3632
  const stage = asString(params?.stage || '').trim();
2717
3633
  const ok = params?.ok !== false;
2718
3634
  const chunkIndex = Number(params?.chunkIndex ?? -1);
2719
3635
 
3636
+ this.logInfo(
3637
+ 'file-ack-inbound',
3638
+ JSON.stringify({
3639
+ bridge: this.bridgeId,
3640
+ accountId,
3641
+ connId,
3642
+ clientId: clientId || null,
3643
+ transferId,
3644
+ stage,
3645
+ ok,
3646
+ chunkIndex: chunkIndex >= 0 ? chunkIndex : undefined,
3647
+ errorCode: asString(params?.errorCode || ''),
3648
+ errorMessage: asString(params?.errorMessage || ''),
3649
+ path: asString(params?.path || '').trim(),
3650
+ }),
3651
+ { debugOnly: true },
3652
+ );
3653
+
2720
3654
  if (!transferId || !stage) {
2721
3655
  respond(false, { error: 'transferId/stage required' });
2722
3656
  return;
2723
3657
  }
2724
3658
 
2725
3659
  const st = this.fileSendTransfers.get(transferId);
3660
+ const staleKind =
3661
+ stage === 'init'
3662
+ ? 'file.init'
3663
+ : stage === 'chunk'
3664
+ ? 'file.chunk'
3665
+ : stage === 'abort'
3666
+ ? 'file.abort'
3667
+ : 'file.complete';
3668
+ const staleObserved = this.observeLease(staleKind, params ?? {});
3669
+ if (staleObserved.stale) {
3670
+ const sameConn = !!st?.ownerConnId && st.ownerConnId === connId;
3671
+ const sameClient =
3672
+ !st?.ownerConnId && !!st?.ownerClientId && !!clientId && st.ownerClientId === clientId;
3673
+ const adopted =
3674
+ !(sameConn || sameClient) &&
3675
+ this.tryAdoptTransferOwner({
3676
+ accountId,
3677
+ transfer: st,
3678
+ connId,
3679
+ clientId,
3680
+ });
3681
+ if (!(sameConn || sameClient || adopted)) {
3682
+ this.logWarn(
3683
+ 'stale',
3684
+ `ignore kind=file.ack accountId=${accountId} connId=${connId} clientId=${clientId || '-'} transferId=${transferId} stage=${stage} reason=owner-mismatch ownerConnId=${st?.ownerConnId || '-'} ownerClientId=${st?.ownerClientId || '-'}`,
3685
+ { debugOnly: true },
3686
+ );
3687
+ respond(true, { ok: true, stale: true, ignored: true });
3688
+ return;
3689
+ }
3690
+ } else {
3691
+ this.rememberGatewayContext(context);
3692
+ this.markSeen(accountId, connId, clientId);
3693
+ this.markActivity(accountId);
3694
+ }
3695
+
2726
3696
  if (st) {
2727
3697
  if (!ok) {
2728
3698
  const code = asString(params?.errorCode || 'ACK_FAILED');
@@ -2759,12 +3729,24 @@ class BncrBridgeRuntime {
2759
3729
  ok,
2760
3730
  });
2761
3731
 
2762
- respond(true, {
2763
- ok: true,
2764
- transferId,
2765
- stage,
2766
- state: st?.status || 'late',
2767
- });
3732
+ respond(
3733
+ true,
3734
+ staleObserved.stale
3735
+ ? {
3736
+ ok: true,
3737
+ transferId,
3738
+ stage,
3739
+ state: st?.status || 'late',
3740
+ stale: true,
3741
+ staleAccepted: true,
3742
+ }
3743
+ : {
3744
+ ok: true,
3745
+ transferId,
3746
+ stage,
3747
+ state: st?.status || 'late',
3748
+ },
3749
+ );
2768
3750
  };
2769
3751
 
2770
3752
  handleInbound = async ({ params, respond, client, context }: GatewayRequestHandlerOptions) => {
@@ -2790,10 +3772,49 @@ class BncrBridgeRuntime {
2790
3772
  } = parsed;
2791
3773
  const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
2792
3774
  const clientId = asString((params as any)?.clientId || '').trim() || undefined;
3775
+ if (
3776
+ this.shouldIgnoreStaleEvent({
3777
+ kind: 'inbound',
3778
+ payload: params ?? {},
3779
+ accountId,
3780
+ connId,
3781
+ clientId,
3782
+ })
3783
+ ) {
3784
+ respond(true, {
3785
+ accepted: false,
3786
+ stale: true,
3787
+ ignored: true,
3788
+ accountId,
3789
+ msgId: msgId ?? null,
3790
+ });
3791
+ return;
3792
+ }
2793
3793
  this.rememberGatewayContext(context);
2794
3794
  this.markSeen(accountId, connId, clientId);
2795
- this.observeLease('inbound', params ?? {});
2796
3795
  this.markActivity(accountId);
3796
+ this.logInfo(
3797
+ 'inbound',
3798
+ `lifecycle ${JSON.stringify({
3799
+ stage: 'accepted',
3800
+ bridge: this.bridgeId,
3801
+ accountId,
3802
+ connId,
3803
+ clientId,
3804
+ onlineAfterSeen: this.isOnline(accountId),
3805
+ recentInboundReachable: this.hasRecentInboundReachability(accountId),
3806
+ activeConnectionKey: this.activeConnectionByAccount.get(accountId) || null,
3807
+ activeConnections: Array.from(this.connections.values())
3808
+ .filter((c) => c.accountId === accountId)
3809
+ .map((c) => ({
3810
+ connId: c.connId,
3811
+ clientId: c.clientId,
3812
+ connectedAt: c.connectedAt,
3813
+ lastSeenAt: c.lastSeenAt,
3814
+ })),
3815
+ })}`,
3816
+ { debugOnly: true },
3817
+ );
2797
3818
  this.lastInboundAtGlobal = now();
2798
3819
  this.incrementCounter(this.inboundEventsByAccount, accountId);
2799
3820
 
@@ -2811,7 +3832,7 @@ class BncrBridgeRuntime {
2811
3832
  return;
2812
3833
  }
2813
3834
 
2814
- const cfg = await this.api.runtime.config.loadConfig();
3835
+ const cfg = this.api.runtime.config.current();
2815
3836
  const gate = checkBncrMessageGate({
2816
3837
  parsed,
2817
3838
  cfg,
@@ -2876,6 +3897,7 @@ class BncrBridgeRuntime {
2876
3897
  msgId: msgId ?? null,
2877
3898
  taskKey: extracted.taskKey ?? null,
2878
3899
  });
3900
+ this.flushPushQueue(accountId);
2879
3901
 
2880
3902
  void dispatchBncrInbound({
2881
3903
  api: this.api,
@@ -2902,11 +3924,43 @@ class BncrBridgeRuntime {
2902
3924
 
2903
3925
  channelStartAccount = async (ctx: any) => {
2904
3926
  const accountId = normalizeAccountId(ctx.accountId);
3927
+ this.clearChannelAccountWorker(accountId, 'start-replace');
2905
3928
 
2906
3929
  const tick = () => {
2907
- const connected = this.isOnline(accountId);
2908
3930
  const previous = ctx.getStatus?.() || {};
2909
- const lastActAt = this.lastActivityByAccount.get(accountId) || previous?.lastEventAt || null;
3931
+ const onlineByConn = this.isOnline(accountId);
3932
+ const recentInboundReachable = this.hasRecentInboundReachability(accountId);
3933
+ const connected = onlineByConn || recentInboundReachable;
3934
+ const lastActAt =
3935
+ this.lastActivityByAccount.get(accountId) ||
3936
+ this.lastInboundByAccount.get(accountId) ||
3937
+ this.lastOutboundByAccount.get(accountId) ||
3938
+ previous?.lastEventAt ||
3939
+ null;
3940
+ this.logInfo(
3941
+ 'health',
3942
+ `status-tick ${JSON.stringify({
3943
+ bridge: this.bridgeId,
3944
+ accountId,
3945
+ connected,
3946
+ onlineByConn,
3947
+ recentInboundReachable,
3948
+ lastActivityAt: this.lastActivityByAccount.get(accountId) || null,
3949
+ lastInboundAt: this.lastInboundByAccount.get(accountId) || null,
3950
+ lastOutboundAt: this.lastOutboundByAccount.get(accountId) || null,
3951
+ chosenLastEventAt: lastActAt,
3952
+ activeConnectionKey: this.activeConnectionByAccount.get(accountId) || null,
3953
+ activeConnections: Array.from(this.connections.values())
3954
+ .filter((c) => c.accountId === accountId)
3955
+ .map((c) => ({
3956
+ connId: c.connId,
3957
+ clientId: c.clientId,
3958
+ connectedAt: c.connectedAt,
3959
+ lastSeenAt: c.lastSeenAt,
3960
+ })),
3961
+ })}`,
3962
+ { debugOnly: true },
3963
+ );
2910
3964
 
2911
3965
  ctx.setStatus?.({
2912
3966
  ...previous,
@@ -2923,13 +3977,30 @@ class BncrBridgeRuntime {
2923
3977
 
2924
3978
  tick();
2925
3979
  const timer = setInterval(tick, 5_000);
3980
+ this.channelAccountTimers.set(accountId, timer);
2926
3981
 
2927
3982
  await new Promise<void>((resolve) => {
2928
- const onAbort = () => {
2929
- clearInterval(timer);
3983
+ let settled = false;
3984
+ const finish = (reason: string) => {
3985
+ if (settled) return;
3986
+ settled = true;
3987
+ const activeTimer = this.channelAccountTimers.get(accountId);
3988
+ if (activeTimer === timer) {
3989
+ clearInterval(timer);
3990
+ this.channelAccountTimers.delete(accountId);
3991
+ } else {
3992
+ clearInterval(timer);
3993
+ }
3994
+ this.logInfo(
3995
+ 'health',
3996
+ `status-worker finished ${JSON.stringify({ bridge: this.bridgeId, accountId, reason })}`,
3997
+ { debugOnly: true },
3998
+ );
2930
3999
  resolve();
2931
4000
  };
2932
4001
 
4002
+ const onAbort = () => finish('abort');
4003
+
2933
4004
  if (ctx.abortSignal?.aborted) {
2934
4005
  onAbort();
2935
4006
  return;
@@ -2939,8 +4010,23 @@ class BncrBridgeRuntime {
2939
4010
  });
2940
4011
  };
2941
4012
 
2942
- channelStopAccount = async (_ctx: any) => {
2943
- // no-op
4013
+ channelStopAccount = async (ctx: any) => {
4014
+ const accountId = normalizeAccountId(ctx?.accountId);
4015
+ const cleared = this.clearChannelAccountWorker(accountId, 'explicit-stop');
4016
+ const previous = ctx?.getStatus?.() || {};
4017
+ ctx?.setStatus?.({
4018
+ ...previous,
4019
+ accountId,
4020
+ running: false,
4021
+ restartPending: false,
4022
+ lastStopAt: Date.now(),
4023
+ meta: this.buildStatusMeta(accountId),
4024
+ });
4025
+ this.logInfo(
4026
+ 'health',
4027
+ `status-stop ${JSON.stringify({ bridge: this.bridgeId, accountId, cleared })}`,
4028
+ { debugOnly: true },
4029
+ );
2944
4030
  };
2945
4031
 
2946
4032
  channelSendText = async (ctx: any) => {
@@ -3034,7 +4120,7 @@ export function createBncrBridge(api: OpenClawPluginApi) {
3034
4120
  return new BncrBridgeRuntime(api);
3035
4121
  }
3036
4122
 
3037
- export function createBncrChannelPlugin(bridge: BncrBridgeRuntime) {
4123
+ export function createBncrChannelPlugin(getBridge: () => BncrBridgeRuntime) {
3038
4124
  const messageActions: ChannelMessageActionAdapter = {
3039
4125
  describeMessageTool: ({ cfg }) => {
3040
4126
  const channelCfg = cfg?.channels?.[CHANNEL_ID];
@@ -3046,9 +4132,10 @@ export function createBncrChannelPlugin(bridge: BncrBridgeRuntime) {
3046
4132
  (accountId) => resolveAccount(cfg, accountId).enabled !== false,
3047
4133
  );
3048
4134
 
4135
+ const runtimeBridge = getBridge();
3049
4136
  const hasConnectedRuntime = listAccountIds(cfg).some((accountId) => {
3050
4137
  const resolved = resolveAccount(cfg, accountId);
3051
- const runtime = bridge.getAccountRuntimeSnapshot(resolved.accountId);
4138
+ const runtime = runtimeBridge.getAccountRuntimeSnapshot(resolved.accountId);
3052
4139
  return Boolean(runtime?.connected);
3053
4140
  });
3054
4141
 
@@ -3066,49 +4153,37 @@ export function createBncrChannelPlugin(bridge: BncrBridgeRuntime) {
3066
4153
  handleAction: async ({ action, params, accountId, mediaLocalRoots }) => {
3067
4154
  if (action !== 'send')
3068
4155
  throw new Error(`Action ${action} is not supported for provider ${CHANNEL_ID}.`);
3069
- const to = readStringParam(params, 'to', { required: true });
3070
- const message = readStringParam(params, 'message', { allowEmpty: true }) ?? '';
3071
- const caption = readStringParam(params, 'caption', { allowEmpty: true }) ?? '';
3072
- const content = message || caption || '';
3073
- const mediaUrl =
3074
- readStringParam(params, 'media', { trim: false }) ??
3075
- readStringParam(params, 'path', { trim: false }) ??
3076
- readStringParam(params, 'filePath', { trim: false }) ??
3077
- readStringParam(params, 'mediaUrl', { trim: false });
3078
- const asVoice = readBooleanParam(params, 'asVoice') ?? false;
3079
- const audioAsVoice = readBooleanParam(params, 'audioAsVoice') ?? false;
3080
- const resolvedAccountId = normalizeAccountId(
3081
- readStringParam(params, 'accountId') ?? accountId,
3082
- );
3083
-
3084
- if (!content.trim() && !mediaUrl) throw new Error('send requires text or media');
4156
+ const normalized = normalizeBncrSendParams({ params, accountId });
3085
4157
 
3086
- const result = mediaUrl
4158
+ const runtimeBridge = getBridge();
4159
+ const result = normalized.mediaUrl
3087
4160
  ? await sendBncrMedia({
3088
4161
  channelId: CHANNEL_ID,
3089
- accountId: resolvedAccountId,
3090
- to,
3091
- text: content,
3092
- mediaUrl,
3093
- asVoice,
3094
- audioAsVoice,
4162
+ accountId: normalized.accountId,
4163
+ to: normalized.to,
4164
+ text: normalized.caption,
4165
+ mediaUrl: normalized.mediaUrl,
4166
+ asVoice: normalized.asVoice,
4167
+ audioAsVoice: normalized.audioAsVoice,
3095
4168
  mediaLocalRoots,
3096
- resolveVerifiedTarget: (to, accountId) => bridge.resolveVerifiedTarget(to, accountId),
4169
+ resolveVerifiedTarget: (to, accountId) =>
4170
+ runtimeBridge.resolveVerifiedTarget(to, accountId),
3097
4171
  rememberSessionRoute: (sessionKey, accountId, route) =>
3098
- bridge.rememberSessionRoute(sessionKey, accountId, route),
3099
- enqueueFromReply: (args) => bridge.enqueueFromReply(args as any),
4172
+ runtimeBridge.rememberSessionRoute(sessionKey, accountId, route),
4173
+ enqueueFromReply: (args) => runtimeBridge.enqueueFromReply(args as any),
3100
4174
  createMessageId: () => randomUUID(),
3101
4175
  })
3102
4176
  : await sendBncrText({
3103
4177
  channelId: CHANNEL_ID,
3104
- accountId: resolvedAccountId,
3105
- to,
3106
- text: content,
4178
+ accountId: normalized.accountId,
4179
+ to: normalized.to,
4180
+ text: normalized.message,
3107
4181
  mediaLocalRoots,
3108
- resolveVerifiedTarget: (to, accountId) => bridge.resolveVerifiedTarget(to, accountId),
4182
+ resolveVerifiedTarget: (to, accountId) =>
4183
+ runtimeBridge.resolveVerifiedTarget(to, accountId),
3109
4184
  rememberSessionRoute: (sessionKey, accountId, route) =>
3110
- bridge.rememberSessionRoute(sessionKey, accountId, route),
3111
- enqueueFromReply: (args) => bridge.enqueueFromReply(args as any),
4185
+ runtimeBridge.rememberSessionRoute(sessionKey, accountId, route),
4186
+ enqueueFromReply: (args) => runtimeBridge.enqueueFromReply(args as any),
3112
4187
  createMessageId: () => randomUUID(),
3113
4188
  });
3114
4189
 
@@ -3143,9 +4218,10 @@ export function createBncrChannelPlugin(bridge: BncrBridgeRuntime) {
3143
4218
  const resolvedAccountId = normalizeAccountId(
3144
4219
  asString(accountId || BNCR_DEFAULT_ACCOUNT_ID),
3145
4220
  );
4221
+ const runtimeBridge = getBridge();
3146
4222
  const canonicalAgentId =
3147
- bridge.canonicalAgentId ||
3148
- bridge.ensureCanonicalAgentId({ cfg, accountId: resolvedAccountId });
4223
+ runtimeBridge.canonicalAgentId ||
4224
+ runtimeBridge.ensureCanonicalAgentId({ cfg, accountId: resolvedAccountId });
3149
4225
  return parseExplicitTarget(asString(raw).trim(), { canonicalAgentId });
3150
4226
  },
3151
4227
  formatTargetDisplay: ({ target }: any) => {
@@ -3157,13 +4233,14 @@ export function createBncrChannelPlugin(bridge: BncrBridgeRuntime) {
3157
4233
  const resolvedAccountId = normalizeAccountId(
3158
4234
  asString(accountId || BNCR_DEFAULT_ACCOUNT_ID),
3159
4235
  );
4236
+ const runtimeBridge = getBridge();
3160
4237
  const canonicalAgentId =
3161
- bridge.canonicalAgentId ||
3162
- bridge.ensureCanonicalAgentId({ cfg, accountId: resolvedAccountId });
4238
+ runtimeBridge.canonicalAgentId ||
4239
+ runtimeBridge.ensureCanonicalAgentId({ cfg, accountId: resolvedAccountId });
3163
4240
 
3164
4241
  let parsed = parseExplicitTarget(raw, { canonicalAgentId });
3165
4242
  if (!parsed) {
3166
- const route = bridge.resolveRouteBySession(raw, resolvedAccountId);
4243
+ const route = runtimeBridge.resolveRouteBySession(raw, resolvedAccountId);
3167
4244
  if (route) {
3168
4245
  parsed = parseExplicitTarget(formatDisplayScope(route), { canonicalAgentId });
3169
4246
  }
@@ -3174,13 +4251,15 @@ export function createBncrChannelPlugin(bridge: BncrBridgeRuntime) {
3174
4251
  const accountId = normalizeAccountId(
3175
4252
  asString(params?.accountId || BNCR_DEFAULT_ACCOUNT_ID),
3176
4253
  );
4254
+ const runtimeBridge = getBridge();
3177
4255
  const canonicalAgentId =
3178
- bridge.canonicalAgentId || bridge.ensureCanonicalAgentId({ cfg: params?.cfg, accountId });
4256
+ runtimeBridge.canonicalAgentId ||
4257
+ runtimeBridge.ensureCanonicalAgentId({ cfg: params?.cfg, accountId });
3179
4258
  return resolveBncrOutboundSessionRoute({
3180
4259
  ...params,
3181
4260
  canonicalAgentId,
3182
4261
  resolveRouteBySession: (raw: string, acc: string) =>
3183
- bridge.resolveRouteBySession(raw, acc),
4262
+ runtimeBridge.resolveRouteBySession(raw, acc),
3184
4263
  });
3185
4264
  },
3186
4265
  targetResolver: {
@@ -3188,11 +4267,12 @@ export function createBncrChannelPlugin(bridge: BncrBridgeRuntime) {
3188
4267
  return looksLikeBncrExplicitTarget(asString(normalized || raw).trim());
3189
4268
  },
3190
4269
  resolveTarget: async ({ accountId, input, normalized }) => {
4270
+ const runtimeBridge = getBridge();
3191
4271
  const resolved = resolveBncrOutboundTarget({
3192
4272
  target: asString(normalized || input).trim(),
3193
4273
  accountId: normalizeAccountId(asString(accountId || BNCR_DEFAULT_ACCOUNT_ID)),
3194
4274
  resolveRouteBySession: (raw: string, acc: string) =>
3195
- this.resolveRouteBySession(raw, acc),
4275
+ runtimeBridge.resolveRouteBySession(raw, acc),
3196
4276
  });
3197
4277
  if (!resolved) return null;
3198
4278
  return {
@@ -3255,8 +4335,8 @@ export function createBncrChannelPlugin(bridge: BncrBridgeRuntime) {
3255
4335
  },
3256
4336
  outbound: {
3257
4337
  deliveryMode: 'gateway' as const,
3258
- sendText: bridge.channelSendText,
3259
- sendMedia: bridge.channelSendMedia,
4338
+ sendText: async (ctx: any) => getBridge().channelSendText(ctx),
4339
+ sendMedia: async (ctx: any) => getBridge().channelSendMedia(ctx),
3260
4340
  replyAction: async (ctx: any) =>
3261
4341
  sendBncrReplyAction({
3262
4342
  accountId: normalizeAccountId(ctx?.accountId),
@@ -3265,7 +4345,7 @@ export function createBncrChannelPlugin(bridge: BncrBridgeRuntime) {
3265
4345
  replyToMessageId:
3266
4346
  asString(ctx?.replyToId || ctx?.replyToMessageId || '').trim() || undefined,
3267
4347
  sendText: async ({ accountId, to, text }) =>
3268
- bridge.channelSendText({ accountId, to, text }),
4348
+ getBridge().channelSendText({ accountId, to, text }),
3269
4349
  }),
3270
4350
  deleteAction: async (ctx: any) =>
3271
4351
  deleteBncrMessageAction({
@@ -3290,10 +4370,11 @@ export function createBncrChannelPlugin(bridge: BncrBridgeRuntime) {
3290
4370
  mode: 'ws-offline',
3291
4371
  }),
3292
4372
  buildChannelSummary: async ({ defaultAccountId }: any) => {
3293
- return bridge.getChannelSummary(defaultAccountId || BNCR_DEFAULT_ACCOUNT_ID);
4373
+ return getBridge().getChannelSummary(defaultAccountId || BNCR_DEFAULT_ACCOUNT_ID);
3294
4374
  },
3295
4375
  buildAccountSnapshot: async ({ account, runtime }: any) => {
3296
- const rt = runtime || bridge.getAccountRuntimeSnapshot(account?.accountId);
4376
+ const runtimeBridge = getBridge();
4377
+ const rt = runtime || runtimeBridge.getAccountRuntimeSnapshot(account?.accountId);
3297
4378
  const meta = rt?.meta || {};
3298
4379
 
3299
4380
  const pending = Number(rt?.pending ?? meta.pending ?? 0);
@@ -3328,7 +4409,7 @@ export function createBncrChannelPlugin(bridge: BncrBridgeRuntime) {
3328
4409
  mode: normalizedMode,
3329
4410
  pending,
3330
4411
  deadLetter,
3331
- healthSummary: bridge.getStatusHeadline(account?.accountId),
4412
+ healthSummary: runtimeBridge.getStatusHeadline(account?.accountId),
3332
4413
  lastSessionKey,
3333
4414
  lastSessionScope,
3334
4415
  lastSessionAt,
@@ -3346,7 +4427,7 @@ export function createBncrChannelPlugin(bridge: BncrBridgeRuntime) {
3346
4427
  if (!enabled) return 'disabled';
3347
4428
  const resolved = resolveAccount(cfg, account?.accountId);
3348
4429
  if (!(resolved.enabled && configured)) return 'not configured';
3349
- const rt = runtime || bridge.getAccountRuntimeSnapshot(account?.accountId);
4430
+ const rt = runtime || getBridge().getAccountRuntimeSnapshot(account?.accountId);
3350
4431
  return rt?.connected ? 'linked' : 'configured';
3351
4432
  },
3352
4433
  },
@@ -3363,8 +4444,8 @@ export function createBncrChannelPlugin(bridge: BncrBridgeRuntime) {
3363
4444
  'bncr.file.ack',
3364
4445
  ],
3365
4446
  gateway: {
3366
- startAccount: bridge.channelStartAccount,
3367
- stopAccount: bridge.channelStopAccount,
4447
+ startAccount: async (ctx: any) => getBridge().channelStartAccount(ctx),
4448
+ stopAccount: async (ctx: any) => getBridge().channelStopAccount(ctx),
3368
4449
  },
3369
4450
  };
3370
4451