@xmoxmo/bncr 0.2.2 → 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
@@ -135,6 +135,12 @@ type FileRecvTransferState = {
135
135
  error?: string;
136
136
  };
137
137
 
138
+ type FileAckPayloadState = {
139
+ payload: Record<string, unknown>;
140
+ ok: boolean;
141
+ at: number;
142
+ };
143
+
138
144
  type ChatType = 'direct' | 'group' | (string & {});
139
145
 
140
146
  type ChannelMessageActionAdapter = {
@@ -190,6 +196,56 @@ type PersistedState = {
190
196
  } | null;
191
197
  };
192
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
+
193
249
  function now() {
194
250
  return Date.now();
195
251
  }
@@ -200,6 +256,10 @@ function asString(v: unknown, fallback = ''): string {
200
256
  return String(v);
201
257
  }
202
258
 
259
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
260
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
261
+ }
262
+
203
263
  function backoffMs(retryCount: number): number {
204
264
  // 1s,2s,4s,8s... capped by retry count checks
205
265
  return Math.max(1_000, 1_000 * 2 ** Math.max(0, retryCount - 1));
@@ -349,6 +409,7 @@ class BncrBridgeRuntime {
349
409
  private lastActivityByAccount = new Map<string, number>();
350
410
  private lastInboundByAccount = new Map<string, number>();
351
411
  private lastOutboundByAccount = new Map<string, number>();
412
+ private channelAccountTimers = new Map<string, NodeJS.Timeout>();
352
413
  private canonicalAgentId: string | null = null;
353
414
  private canonicalAgentSource: 'startup' | 'runtime' | 'fallback-main' | null = null;
354
415
  private canonicalAgentResolvedAt: number | null = null;
@@ -383,6 +444,7 @@ class BncrBridgeRuntime {
383
444
  timer: NodeJS.Timeout;
384
445
  }
385
446
  >();
447
+ private earlyFileAcks = new Map<string, FileAckPayloadState>();
386
448
 
387
449
  constructor(api: OpenClawPluginApi) {
388
450
  this.api = api;
@@ -441,6 +503,19 @@ class BncrBridgeRuntime {
441
503
  this.logInfo('outbound', [type, this.summarizeScope(entry.route), preview].join('|'));
442
504
  }
443
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
+
444
519
  private classifyRegisterTrace(stack: string) {
445
520
  if (
446
521
  stack.includes('prepareSecretsRuntimeSnapshot') ||
@@ -821,6 +896,7 @@ class BncrBridgeRuntime {
821
896
  clearTimeout(waiter.timer);
822
897
  }
823
898
  this.fileAckWaiters.clear();
899
+ this.earlyFileAcks.clear();
824
900
  }
825
901
 
826
902
  private scheduleSave() {
@@ -1319,7 +1395,255 @@ class BncrBridgeRuntime {
1319
1395
  return connIds;
1320
1396
  }
1321
1397
 
1322
- private tryPushEntry(entry: OutboxEntry): boolean {
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
+
1323
1647
  const ctx = this.gatewayContext;
1324
1648
  if (!ctx) {
1325
1649
  this.logInfo(
@@ -1508,7 +1832,7 @@ class BncrBridgeRuntime {
1508
1832
  }
1509
1833
 
1510
1834
  const onlineNow = this.isOnline(acc) || this.hasRecentInboundReachability(acc);
1511
- const pushed = this.tryPushEntry(entry);
1835
+ const pushed = await this.tryPushEntry(entry);
1512
1836
  if (pushed) {
1513
1837
  const requireAck = this.isOutboundAckRequired(acc);
1514
1838
  let ackResult: 'acked' | 'timeout' = requireAck ? 'timeout' : 'acked';
@@ -1558,6 +1882,11 @@ class BncrBridgeRuntime {
1558
1882
  break;
1559
1883
  }
1560
1884
 
1885
+ if (!this.outbox.has(entry.messageId)) {
1886
+ await this.sleepMs(PUSH_DRAIN_INTERVAL_MS);
1887
+ continue;
1888
+ }
1889
+
1561
1890
  const nextAttempt = entry.retryCount + 1;
1562
1891
  if (nextAttempt > MAX_RETRY) {
1563
1892
  this.moveToDeadLetter(entry, entry.lastError || 'push-retry-limit');
@@ -1650,6 +1979,9 @@ class BncrBridgeRuntime {
1650
1979
  for (const [id, st] of this.fileRecvTransfers.entries()) {
1651
1980
  if (t - st.startedAt > FILE_TRANSFER_KEEP_MS) this.fileRecvTransfers.delete(id);
1652
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
+ }
1653
1985
  }
1654
1986
 
1655
1987
  private markSeen(accountId: string, connId: string, clientId?: string) {
@@ -1659,6 +1991,8 @@ class BncrBridgeRuntime {
1659
1991
  const key = this.connectionKey(acc, clientId);
1660
1992
  const t = now();
1661
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;
1662
1996
 
1663
1997
  const nextConn: BncrConnection = {
1664
1998
  accountId: acc,
@@ -1685,6 +2019,27 @@ class BncrBridgeRuntime {
1685
2019
  const current = this.activeConnectionByAccount.get(acc);
1686
2020
  if (!current) {
1687
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
+ );
1688
2043
  return;
1689
2044
  }
1690
2045
 
@@ -1695,6 +2050,31 @@ class BncrBridgeRuntime {
1695
2050
  nextConn.connectedAt >= curConn.connectedAt
1696
2051
  ) {
1697
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
+ );
1698
2078
  }
1699
2079
  }
1700
2080
 
@@ -1858,9 +2238,61 @@ class BncrBridgeRuntime {
1858
2238
  Math.min(Number(params.timeoutMs || FILE_ACK_TIMEOUT_MS), 120_000),
1859
2239
  );
1860
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
+
1861
2280
  return new Promise<Record<string, unknown>>((resolve, reject) => {
1862
2281
  const timer = setTimeout(() => {
1863
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
+ );
1864
2296
  reject(new Error(`file ack timeout: ${key}`));
1865
2297
  }, timeoutMs);
1866
2298
  this.fileAckWaiters.set(key, { resolve, reject, timer });
@@ -1878,9 +2310,45 @@ class BncrBridgeRuntime {
1878
2310
  const stage = asString(params.stage).trim();
1879
2311
  const key = this.fileAckKey(transferId, stage, params.chunkIndex);
1880
2312
  const waiter = this.fileAckWaiters.get(key);
1881
- 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
+ }
1882
2336
  this.fileAckWaiters.delete(key);
1883
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
+ );
1884
2352
  if (params.ok) waiter.resolve(params.payload);
1885
2353
  else
1886
2354
  waiter.reject(
@@ -2254,9 +2722,46 @@ class BncrBridgeRuntime {
2254
2722
  }
2255
2723
 
2256
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
+ );
2257
2759
  if (!ctx) throw new Error('gateway context unavailable');
2258
2760
 
2259
- const connIds = this.resolvePushConnIds(params.accountId);
2761
+ let connIds = directConnIds;
2762
+ if (!connIds.size && recentInboundReachable) {
2763
+ connIds = recentConnIds;
2764
+ }
2260
2765
  if (!connIds.size) throw new Error('no active bncr client for file chunk transfer');
2261
2766
 
2262
2767
  const transferId = randomUUID();
@@ -2264,7 +2769,26 @@ class BncrBridgeRuntime {
2264
2769
  const totalChunks = Math.ceil(size / chunkSize);
2265
2770
  const fileSha256 = createHash('sha256').update(loaded.buffer).digest('hex');
2266
2771
 
2267
- const owner = this.resolveOutboxPushOwner(params.accountId);
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
+
2268
2792
  const st: FileSendTransferState = {
2269
2793
  transferId,
2270
2794
  accountId: normalizeAccountId(params.accountId),
@@ -2329,16 +2853,54 @@ class BncrBridgeRuntime {
2329
2853
  connIds,
2330
2854
  );
2331
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
+
2332
2871
  try {
2333
2872
  await this.waitChunkAck({
2334
2873
  transferId,
2335
2874
  chunkIndex: idx,
2336
2875
  timeoutMs: FILE_TRANSFER_ACK_TTL_MS,
2337
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
+ );
2338
2888
  ok = true;
2339
2889
  break;
2340
2890
  } catch (err) {
2341
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
+ );
2342
2904
  await this.sleepMs(150 * attempt);
2343
2905
  }
2344
2906
  }
@@ -2369,8 +2931,30 @@ class BncrBridgeRuntime {
2369
2931
  connIds,
2370
2932
  );
2371
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
+
2372
2945
  const done = await this.waitCompleteAck({ transferId, timeoutMs: 60_000 });
2373
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
+
2374
2958
  return {
2375
2959
  mode: 'chunk',
2376
2960
  mimeType,
@@ -2405,44 +2989,20 @@ class BncrBridgeRuntime {
2405
2989
  if (mediaList.length > 0) {
2406
2990
  let first = true;
2407
2991
  for (const mediaUrl of mediaList) {
2408
- const media = await this.transferMediaToBncrClient({
2409
- accountId,
2410
- sessionKey,
2411
- route,
2412
- mediaUrl,
2413
- mediaLocalRoots,
2414
- });
2415
- const messageId = randomUUID();
2416
- const mediaMsg = first ? asString(payload.text || '') : '';
2417
- const wantsVoice = payload.asVoice === true || payload.audioAsVoice === true;
2418
- const frame = buildBncrMediaOutboundFrame({
2419
- messageId,
2420
- sessionKey,
2421
- route,
2422
- media,
2423
- mediaUrl,
2424
- mediaMsg,
2425
- fileName: resolveOutboundFileName({
2992
+ this.enqueueOutbound(
2993
+ this.buildFileTransferOutboxEntry({
2994
+ accountId,
2995
+ sessionKey,
2996
+ route,
2426
2997
  mediaUrl,
2427
- fileName: media.fileName,
2428
- 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,
2429
3004
  }),
2430
- hintedType: wantsVoice ? 'voice' : undefined,
2431
- kind: payload.kind,
2432
- replyToId: asString(payload.replyToId || '').trim() || undefined,
2433
- now: now(),
2434
- });
2435
-
2436
- this.enqueueOutbound({
2437
- messageId,
2438
- accountId: normalizeAccountId(accountId),
2439
- sessionKey,
2440
- route,
2441
- payload: frame,
2442
- createdAt: now(),
2443
- retryCount: 0,
2444
- nextAttemptAt: now(),
2445
- });
3005
+ );
2446
3006
  first = false;
2447
3007
  }
2448
3008
  return;
@@ -3073,6 +3633,24 @@ class BncrBridgeRuntime {
3073
3633
  const ok = params?.ok !== false;
3074
3634
  const chunkIndex = Number(params?.chunkIndex ?? -1);
3075
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
+
3076
3654
  if (!transferId || !stage) {
3077
3655
  respond(false, { error: 'transferId/stage required' });
3078
3656
  return;
@@ -3092,7 +3670,15 @@ class BncrBridgeRuntime {
3092
3670
  const sameConn = !!st?.ownerConnId && st.ownerConnId === connId;
3093
3671
  const sameClient =
3094
3672
  !st?.ownerConnId && !!st?.ownerClientId && !!clientId && st.ownerClientId === clientId;
3095
- if (!(sameConn || sameClient)) {
3673
+ const adopted =
3674
+ !(sameConn || sameClient) &&
3675
+ this.tryAdoptTransferOwner({
3676
+ accountId,
3677
+ transfer: st,
3678
+ connId,
3679
+ clientId,
3680
+ });
3681
+ if (!(sameConn || sameClient || adopted)) {
3096
3682
  this.logWarn(
3097
3683
  'stale',
3098
3684
  `ignore kind=file.ack accountId=${accountId} connId=${connId} clientId=${clientId || '-'} transferId=${transferId} stage=${stage} reason=owner-mismatch ownerConnId=${st?.ownerConnId || '-'} ownerClientId=${st?.ownerClientId || '-'}`,
@@ -3207,6 +3793,28 @@ class BncrBridgeRuntime {
3207
3793
  this.rememberGatewayContext(context);
3208
3794
  this.markSeen(accountId, connId, clientId);
3209
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
+ );
3210
3818
  this.lastInboundAtGlobal = now();
3211
3819
  this.incrementCounter(this.inboundEventsByAccount, accountId);
3212
3820
 
@@ -3316,11 +3924,43 @@ class BncrBridgeRuntime {
3316
3924
 
3317
3925
  channelStartAccount = async (ctx: any) => {
3318
3926
  const accountId = normalizeAccountId(ctx.accountId);
3927
+ this.clearChannelAccountWorker(accountId, 'start-replace');
3319
3928
 
3320
3929
  const tick = () => {
3321
- const connected = this.isOnline(accountId);
3322
3930
  const previous = ctx.getStatus?.() || {};
3323
- 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
+ );
3324
3964
 
3325
3965
  ctx.setStatus?.({
3326
3966
  ...previous,
@@ -3337,13 +3977,30 @@ class BncrBridgeRuntime {
3337
3977
 
3338
3978
  tick();
3339
3979
  const timer = setInterval(tick, 5_000);
3980
+ this.channelAccountTimers.set(accountId, timer);
3340
3981
 
3341
3982
  await new Promise<void>((resolve) => {
3342
- const onAbort = () => {
3343
- 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
+ );
3344
3999
  resolve();
3345
4000
  };
3346
4001
 
4002
+ const onAbort = () => finish('abort');
4003
+
3347
4004
  if (ctx.abortSignal?.aborted) {
3348
4005
  onAbort();
3349
4006
  return;
@@ -3353,8 +4010,23 @@ class BncrBridgeRuntime {
3353
4010
  });
3354
4011
  };
3355
4012
 
3356
- channelStopAccount = async (_ctx: any) => {
3357
- // 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
+ );
3358
4030
  };
3359
4031
 
3360
4032
  channelSendText = async (ctx: any) => {
@@ -3448,7 +4120,7 @@ export function createBncrBridge(api: OpenClawPluginApi) {
3448
4120
  return new BncrBridgeRuntime(api);
3449
4121
  }
3450
4122
 
3451
- export function createBncrChannelPlugin(bridge: BncrBridgeRuntime) {
4123
+ export function createBncrChannelPlugin(getBridge: () => BncrBridgeRuntime) {
3452
4124
  const messageActions: ChannelMessageActionAdapter = {
3453
4125
  describeMessageTool: ({ cfg }) => {
3454
4126
  const channelCfg = cfg?.channels?.[CHANNEL_ID];
@@ -3460,9 +4132,10 @@ export function createBncrChannelPlugin(bridge: BncrBridgeRuntime) {
3460
4132
  (accountId) => resolveAccount(cfg, accountId).enabled !== false,
3461
4133
  );
3462
4134
 
4135
+ const runtimeBridge = getBridge();
3463
4136
  const hasConnectedRuntime = listAccountIds(cfg).some((accountId) => {
3464
4137
  const resolved = resolveAccount(cfg, accountId);
3465
- const runtime = bridge.getAccountRuntimeSnapshot(resolved.accountId);
4138
+ const runtime = runtimeBridge.getAccountRuntimeSnapshot(resolved.accountId);
3466
4139
  return Boolean(runtime?.connected);
3467
4140
  });
3468
4141
 
@@ -3480,49 +4153,37 @@ export function createBncrChannelPlugin(bridge: BncrBridgeRuntime) {
3480
4153
  handleAction: async ({ action, params, accountId, mediaLocalRoots }) => {
3481
4154
  if (action !== 'send')
3482
4155
  throw new Error(`Action ${action} is not supported for provider ${CHANNEL_ID}.`);
3483
- const to = readStringParam(params, 'to', { required: true });
3484
- const message = readStringParam(params, 'message', { allowEmpty: true }) ?? '';
3485
- const caption = readStringParam(params, 'caption', { allowEmpty: true }) ?? '';
3486
- const content = message || caption || '';
3487
- const mediaUrl =
3488
- readStringParam(params, 'media', { trim: false }) ??
3489
- readStringParam(params, 'path', { trim: false }) ??
3490
- readStringParam(params, 'filePath', { trim: false }) ??
3491
- readStringParam(params, 'mediaUrl', { trim: false });
3492
- const asVoice = readBooleanParam(params, 'asVoice') ?? false;
3493
- const audioAsVoice = readBooleanParam(params, 'audioAsVoice') ?? false;
3494
- const resolvedAccountId = normalizeAccountId(
3495
- readStringParam(params, 'accountId') ?? accountId,
3496
- );
4156
+ const normalized = normalizeBncrSendParams({ params, accountId });
3497
4157
 
3498
- if (!content.trim() && !mediaUrl) throw new Error('send requires text or media');
3499
-
3500
- const result = mediaUrl
4158
+ const runtimeBridge = getBridge();
4159
+ const result = normalized.mediaUrl
3501
4160
  ? await sendBncrMedia({
3502
4161
  channelId: CHANNEL_ID,
3503
- accountId: resolvedAccountId,
3504
- to,
3505
- text: content,
3506
- mediaUrl,
3507
- asVoice,
3508
- 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,
3509
4168
  mediaLocalRoots,
3510
- resolveVerifiedTarget: (to, accountId) => bridge.resolveVerifiedTarget(to, accountId),
4169
+ resolveVerifiedTarget: (to, accountId) =>
4170
+ runtimeBridge.resolveVerifiedTarget(to, accountId),
3511
4171
  rememberSessionRoute: (sessionKey, accountId, route) =>
3512
- bridge.rememberSessionRoute(sessionKey, accountId, route),
3513
- enqueueFromReply: (args) => bridge.enqueueFromReply(args as any),
4172
+ runtimeBridge.rememberSessionRoute(sessionKey, accountId, route),
4173
+ enqueueFromReply: (args) => runtimeBridge.enqueueFromReply(args as any),
3514
4174
  createMessageId: () => randomUUID(),
3515
4175
  })
3516
4176
  : await sendBncrText({
3517
4177
  channelId: CHANNEL_ID,
3518
- accountId: resolvedAccountId,
3519
- to,
3520
- text: content,
4178
+ accountId: normalized.accountId,
4179
+ to: normalized.to,
4180
+ text: normalized.message,
3521
4181
  mediaLocalRoots,
3522
- resolveVerifiedTarget: (to, accountId) => bridge.resolveVerifiedTarget(to, accountId),
4182
+ resolveVerifiedTarget: (to, accountId) =>
4183
+ runtimeBridge.resolveVerifiedTarget(to, accountId),
3523
4184
  rememberSessionRoute: (sessionKey, accountId, route) =>
3524
- bridge.rememberSessionRoute(sessionKey, accountId, route),
3525
- enqueueFromReply: (args) => bridge.enqueueFromReply(args as any),
4185
+ runtimeBridge.rememberSessionRoute(sessionKey, accountId, route),
4186
+ enqueueFromReply: (args) => runtimeBridge.enqueueFromReply(args as any),
3526
4187
  createMessageId: () => randomUUID(),
3527
4188
  });
3528
4189
 
@@ -3557,9 +4218,10 @@ export function createBncrChannelPlugin(bridge: BncrBridgeRuntime) {
3557
4218
  const resolvedAccountId = normalizeAccountId(
3558
4219
  asString(accountId || BNCR_DEFAULT_ACCOUNT_ID),
3559
4220
  );
4221
+ const runtimeBridge = getBridge();
3560
4222
  const canonicalAgentId =
3561
- bridge.canonicalAgentId ||
3562
- bridge.ensureCanonicalAgentId({ cfg, accountId: resolvedAccountId });
4223
+ runtimeBridge.canonicalAgentId ||
4224
+ runtimeBridge.ensureCanonicalAgentId({ cfg, accountId: resolvedAccountId });
3563
4225
  return parseExplicitTarget(asString(raw).trim(), { canonicalAgentId });
3564
4226
  },
3565
4227
  formatTargetDisplay: ({ target }: any) => {
@@ -3571,13 +4233,14 @@ export function createBncrChannelPlugin(bridge: BncrBridgeRuntime) {
3571
4233
  const resolvedAccountId = normalizeAccountId(
3572
4234
  asString(accountId || BNCR_DEFAULT_ACCOUNT_ID),
3573
4235
  );
4236
+ const runtimeBridge = getBridge();
3574
4237
  const canonicalAgentId =
3575
- bridge.canonicalAgentId ||
3576
- bridge.ensureCanonicalAgentId({ cfg, accountId: resolvedAccountId });
4238
+ runtimeBridge.canonicalAgentId ||
4239
+ runtimeBridge.ensureCanonicalAgentId({ cfg, accountId: resolvedAccountId });
3577
4240
 
3578
4241
  let parsed = parseExplicitTarget(raw, { canonicalAgentId });
3579
4242
  if (!parsed) {
3580
- const route = bridge.resolveRouteBySession(raw, resolvedAccountId);
4243
+ const route = runtimeBridge.resolveRouteBySession(raw, resolvedAccountId);
3581
4244
  if (route) {
3582
4245
  parsed = parseExplicitTarget(formatDisplayScope(route), { canonicalAgentId });
3583
4246
  }
@@ -3588,13 +4251,15 @@ export function createBncrChannelPlugin(bridge: BncrBridgeRuntime) {
3588
4251
  const accountId = normalizeAccountId(
3589
4252
  asString(params?.accountId || BNCR_DEFAULT_ACCOUNT_ID),
3590
4253
  );
4254
+ const runtimeBridge = getBridge();
3591
4255
  const canonicalAgentId =
3592
- bridge.canonicalAgentId || bridge.ensureCanonicalAgentId({ cfg: params?.cfg, accountId });
4256
+ runtimeBridge.canonicalAgentId ||
4257
+ runtimeBridge.ensureCanonicalAgentId({ cfg: params?.cfg, accountId });
3593
4258
  return resolveBncrOutboundSessionRoute({
3594
4259
  ...params,
3595
4260
  canonicalAgentId,
3596
4261
  resolveRouteBySession: (raw: string, acc: string) =>
3597
- bridge.resolveRouteBySession(raw, acc),
4262
+ runtimeBridge.resolveRouteBySession(raw, acc),
3598
4263
  });
3599
4264
  },
3600
4265
  targetResolver: {
@@ -3602,11 +4267,12 @@ export function createBncrChannelPlugin(bridge: BncrBridgeRuntime) {
3602
4267
  return looksLikeBncrExplicitTarget(asString(normalized || raw).trim());
3603
4268
  },
3604
4269
  resolveTarget: async ({ accountId, input, normalized }) => {
4270
+ const runtimeBridge = getBridge();
3605
4271
  const resolved = resolveBncrOutboundTarget({
3606
4272
  target: asString(normalized || input).trim(),
3607
4273
  accountId: normalizeAccountId(asString(accountId || BNCR_DEFAULT_ACCOUNT_ID)),
3608
4274
  resolveRouteBySession: (raw: string, acc: string) =>
3609
- this.resolveRouteBySession(raw, acc),
4275
+ runtimeBridge.resolveRouteBySession(raw, acc),
3610
4276
  });
3611
4277
  if (!resolved) return null;
3612
4278
  return {
@@ -3669,8 +4335,8 @@ export function createBncrChannelPlugin(bridge: BncrBridgeRuntime) {
3669
4335
  },
3670
4336
  outbound: {
3671
4337
  deliveryMode: 'gateway' as const,
3672
- sendText: bridge.channelSendText,
3673
- sendMedia: bridge.channelSendMedia,
4338
+ sendText: async (ctx: any) => getBridge().channelSendText(ctx),
4339
+ sendMedia: async (ctx: any) => getBridge().channelSendMedia(ctx),
3674
4340
  replyAction: async (ctx: any) =>
3675
4341
  sendBncrReplyAction({
3676
4342
  accountId: normalizeAccountId(ctx?.accountId),
@@ -3679,7 +4345,7 @@ export function createBncrChannelPlugin(bridge: BncrBridgeRuntime) {
3679
4345
  replyToMessageId:
3680
4346
  asString(ctx?.replyToId || ctx?.replyToMessageId || '').trim() || undefined,
3681
4347
  sendText: async ({ accountId, to, text }) =>
3682
- bridge.channelSendText({ accountId, to, text }),
4348
+ getBridge().channelSendText({ accountId, to, text }),
3683
4349
  }),
3684
4350
  deleteAction: async (ctx: any) =>
3685
4351
  deleteBncrMessageAction({
@@ -3704,10 +4370,11 @@ export function createBncrChannelPlugin(bridge: BncrBridgeRuntime) {
3704
4370
  mode: 'ws-offline',
3705
4371
  }),
3706
4372
  buildChannelSummary: async ({ defaultAccountId }: any) => {
3707
- return bridge.getChannelSummary(defaultAccountId || BNCR_DEFAULT_ACCOUNT_ID);
4373
+ return getBridge().getChannelSummary(defaultAccountId || BNCR_DEFAULT_ACCOUNT_ID);
3708
4374
  },
3709
4375
  buildAccountSnapshot: async ({ account, runtime }: any) => {
3710
- const rt = runtime || bridge.getAccountRuntimeSnapshot(account?.accountId);
4376
+ const runtimeBridge = getBridge();
4377
+ const rt = runtime || runtimeBridge.getAccountRuntimeSnapshot(account?.accountId);
3711
4378
  const meta = rt?.meta || {};
3712
4379
 
3713
4380
  const pending = Number(rt?.pending ?? meta.pending ?? 0);
@@ -3742,7 +4409,7 @@ export function createBncrChannelPlugin(bridge: BncrBridgeRuntime) {
3742
4409
  mode: normalizedMode,
3743
4410
  pending,
3744
4411
  deadLetter,
3745
- healthSummary: bridge.getStatusHeadline(account?.accountId),
4412
+ healthSummary: runtimeBridge.getStatusHeadline(account?.accountId),
3746
4413
  lastSessionKey,
3747
4414
  lastSessionScope,
3748
4415
  lastSessionAt,
@@ -3760,7 +4427,7 @@ export function createBncrChannelPlugin(bridge: BncrBridgeRuntime) {
3760
4427
  if (!enabled) return 'disabled';
3761
4428
  const resolved = resolveAccount(cfg, account?.accountId);
3762
4429
  if (!(resolved.enabled && configured)) return 'not configured';
3763
- const rt = runtime || bridge.getAccountRuntimeSnapshot(account?.accountId);
4430
+ const rt = runtime || getBridge().getAccountRuntimeSnapshot(account?.accountId);
3764
4431
  return rt?.connected ? 'linked' : 'configured';
3765
4432
  },
3766
4433
  },
@@ -3777,8 +4444,8 @@ export function createBncrChannelPlugin(bridge: BncrBridgeRuntime) {
3777
4444
  'bncr.file.ack',
3778
4445
  ],
3779
4446
  gateway: {
3780
- startAccount: bridge.channelStartAccount,
3781
- stopAccount: bridge.channelStopAccount,
4447
+ startAccount: async (ctx: any) => getBridge().channelStartAccount(ctx),
4448
+ stopAccount: async (ctx: any) => getBridge().channelStopAccount(ctx),
3782
4449
  },
3783
4450
  };
3784
4451