@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/README.md +1 -1
- package/index.ts +167 -18
- package/package.json +3 -3
- package/src/channel.ts +766 -99
- package/src/core/types.ts +15 -1
- package/src/messaging/outbound/build-send-action.ts +115 -0
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
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
|
|
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
|
-
|
|
2428
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
3343
|
-
|
|
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 (
|
|
3357
|
-
|
|
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(
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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:
|
|
3504
|
-
to,
|
|
3505
|
-
text:
|
|
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) =>
|
|
4169
|
+
resolveVerifiedTarget: (to, accountId) =>
|
|
4170
|
+
runtimeBridge.resolveVerifiedTarget(to, accountId),
|
|
3511
4171
|
rememberSessionRoute: (sessionKey, accountId, route) =>
|
|
3512
|
-
|
|
3513
|
-
enqueueFromReply: (args) =>
|
|
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:
|
|
3519
|
-
to,
|
|
3520
|
-
text:
|
|
4178
|
+
accountId: normalized.accountId,
|
|
4179
|
+
to: normalized.to,
|
|
4180
|
+
text: normalized.message,
|
|
3521
4181
|
mediaLocalRoots,
|
|
3522
|
-
resolveVerifiedTarget: (to, accountId) =>
|
|
4182
|
+
resolveVerifiedTarget: (to, accountId) =>
|
|
4183
|
+
runtimeBridge.resolveVerifiedTarget(to, accountId),
|
|
3523
4184
|
rememberSessionRoute: (sessionKey, accountId, route) =>
|
|
3524
|
-
|
|
3525
|
-
enqueueFromReply: (args) =>
|
|
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
|
-
|
|
3562
|
-
|
|
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
|
-
|
|
3576
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
3673
|
-
sendMedia:
|
|
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
|
-
|
|
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
|
|
4373
|
+
return getBridge().getChannelSummary(defaultAccountId || BNCR_DEFAULT_ACCOUNT_ID);
|
|
3708
4374
|
},
|
|
3709
4375
|
buildAccountSnapshot: async ({ account, runtime }: any) => {
|
|
3710
|
-
const
|
|
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:
|
|
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 ||
|
|
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:
|
|
3781
|
-
stopAccount:
|
|
4447
|
+
startAccount: async (ctx: any) => getBridge().channelStartAccount(ctx),
|
|
4448
|
+
stopAccount: async (ctx: any) => getBridge().channelStopAccount(ctx),
|
|
3782
4449
|
},
|
|
3783
4450
|
};
|
|
3784
4451
|
|