@xmoxmo/bncr 0.0.2 → 0.0.4
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 +45 -315
- package/index.ts +24 -0
- package/package.json +1 -1
- package/src/channel.ts +1078 -79
- package/LICENSE +0 -21
package/src/channel.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
1
2
|
import path from 'node:path';
|
|
2
3
|
import { createHash, randomUUID } from 'node:crypto';
|
|
3
4
|
import type {
|
|
@@ -20,6 +21,15 @@ const BRIDGE_VERSION = 2;
|
|
|
20
21
|
const BNCR_PUSH_EVENT = 'bncr.push';
|
|
21
22
|
const CONNECT_TTL_MS = 120_000;
|
|
22
23
|
const MAX_RETRY = 10;
|
|
24
|
+
const PUSH_DRAIN_INTERVAL_MS = 500;
|
|
25
|
+
const FILE_FORCE_CHUNK = true; // 统一走 WS 分块,保留 base64 仅作兜底
|
|
26
|
+
const FILE_INLINE_THRESHOLD = 5 * 1024 * 1024; // fallback 阈值(仅 FILE_FORCE_CHUNK=false 时生效)
|
|
27
|
+
const FILE_CHUNK_SIZE = 256 * 1024; // 256KB
|
|
28
|
+
const FILE_CHUNK_RETRY = 3;
|
|
29
|
+
const FILE_ACK_TIMEOUT_MS = 30_000;
|
|
30
|
+
const FILE_TRANSFER_ACK_TTL_MS = 30_000;
|
|
31
|
+
const FILE_TRANSFER_KEEP_MS = 6 * 60 * 60 * 1000;
|
|
32
|
+
const BNCR_DEBUG_VERBOSE = true; // 临时调试:打印发送入口完整请求体
|
|
23
33
|
|
|
24
34
|
const BncrConfigSchema = {
|
|
25
35
|
schema: {
|
|
@@ -68,6 +78,44 @@ type OutboxEntry = {
|
|
|
68
78
|
lastError?: string;
|
|
69
79
|
};
|
|
70
80
|
|
|
81
|
+
type FileSendTransferState = {
|
|
82
|
+
transferId: string;
|
|
83
|
+
accountId: string;
|
|
84
|
+
sessionKey: string;
|
|
85
|
+
route: BncrRoute;
|
|
86
|
+
fileName: string;
|
|
87
|
+
mimeType: string;
|
|
88
|
+
fileSize: number;
|
|
89
|
+
chunkSize: number;
|
|
90
|
+
totalChunks: number;
|
|
91
|
+
fileSha256: string;
|
|
92
|
+
startedAt: number;
|
|
93
|
+
status: 'init' | 'transferring' | 'completed' | 'aborted';
|
|
94
|
+
ackedChunks: Set<number>;
|
|
95
|
+
failedChunks: Map<number, string>;
|
|
96
|
+
completedPath?: string;
|
|
97
|
+
error?: string;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
type FileRecvTransferState = {
|
|
101
|
+
transferId: string;
|
|
102
|
+
accountId: string;
|
|
103
|
+
sessionKey: string;
|
|
104
|
+
route: BncrRoute;
|
|
105
|
+
fileName: string;
|
|
106
|
+
mimeType: string;
|
|
107
|
+
fileSize: number;
|
|
108
|
+
chunkSize: number;
|
|
109
|
+
totalChunks: number;
|
|
110
|
+
fileSha256: string;
|
|
111
|
+
startedAt: number;
|
|
112
|
+
status: 'init' | 'transferring' | 'completed' | 'aborted';
|
|
113
|
+
bufferByChunk: Map<number, Buffer>;
|
|
114
|
+
receivedChunks: Set<number>;
|
|
115
|
+
completedPath?: string;
|
|
116
|
+
error?: string;
|
|
117
|
+
};
|
|
118
|
+
|
|
71
119
|
type PersistedState = {
|
|
72
120
|
outbox: OutboxEntry[];
|
|
73
121
|
deadLetter: OutboxEntry[];
|
|
@@ -109,7 +157,11 @@ function asString(v: unknown, fallback = ''): string {
|
|
|
109
157
|
|
|
110
158
|
function normalizeAccountId(accountId?: string | null): string {
|
|
111
159
|
const v = asString(accountId || '').trim();
|
|
112
|
-
|
|
160
|
+
if (!v) return BNCR_DEFAULT_ACCOUNT_ID;
|
|
161
|
+
const lower = v.toLowerCase();
|
|
162
|
+
// 历史兼容:default/primary 统一折叠到 Primary,避免状态尾巴反复出现。
|
|
163
|
+
if (lower === 'default' || lower === 'primary') return BNCR_DEFAULT_ACCOUNT_ID;
|
|
164
|
+
return v;
|
|
113
165
|
}
|
|
114
166
|
|
|
115
167
|
function parseRouteFromScope(scope: string): BncrRoute | null {
|
|
@@ -124,18 +176,45 @@ function parseRouteFromDisplayScope(scope: string): BncrRoute | null {
|
|
|
124
176
|
const raw = asString(scope).trim();
|
|
125
177
|
if (!raw) return null;
|
|
126
178
|
|
|
127
|
-
//
|
|
128
|
-
|
|
129
|
-
|
|
179
|
+
// 终版兼容(6 种):
|
|
180
|
+
// 1) bncr:g-<hex(scope)>
|
|
181
|
+
// 2) bncr:<hex(scope)>
|
|
182
|
+
// 3) bncr:<platform>:<groupId>:<userId>
|
|
183
|
+
// 4) bncr:g-<platform>:<groupId>:<userId>
|
|
184
|
+
|
|
185
|
+
// 1) bncr:g-<hex> 或 bncr:g-<scope>
|
|
186
|
+
const gPayload = raw.match(/^bncr:g-(.+)$/i)?.[1];
|
|
187
|
+
if (gPayload) {
|
|
188
|
+
if (isLowerHex(gPayload)) {
|
|
189
|
+
const route = parseRouteFromHexScope(gPayload);
|
|
190
|
+
if (route) return route;
|
|
191
|
+
}
|
|
192
|
+
return parseRouteFromScope(gPayload);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// 2) / 3) bncr:<hex> or bncr:<scope>
|
|
196
|
+
const bPayload = raw.match(/^bncr:(.+)$/i)?.[1];
|
|
197
|
+
if (bPayload) {
|
|
198
|
+
if (isLowerHex(bPayload)) {
|
|
199
|
+
const route = parseRouteFromHexScope(bPayload);
|
|
200
|
+
if (route) return route;
|
|
201
|
+
}
|
|
202
|
+
return parseRouteFromScope(bPayload);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return null;
|
|
130
206
|
}
|
|
131
207
|
|
|
132
208
|
function formatDisplayScope(route: BncrRoute): string {
|
|
133
|
-
|
|
209
|
+
// 主推荐标签:bncr:<platform>:<groupId>:<userId>
|
|
210
|
+
// 保持原始大小写,不做平台名降级。
|
|
211
|
+
return `bncr:${route.platform}:${route.groupId}:${route.userId}`;
|
|
134
212
|
}
|
|
135
213
|
|
|
136
214
|
function isLowerHex(input: string): boolean {
|
|
137
215
|
const raw = asString(input).trim();
|
|
138
|
-
|
|
216
|
+
// 兼容大小写十六进制,不主动降级大小写
|
|
217
|
+
return !!raw && /^[0-9a-fA-F]+$/.test(raw) && raw.length % 2 === 0;
|
|
139
218
|
}
|
|
140
219
|
|
|
141
220
|
function routeScopeToHex(route: BncrRoute): string {
|
|
@@ -144,7 +223,7 @@ function routeScopeToHex(route: BncrRoute): string {
|
|
|
144
223
|
}
|
|
145
224
|
|
|
146
225
|
function parseRouteFromHexScope(scopeHex: string): BncrRoute | null {
|
|
147
|
-
const rawHex = asString(scopeHex).trim()
|
|
226
|
+
const rawHex = asString(scopeHex).trim();
|
|
148
227
|
if (!isLowerHex(rawHex)) return null;
|
|
149
228
|
|
|
150
229
|
try {
|
|
@@ -277,24 +356,30 @@ function hex2utf8SessionKey(str: string): { sessionKey: string; scope: string }
|
|
|
277
356
|
function parseStrictBncrSessionKey(input: string): { sessionKey: string; scopeHex: string; route: BncrRoute } | null {
|
|
278
357
|
const raw = asString(input).trim();
|
|
279
358
|
if (!raw) return null;
|
|
280
|
-
if (!raw.startsWith(BNCR_SESSION_KEY_PREFIX)) return null;
|
|
281
359
|
|
|
282
|
-
|
|
283
|
-
//
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
360
|
+
// 终版兼容 sessionKey:
|
|
361
|
+
// 1) agent:main:bncr:direct:<hex(scope)>
|
|
362
|
+
// 2) agent:main:bncr:group:<hex(scope)>
|
|
363
|
+
// (统一归一成 direct:<hex(scope)>)
|
|
364
|
+
const m = raw.match(/^agent:main:bncr:(direct|group):(.+)$/);
|
|
365
|
+
if (!m?.[1] || !m?.[2]) return null;
|
|
366
|
+
|
|
367
|
+
const payload = asString(m[2]).trim();
|
|
368
|
+
let route: BncrRoute | null = null;
|
|
369
|
+
let scopeHex = '';
|
|
370
|
+
|
|
371
|
+
if (isLowerHex(payload)) {
|
|
372
|
+
scopeHex = payload;
|
|
373
|
+
route = parseRouteFromHexScope(payload);
|
|
374
|
+
} else {
|
|
375
|
+
route = parseRouteFromScope(payload);
|
|
376
|
+
if (route) scopeHex = routeScopeToHex(route);
|
|
287
377
|
}
|
|
288
378
|
|
|
289
|
-
|
|
290
|
-
if (!isLowerHex(scopeHex)) return null;
|
|
291
|
-
|
|
292
|
-
const decoded = hex2utf8SessionKey(raw);
|
|
293
|
-
const route = parseRouteFromScope(decoded.scope);
|
|
294
|
-
if (!route) return null;
|
|
379
|
+
if (!route || !scopeHex) return null;
|
|
295
380
|
|
|
296
381
|
return {
|
|
297
|
-
sessionKey:
|
|
382
|
+
sessionKey: `${BNCR_SESSION_KEY_PREFIX}${scopeHex}`,
|
|
298
383
|
scopeHex,
|
|
299
384
|
route,
|
|
300
385
|
};
|
|
@@ -352,6 +437,7 @@ function withTaskSessionKey(sessionKey: string, taskKey?: string | null): string
|
|
|
352
437
|
}
|
|
353
438
|
|
|
354
439
|
function buildFallbackSessionKey(route: BncrRoute): string {
|
|
440
|
+
// 新主口径:sessionKey 使用 agent:main:bncr:direct:<hex(scope)>
|
|
355
441
|
return `${BNCR_SESSION_KEY_PREFIX}${routeScopeToHex(route)}`;
|
|
356
442
|
}
|
|
357
443
|
|
|
@@ -383,14 +469,85 @@ function inboundDedupKey(params: {
|
|
|
383
469
|
return `${accountId}|${platform}|${groupId}|${userId}|hash:${digest}`;
|
|
384
470
|
}
|
|
385
471
|
|
|
386
|
-
function resolveChatType(
|
|
387
|
-
|
|
472
|
+
function resolveChatType(_route: BncrRoute): 'direct' | 'group' {
|
|
473
|
+
// 业务口径:无论群聊/私聊,统一按 direct 上报,避免会话层落到 group 显示分支(历史 bncr:g-*)。
|
|
474
|
+
return 'direct';
|
|
388
475
|
}
|
|
389
476
|
|
|
390
477
|
function routeKey(accountId: string, route: BncrRoute): string {
|
|
391
478
|
return `${accountId}:${route.platform}:${route.groupId}:${route.userId}`.toLowerCase();
|
|
392
479
|
}
|
|
393
480
|
|
|
481
|
+
function fileExtFromMime(mimeType?: string): string {
|
|
482
|
+
const mt = asString(mimeType || '').toLowerCase();
|
|
483
|
+
const map: Record<string, string> = {
|
|
484
|
+
'image/jpeg': '.jpg',
|
|
485
|
+
'image/jpg': '.jpg',
|
|
486
|
+
'image/png': '.png',
|
|
487
|
+
'image/webp': '.webp',
|
|
488
|
+
'image/gif': '.gif',
|
|
489
|
+
'video/mp4': '.mp4',
|
|
490
|
+
'video/webm': '.webm',
|
|
491
|
+
'video/quicktime': '.mov',
|
|
492
|
+
'audio/mpeg': '.mp3',
|
|
493
|
+
'audio/mp4': '.m4a',
|
|
494
|
+
'application/pdf': '.pdf',
|
|
495
|
+
'text/plain': '.txt',
|
|
496
|
+
};
|
|
497
|
+
return map[mt] || '';
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function sanitizeFileName(rawName?: string, fallback = 'file.bin'): string {
|
|
501
|
+
const name = asString(rawName || '').trim();
|
|
502
|
+
const base = name || fallback;
|
|
503
|
+
const cleaned = base.replace(/[\\/:*?"<>|\x00-\x1F]+/g, '_').replace(/\s+/g, ' ').trim();
|
|
504
|
+
return cleaned || fallback;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function buildTimestampFileName(mimeType?: string): string {
|
|
508
|
+
const d = new Date();
|
|
509
|
+
const pad = (n: number) => String(n).padStart(2, '0');
|
|
510
|
+
const ts = `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}_${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
|
|
511
|
+
const ext = fileExtFromMime(mimeType) || '.bin';
|
|
512
|
+
return `bncr_${ts}_${Math.random().toString(16).slice(2, 8)}${ext}`;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function resolveOutboundFileName(params: { mediaUrl?: string; fileName?: string; mimeType?: string }): string {
|
|
516
|
+
const mediaUrl = asString(params.mediaUrl || '').trim();
|
|
517
|
+
const mimeType = asString(params.mimeType || '').trim();
|
|
518
|
+
|
|
519
|
+
// 线上下载的文件,统一用时间戳命名(避免超长/无意义文件名)
|
|
520
|
+
if (/^https?:\/\//i.test(mediaUrl)) {
|
|
521
|
+
return buildTimestampFileName(mimeType);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const candidate = sanitizeFileName(params.fileName, 'file.bin');
|
|
525
|
+
if (candidate.length <= 80) return candidate;
|
|
526
|
+
|
|
527
|
+
// 超长文件名做裁剪,尽量保留扩展名
|
|
528
|
+
const ext = path.extname(candidate);
|
|
529
|
+
const stem = candidate.slice(0, Math.max(1, 80 - ext.length));
|
|
530
|
+
return `${stem}${ext}`;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function resolveBncrOutboundMessageType(params: { mimeType?: string; fileName?: string; hintedType?: string; hasPayload?: boolean }): 'text' | 'image' | 'video' | 'voice' | 'audio' | 'file' {
|
|
534
|
+
const hinted = asString(params.hintedType || '').toLowerCase();
|
|
535
|
+
const hasPayload = !!params.hasPayload;
|
|
536
|
+
const mt = asString(params.mimeType || '').toLowerCase();
|
|
537
|
+
const major = mt.split('/')[0] || '';
|
|
538
|
+
const isStandard = hinted === 'text' || hinted === 'image' || hinted === 'video' || hinted === 'voice' || hinted === 'audio' || hinted === 'file';
|
|
539
|
+
|
|
540
|
+
// 文本类附件不应落成 text:当上游显式给 text,或上游 type 不在标准列表时,若带文件载荷且 mime 主类型为 text,则归到 file。
|
|
541
|
+
if (hasPayload && major === 'text' && (hinted === 'text' || !isStandard)) return 'file';
|
|
542
|
+
|
|
543
|
+
// 优先使用上游已给出的标准类型;仅当不在支持列表时再尝试纠正
|
|
544
|
+
if (isStandard) return hinted as any;
|
|
545
|
+
|
|
546
|
+
if (major === 'text' || major === 'image' || major === 'video' || major === 'audio') return major as any;
|
|
547
|
+
|
|
548
|
+
return 'file';
|
|
549
|
+
}
|
|
550
|
+
|
|
394
551
|
class BncrBridgeRuntime {
|
|
395
552
|
private api: OpenClawPluginApi;
|
|
396
553
|
private statePath: string | null = null;
|
|
@@ -409,11 +566,28 @@ class BncrBridgeRuntime {
|
|
|
409
566
|
private lastInboundByAccount = new Map<string, number>();
|
|
410
567
|
private lastOutboundByAccount = new Map<string, number>();
|
|
411
568
|
|
|
569
|
+
// 内置健康/回归计数(替代独立脚本)
|
|
570
|
+
private startedAt = now();
|
|
571
|
+
private connectEventsByAccount = new Map<string, number>();
|
|
572
|
+
private inboundEventsByAccount = new Map<string, number>();
|
|
573
|
+
private activityEventsByAccount = new Map<string, number>();
|
|
574
|
+
private ackEventsByAccount = new Map<string, number>();
|
|
575
|
+
|
|
412
576
|
private saveTimer: NodeJS.Timeout | null = null;
|
|
413
577
|
private pushTimer: NodeJS.Timeout | null = null;
|
|
578
|
+
private pushDrainRunningAccounts = new Set<string>();
|
|
414
579
|
private waiters = new Map<string, Array<() => void>>();
|
|
415
580
|
private gatewayContext: GatewayRequestHandlerOptions['context'] | null = null;
|
|
416
581
|
|
|
582
|
+
// 文件互传状态(V1:尽力而为,重连不续传)
|
|
583
|
+
private fileSendTransfers = new Map<string, FileSendTransferState>(); // OpenClaw -> Bncr(服务端发起)
|
|
584
|
+
private fileRecvTransfers = new Map<string, FileRecvTransferState>(); // Bncr -> OpenClaw(客户端发起)
|
|
585
|
+
private fileAckWaiters = new Map<string, {
|
|
586
|
+
resolve: (payload: Record<string, unknown>) => void;
|
|
587
|
+
reject: (err: Error) => void;
|
|
588
|
+
timer: NodeJS.Timeout;
|
|
589
|
+
}>();
|
|
590
|
+
|
|
417
591
|
constructor(api: OpenClawPluginApi) {
|
|
418
592
|
this.api = api;
|
|
419
593
|
}
|
|
@@ -421,7 +595,8 @@ class BncrBridgeRuntime {
|
|
|
421
595
|
startService = async (ctx: OpenClawPluginServiceContext) => {
|
|
422
596
|
this.statePath = path.join(ctx.stateDir, 'bncr-bridge-state.json');
|
|
423
597
|
await this.loadState();
|
|
424
|
-
this.
|
|
598
|
+
const bootDiag = this.buildIntegratedDiagnostics(BNCR_DEFAULT_ACCOUNT_ID);
|
|
599
|
+
this.api.logger.info(`bncr-channel service started (diag.ok=${bootDiag.regression.ok} routes=${bootDiag.regression.totalKnownRoutes} pending=${bootDiag.health.pending} dead=${bootDiag.health.deadLetter})`);
|
|
425
600
|
};
|
|
426
601
|
|
|
427
602
|
stopService = async () => {
|
|
@@ -441,6 +616,96 @@ class BncrBridgeRuntime {
|
|
|
441
616
|
}, 300);
|
|
442
617
|
}
|
|
443
618
|
|
|
619
|
+
private incrementCounter(map: Map<string, number>, accountId: string) {
|
|
620
|
+
const acc = normalizeAccountId(accountId);
|
|
621
|
+
map.set(acc, (map.get(acc) || 0) + 1);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
private getCounter(map: Map<string, number>, accountId: string): number {
|
|
625
|
+
return map.get(normalizeAccountId(accountId)) || 0;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
private countInvalidOutboxSessionKeys(accountId: string): number {
|
|
629
|
+
const acc = normalizeAccountId(accountId);
|
|
630
|
+
let count = 0;
|
|
631
|
+
for (const entry of this.outbox.values()) {
|
|
632
|
+
if (entry.accountId !== acc) continue;
|
|
633
|
+
if (!parseStrictBncrSessionKey(entry.sessionKey)) count += 1;
|
|
634
|
+
}
|
|
635
|
+
return count;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
private countLegacyAccountResidue(accountId: string): number {
|
|
639
|
+
const acc = normalizeAccountId(accountId);
|
|
640
|
+
const mismatched = (raw?: string | null) => asString(raw || '').trim() && normalizeAccountId(raw) !== acc;
|
|
641
|
+
|
|
642
|
+
let count = 0;
|
|
643
|
+
|
|
644
|
+
for (const entry of this.outbox.values()) {
|
|
645
|
+
if (mismatched(entry.accountId)) count += 1;
|
|
646
|
+
}
|
|
647
|
+
for (const entry of this.deadLetter) {
|
|
648
|
+
if (mismatched(entry.accountId)) count += 1;
|
|
649
|
+
}
|
|
650
|
+
for (const info of this.sessionRoutes.values()) {
|
|
651
|
+
if (mismatched(info.accountId)) count += 1;
|
|
652
|
+
}
|
|
653
|
+
for (const key of this.lastSessionByAccount.keys()) {
|
|
654
|
+
if (mismatched(key)) count += 1;
|
|
655
|
+
}
|
|
656
|
+
for (const key of this.lastActivityByAccount.keys()) {
|
|
657
|
+
if (mismatched(key)) count += 1;
|
|
658
|
+
}
|
|
659
|
+
for (const key of this.lastInboundByAccount.keys()) {
|
|
660
|
+
if (mismatched(key)) count += 1;
|
|
661
|
+
}
|
|
662
|
+
for (const key of this.lastOutboundByAccount.keys()) {
|
|
663
|
+
if (mismatched(key)) count += 1;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
return count;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
private buildIntegratedDiagnostics(accountId: string) {
|
|
670
|
+
const acc = normalizeAccountId(accountId);
|
|
671
|
+
const t = now();
|
|
672
|
+
|
|
673
|
+
const pending = Array.from(this.outbox.values()).filter((v) => v.accountId === acc).length;
|
|
674
|
+
const dead = this.deadLetter.filter((v) => v.accountId === acc).length;
|
|
675
|
+
const invalidOutboxSessionKeys = this.countInvalidOutboxSessionKeys(acc);
|
|
676
|
+
const legacyAccountResidue = this.countLegacyAccountResidue(acc);
|
|
677
|
+
|
|
678
|
+
const totalKnownRoutes = Array.from(this.sessionRoutes.values()).filter((v) => v.accountId === acc).length;
|
|
679
|
+
const connected = this.isOnline(acc);
|
|
680
|
+
|
|
681
|
+
const pluginIndexExists = fs.existsSync(path.join(process.cwd(), 'plugins', 'bncr', 'index.ts'));
|
|
682
|
+
const pluginChannelExists = fs.existsSync(path.join(process.cwd(), 'plugins', 'bncr', 'src', 'channel.ts'));
|
|
683
|
+
|
|
684
|
+
const health = {
|
|
685
|
+
connected,
|
|
686
|
+
pending,
|
|
687
|
+
deadLetter: dead,
|
|
688
|
+
activeConnections: this.activeConnectionCount(acc),
|
|
689
|
+
connectEvents: this.getCounter(this.connectEventsByAccount, acc),
|
|
690
|
+
inboundEvents: this.getCounter(this.inboundEventsByAccount, acc),
|
|
691
|
+
activityEvents: this.getCounter(this.activityEventsByAccount, acc),
|
|
692
|
+
ackEvents: this.getCounter(this.ackEventsByAccount, acc),
|
|
693
|
+
uptimeSec: Math.floor((t - this.startedAt) / 1000),
|
|
694
|
+
};
|
|
695
|
+
|
|
696
|
+
const regression = {
|
|
697
|
+
pluginFilesPresent: pluginIndexExists && pluginChannelExists,
|
|
698
|
+
pluginIndexExists,
|
|
699
|
+
pluginChannelExists,
|
|
700
|
+
totalKnownRoutes,
|
|
701
|
+
invalidOutboxSessionKeys,
|
|
702
|
+
legacyAccountResidue,
|
|
703
|
+
ok: invalidOutboxSessionKeys === 0 && legacyAccountResidue === 0,
|
|
704
|
+
};
|
|
705
|
+
|
|
706
|
+
return { health, regression };
|
|
707
|
+
}
|
|
708
|
+
|
|
444
709
|
private async loadState() {
|
|
445
710
|
if (!this.statePath) return;
|
|
446
711
|
const loaded = await readJsonFileWithFallback(this.statePath, {
|
|
@@ -702,55 +967,76 @@ class BncrBridgeRuntime {
|
|
|
702
967
|
const delay = Math.max(0, Math.min(Number(delayMs || 0), 30_000));
|
|
703
968
|
this.pushTimer = setTimeout(() => {
|
|
704
969
|
this.pushTimer = null;
|
|
705
|
-
this.flushPushQueue();
|
|
970
|
+
void this.flushPushQueue();
|
|
706
971
|
}, delay);
|
|
707
972
|
}
|
|
708
973
|
|
|
709
|
-
private flushPushQueue(accountId?: string) {
|
|
710
|
-
const t = now();
|
|
974
|
+
private async flushPushQueue(accountId?: string): Promise<void> {
|
|
711
975
|
const filterAcc = accountId ? normalizeAccountId(accountId) : null;
|
|
712
|
-
const
|
|
713
|
-
|
|
714
|
-
.
|
|
976
|
+
const targetAccounts = filterAcc
|
|
977
|
+
? [filterAcc]
|
|
978
|
+
: Array.from(new Set(Array.from(this.outbox.values()).map((entry) => normalizeAccountId(entry.accountId))));
|
|
715
979
|
|
|
716
|
-
let
|
|
717
|
-
let nextDelay: number | null = null;
|
|
980
|
+
let globalNextDelay: number | null = null;
|
|
718
981
|
|
|
719
|
-
for (const
|
|
720
|
-
if (!this.
|
|
982
|
+
for (const acc of targetAccounts) {
|
|
983
|
+
if (!acc || this.pushDrainRunningAccounts.has(acc)) continue;
|
|
984
|
+
if (!this.isOnline(acc)) continue;
|
|
721
985
|
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
986
|
+
this.pushDrainRunningAccounts.add(acc);
|
|
987
|
+
try {
|
|
988
|
+
let localNextDelay: number | null = null;
|
|
989
|
+
|
|
990
|
+
while (true) {
|
|
991
|
+
const t = now();
|
|
992
|
+
const entries = Array.from(this.outbox.values())
|
|
993
|
+
.filter((entry) => normalizeAccountId(entry.accountId) === acc)
|
|
994
|
+
.sort((a, b) => a.createdAt - b.createdAt);
|
|
995
|
+
|
|
996
|
+
if (!entries.length) break;
|
|
997
|
+
if (!this.isOnline(acc)) break;
|
|
998
|
+
|
|
999
|
+
const entry = entries.find((item) => item.nextAttemptAt <= t);
|
|
1000
|
+
if (!entry) {
|
|
1001
|
+
const wait = Math.max(0, entries[0].nextAttemptAt - t);
|
|
1002
|
+
localNextDelay = localNextDelay == null ? wait : Math.min(localNextDelay, wait);
|
|
1003
|
+
break;
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
const pushed = this.tryPushEntry(entry);
|
|
1007
|
+
if (pushed) {
|
|
1008
|
+
this.scheduleSave();
|
|
1009
|
+
await this.sleepMs(PUSH_DRAIN_INTERVAL_MS);
|
|
1010
|
+
continue;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
const nextAttempt = entry.retryCount + 1;
|
|
1014
|
+
if (nextAttempt > MAX_RETRY) {
|
|
1015
|
+
this.moveToDeadLetter(entry, entry.lastError || 'push-retry-limit');
|
|
1016
|
+
continue;
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
entry.retryCount = nextAttempt;
|
|
1020
|
+
entry.lastAttemptAt = t;
|
|
1021
|
+
entry.nextAttemptAt = t + backoffMs(nextAttempt);
|
|
1022
|
+
entry.lastError = entry.lastError || 'push-retry';
|
|
1023
|
+
this.outbox.set(entry.messageId, entry);
|
|
1024
|
+
this.scheduleSave();
|
|
1025
|
+
|
|
1026
|
+
const wait = Math.max(0, entry.nextAttemptAt - t);
|
|
1027
|
+
localNextDelay = localNextDelay == null ? wait : Math.min(localNextDelay, wait);
|
|
1028
|
+
break;
|
|
1029
|
+
}
|
|
733
1030
|
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
1031
|
+
if (localNextDelay != null) {
|
|
1032
|
+
globalNextDelay = globalNextDelay == null ? localNextDelay : Math.min(globalNextDelay, localNextDelay);
|
|
1033
|
+
}
|
|
1034
|
+
} finally {
|
|
1035
|
+
this.pushDrainRunningAccounts.delete(acc);
|
|
739
1036
|
}
|
|
740
|
-
|
|
741
|
-
entry.retryCount = nextAttempt;
|
|
742
|
-
entry.lastAttemptAt = t;
|
|
743
|
-
entry.nextAttemptAt = t + backoffMs(nextAttempt);
|
|
744
|
-
entry.lastError = entry.lastError || 'push-retry';
|
|
745
|
-
this.outbox.set(entry.messageId, entry);
|
|
746
|
-
changed = true;
|
|
747
|
-
|
|
748
|
-
const wait = entry.nextAttemptAt - t;
|
|
749
|
-
nextDelay = nextDelay == null ? wait : Math.min(nextDelay, wait);
|
|
750
1037
|
}
|
|
751
1038
|
|
|
752
|
-
if (
|
|
753
|
-
if (nextDelay != null) this.schedulePushDrain(nextDelay);
|
|
1039
|
+
if (globalNextDelay != null) this.schedulePushDrain(globalNextDelay);
|
|
754
1040
|
}
|
|
755
1041
|
|
|
756
1042
|
private async waitForOutbound(accountId: string, waitMs: number): Promise<void> {
|
|
@@ -799,6 +1085,18 @@ class BncrBridgeRuntime {
|
|
|
799
1085
|
for (const [key, ts] of this.recentInbound.entries()) {
|
|
800
1086
|
if (t - ts > dedupWindowMs) this.recentInbound.delete(key);
|
|
801
1087
|
}
|
|
1088
|
+
|
|
1089
|
+
this.cleanupFileTransfers();
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
private cleanupFileTransfers() {
|
|
1093
|
+
const t = now();
|
|
1094
|
+
for (const [id, st] of this.fileSendTransfers.entries()) {
|
|
1095
|
+
if (t - st.startedAt > FILE_TRANSFER_KEEP_MS) this.fileSendTransfers.delete(id);
|
|
1096
|
+
}
|
|
1097
|
+
for (const [id, st] of this.fileRecvTransfers.entries()) {
|
|
1098
|
+
if (t - st.startedAt > FILE_TRANSFER_KEEP_MS) this.fileRecvTransfers.delete(id);
|
|
1099
|
+
}
|
|
802
1100
|
}
|
|
803
1101
|
|
|
804
1102
|
private markSeen(accountId: string, connId: string, clientId?: string) {
|
|
@@ -906,10 +1204,10 @@ class BncrBridgeRuntime {
|
|
|
906
1204
|
return alias?.route || parsed.route;
|
|
907
1205
|
}
|
|
908
1206
|
|
|
909
|
-
//
|
|
910
|
-
// 1)
|
|
911
|
-
// 2)
|
|
912
|
-
// 3)
|
|
1207
|
+
// 严谨目标解析(终版兼容模式):
|
|
1208
|
+
// 1) 兼容 6 种输入格式(to/sessionKey)
|
|
1209
|
+
// 2) 统一反查并归一为 sessionKey=agent:main:bncr:direct:<hex(scope)>
|
|
1210
|
+
// 3) 非兼容格式直接失败,并输出标准格式提示日志
|
|
913
1211
|
private resolveVerifiedTarget(rawTarget: string, accountId: string): { sessionKey: string; route: BncrRoute; displayScope: string } {
|
|
914
1212
|
const acc = normalizeAccountId(accountId);
|
|
915
1213
|
const raw = asString(rawTarget).trim();
|
|
@@ -927,8 +1225,10 @@ class BncrBridgeRuntime {
|
|
|
927
1225
|
}
|
|
928
1226
|
|
|
929
1227
|
if (!route) {
|
|
930
|
-
this.api.logger.warn?.(
|
|
931
|
-
|
|
1228
|
+
this.api.logger.warn?.(
|
|
1229
|
+
`[bncr-target-invalid] raw=${raw} accountId=${acc} reason=unparseable-or-unknown standardTo=bncr:<platform>:<groupId>:<userId> standardSessionKey=agent:main:bncr:direct:<hex(scope)>`,
|
|
1230
|
+
);
|
|
1231
|
+
throw new Error(`bncr invalid target(standard: to=bncr:<platform>:<groupId>:<userId>): ${raw}`);
|
|
932
1232
|
}
|
|
933
1233
|
|
|
934
1234
|
const wantedRouteKey = routeKey(acc, route);
|
|
@@ -955,6 +1255,14 @@ class BncrBridgeRuntime {
|
|
|
955
1255
|
throw new Error(`bncr target not found in known sessions: ${raw}`);
|
|
956
1256
|
}
|
|
957
1257
|
|
|
1258
|
+
// 发送链路命中目标时,同步刷新 lastSession,避免状态页显示过期会话。
|
|
1259
|
+
this.lastSessionByAccount.set(acc, {
|
|
1260
|
+
sessionKey: best.sessionKey,
|
|
1261
|
+
scope: formatDisplayScope(best.route),
|
|
1262
|
+
updatedAt: now(),
|
|
1263
|
+
});
|
|
1264
|
+
this.scheduleSave();
|
|
1265
|
+
|
|
958
1266
|
return {
|
|
959
1267
|
sessionKey: best.sessionKey,
|
|
960
1268
|
route: best.route,
|
|
@@ -966,6 +1274,87 @@ class BncrBridgeRuntime {
|
|
|
966
1274
|
this.lastActivityByAccount.set(normalizeAccountId(accountId), at);
|
|
967
1275
|
}
|
|
968
1276
|
|
|
1277
|
+
private fileAckKey(transferId: string, stage: string, chunkIndex?: number): string {
|
|
1278
|
+
const idx = Number.isFinite(Number(chunkIndex)) ? String(Number(chunkIndex)) : '-';
|
|
1279
|
+
return `${transferId}|${stage}|${idx}`;
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
private waitForFileAck(params: { transferId: string; stage: string; chunkIndex?: number; timeoutMs?: number }) {
|
|
1283
|
+
const transferId = asString(params.transferId).trim();
|
|
1284
|
+
const stage = asString(params.stage).trim();
|
|
1285
|
+
const key = this.fileAckKey(transferId, stage, params.chunkIndex);
|
|
1286
|
+
const timeoutMs = Math.max(1_000, Math.min(Number(params.timeoutMs || FILE_ACK_TIMEOUT_MS), 120_000));
|
|
1287
|
+
|
|
1288
|
+
return new Promise<Record<string, unknown>>((resolve, reject) => {
|
|
1289
|
+
const timer = setTimeout(() => {
|
|
1290
|
+
this.fileAckWaiters.delete(key);
|
|
1291
|
+
reject(new Error(`file ack timeout: ${key}`));
|
|
1292
|
+
}, timeoutMs);
|
|
1293
|
+
this.fileAckWaiters.set(key, { resolve, reject, timer });
|
|
1294
|
+
});
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
private resolveFileAck(params: { transferId: string; stage: string; chunkIndex?: number; payload: Record<string, unknown>; ok: boolean }) {
|
|
1298
|
+
const transferId = asString(params.transferId).trim();
|
|
1299
|
+
const stage = asString(params.stage).trim();
|
|
1300
|
+
const key = this.fileAckKey(transferId, stage, params.chunkIndex);
|
|
1301
|
+
const waiter = this.fileAckWaiters.get(key);
|
|
1302
|
+
if (!waiter) return false;
|
|
1303
|
+
this.fileAckWaiters.delete(key);
|
|
1304
|
+
clearTimeout(waiter.timer);
|
|
1305
|
+
if (params.ok) waiter.resolve(params.payload);
|
|
1306
|
+
else waiter.reject(new Error(asString(params.payload?.errorMessage || params.payload?.error || 'file ack failed')));
|
|
1307
|
+
return true;
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
private pushFileEventToAccount(accountId: string, event: string, payload: Record<string, unknown>) {
|
|
1311
|
+
const connIds = this.resolvePushConnIds(accountId);
|
|
1312
|
+
if (!connIds.size || !this.gatewayContext) {
|
|
1313
|
+
throw new Error(`no active bncr connection for account=${accountId}`);
|
|
1314
|
+
}
|
|
1315
|
+
this.gatewayContext.broadcastToConnIds(event, payload, connIds);
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
private resolveInboundFileType(mimeType: string, fileName: string): string {
|
|
1319
|
+
const mt = asString(mimeType).toLowerCase();
|
|
1320
|
+
const fn = asString(fileName).toLowerCase();
|
|
1321
|
+
if (mt.startsWith('image/') || /\.(png|jpe?g|gif|webp|bmp|svg)$/.test(fn)) return 'image';
|
|
1322
|
+
if (mt.startsWith('video/') || /\.(mp4|mov|mkv|avi|webm)$/.test(fn)) return 'video';
|
|
1323
|
+
if (mt.startsWith('audio/') || /\.(mp3|wav|m4a|aac|ogg|flac)$/.test(fn)) return 'audio';
|
|
1324
|
+
return mt || 'file';
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
private resolveInboundFilesDir(): string {
|
|
1328
|
+
const dir = path.join(process.cwd(), '.openclaw', 'media', 'inbound', 'bncr');
|
|
1329
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1330
|
+
return dir;
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
private async materializeRecvTransfer(st: FileRecvTransferState): Promise<{ path: string; fileSha256: string }> {
|
|
1334
|
+
const dir = this.resolveInboundFilesDir();
|
|
1335
|
+
const safeName = asString(st.fileName).trim() || `${st.transferId}.bin`;
|
|
1336
|
+
const finalPath = path.join(dir, safeName);
|
|
1337
|
+
|
|
1338
|
+
const ordered: Buffer[] = [];
|
|
1339
|
+
for (let i = 0; i < st.totalChunks; i++) {
|
|
1340
|
+
const chunk = st.bufferByChunk.get(i);
|
|
1341
|
+
if (!chunk) throw new Error(`missing chunk ${i}`);
|
|
1342
|
+
ordered.push(chunk);
|
|
1343
|
+
}
|
|
1344
|
+
const merged = Buffer.concat(ordered);
|
|
1345
|
+
if (Number(st.fileSize || 0) > 0 && merged.length !== Number(st.fileSize || 0)) {
|
|
1346
|
+
throw new Error(`size mismatch expected=${st.fileSize} got=${merged.length}`);
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
const sha = createHash('sha256').update(merged).digest('hex');
|
|
1350
|
+
if (st.fileSha256 && sha !== st.fileSha256) {
|
|
1351
|
+
throw new Error(`sha256 mismatch expected=${st.fileSha256} got=${sha}`);
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
fs.writeFileSync(finalPath, merged);
|
|
1355
|
+
return { path: finalPath, fileSha256: sha };
|
|
1356
|
+
}
|
|
1357
|
+
|
|
969
1358
|
private fmtAgo(ts?: number | null): string {
|
|
970
1359
|
if (!ts || !Number.isFinite(ts) || ts <= 0) return '-';
|
|
971
1360
|
const diff = Math.max(0, now() - ts);
|
|
@@ -989,6 +1378,7 @@ class BncrBridgeRuntime {
|
|
|
989
1378
|
const lastActivityAgo = this.fmtAgo(lastActAt);
|
|
990
1379
|
const lastInboundAgo = this.fmtAgo(lastInboundAt);
|
|
991
1380
|
const lastOutboundAgo = this.fmtAgo(lastOutboundAt);
|
|
1381
|
+
const diagnostics = this.buildIntegratedDiagnostics(acc);
|
|
992
1382
|
|
|
993
1383
|
return {
|
|
994
1384
|
pending,
|
|
@@ -1003,6 +1393,7 @@ class BncrBridgeRuntime {
|
|
|
1003
1393
|
lastInboundAgo,
|
|
1004
1394
|
lastOutboundAt,
|
|
1005
1395
|
lastOutboundAgo,
|
|
1396
|
+
diagnostics,
|
|
1006
1397
|
};
|
|
1007
1398
|
}
|
|
1008
1399
|
|
|
@@ -1026,21 +1417,49 @@ class BncrBridgeRuntime {
|
|
|
1026
1417
|
};
|
|
1027
1418
|
}
|
|
1028
1419
|
|
|
1420
|
+
private buildStatusHeadline(accountId: string): string {
|
|
1421
|
+
const acc = normalizeAccountId(accountId);
|
|
1422
|
+
const diag = this.buildIntegratedDiagnostics(acc);
|
|
1423
|
+
const h = diag.health;
|
|
1424
|
+
const r = diag.regression;
|
|
1425
|
+
|
|
1426
|
+
const parts = [
|
|
1427
|
+
r.ok ? 'diag:ok' : 'diag:warn',
|
|
1428
|
+
`p:${h.pending}`,
|
|
1429
|
+
`d:${h.deadLetter}`,
|
|
1430
|
+
`c:${h.activeConnections}`,
|
|
1431
|
+
];
|
|
1432
|
+
|
|
1433
|
+
if (!r.ok) {
|
|
1434
|
+
if (r.invalidOutboxSessionKeys > 0) parts.push(`invalid:${r.invalidOutboxSessionKeys}`);
|
|
1435
|
+
if (r.legacyAccountResidue > 0) parts.push(`legacy:${r.legacyAccountResidue}`);
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
return parts.join(' ');
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
getStatusHeadline(accountId: string): string {
|
|
1442
|
+
return this.buildStatusHeadline(accountId);
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1029
1445
|
getChannelSummary(defaultAccountId: string) {
|
|
1030
|
-
const
|
|
1446
|
+
const accountId = normalizeAccountId(defaultAccountId);
|
|
1447
|
+
const runtime = this.getAccountRuntimeSnapshot(accountId);
|
|
1448
|
+
const headline = this.buildStatusHeadline(accountId);
|
|
1449
|
+
|
|
1031
1450
|
if (runtime.connected) {
|
|
1032
|
-
return { linked: true };
|
|
1451
|
+
return { linked: true, self: { e164: headline } };
|
|
1033
1452
|
}
|
|
1034
1453
|
|
|
1035
1454
|
// 顶层汇总不绑定某个 accountId:任一账号在线都应显示 linked
|
|
1036
1455
|
const t = now();
|
|
1037
1456
|
for (const c of this.connections.values()) {
|
|
1038
1457
|
if (t - c.lastSeenAt <= CONNECT_TTL_MS) {
|
|
1039
|
-
return { linked: true };
|
|
1458
|
+
return { linked: true, self: { e164: headline } };
|
|
1040
1459
|
}
|
|
1041
1460
|
}
|
|
1042
1461
|
|
|
1043
|
-
return { linked: false };
|
|
1462
|
+
return { linked: false, self: { e164: headline } };
|
|
1044
1463
|
}
|
|
1045
1464
|
|
|
1046
1465
|
private enqueueOutbound(entry: OutboxEntry) {
|
|
@@ -1111,6 +1530,211 @@ class BncrBridgeRuntime {
|
|
|
1111
1530
|
};
|
|
1112
1531
|
}
|
|
1113
1532
|
|
|
1533
|
+
private async sleepMs(ms: number): Promise<void> {
|
|
1534
|
+
await new Promise<void>((resolve) => setTimeout(resolve, Math.max(0, Number(ms || 0))));
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
private waitChunkAck(params: {
|
|
1538
|
+
transferId: string;
|
|
1539
|
+
chunkIndex: number;
|
|
1540
|
+
timeoutMs?: number;
|
|
1541
|
+
}): Promise<void> {
|
|
1542
|
+
const { transferId, chunkIndex } = params;
|
|
1543
|
+
const timeoutMs = Math.max(1_000, Math.min(Number(params.timeoutMs || FILE_TRANSFER_ACK_TTL_MS), 60_000));
|
|
1544
|
+
const started = now();
|
|
1545
|
+
|
|
1546
|
+
return new Promise<void>((resolve, reject) => {
|
|
1547
|
+
const tick = async () => {
|
|
1548
|
+
const st = this.fileSendTransfers.get(transferId);
|
|
1549
|
+
if (!st) {
|
|
1550
|
+
reject(new Error('transfer state missing'));
|
|
1551
|
+
return;
|
|
1552
|
+
}
|
|
1553
|
+
if (st.failedChunks.has(chunkIndex)) {
|
|
1554
|
+
reject(new Error(st.failedChunks.get(chunkIndex) || `chunk ${chunkIndex} failed`));
|
|
1555
|
+
return;
|
|
1556
|
+
}
|
|
1557
|
+
if (st.ackedChunks.has(chunkIndex)) {
|
|
1558
|
+
resolve();
|
|
1559
|
+
return;
|
|
1560
|
+
}
|
|
1561
|
+
if (now() - started >= timeoutMs) {
|
|
1562
|
+
reject(new Error(`chunk ack timeout index=${chunkIndex}`));
|
|
1563
|
+
return;
|
|
1564
|
+
}
|
|
1565
|
+
await this.sleepMs(120);
|
|
1566
|
+
void tick();
|
|
1567
|
+
};
|
|
1568
|
+
void tick();
|
|
1569
|
+
});
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
private waitCompleteAck(params: {
|
|
1573
|
+
transferId: string;
|
|
1574
|
+
timeoutMs?: number;
|
|
1575
|
+
}): Promise<{ path: string }> {
|
|
1576
|
+
const { transferId } = params;
|
|
1577
|
+
const timeoutMs = Math.max(2_000, Math.min(Number(params.timeoutMs || 60_000), 120_000));
|
|
1578
|
+
const started = now();
|
|
1579
|
+
|
|
1580
|
+
return new Promise<{ path: string }>((resolve, reject) => {
|
|
1581
|
+
const tick = async () => {
|
|
1582
|
+
const st = this.fileSendTransfers.get(transferId);
|
|
1583
|
+
if (!st) {
|
|
1584
|
+
reject(new Error('transfer state missing'));
|
|
1585
|
+
return;
|
|
1586
|
+
}
|
|
1587
|
+
if (st.status === 'aborted') {
|
|
1588
|
+
reject(new Error(st.error || 'transfer aborted'));
|
|
1589
|
+
return;
|
|
1590
|
+
}
|
|
1591
|
+
if (st.status === 'completed' && st.completedPath) {
|
|
1592
|
+
resolve({ path: st.completedPath });
|
|
1593
|
+
return;
|
|
1594
|
+
}
|
|
1595
|
+
if (now() - started >= timeoutMs) {
|
|
1596
|
+
reject(new Error('complete ack timeout'));
|
|
1597
|
+
return;
|
|
1598
|
+
}
|
|
1599
|
+
await this.sleepMs(150);
|
|
1600
|
+
void tick();
|
|
1601
|
+
};
|
|
1602
|
+
void tick();
|
|
1603
|
+
});
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
private async transferMediaToBncrClient(params: {
|
|
1607
|
+
accountId: string;
|
|
1608
|
+
sessionKey: string;
|
|
1609
|
+
route: BncrRoute;
|
|
1610
|
+
mediaUrl: string;
|
|
1611
|
+
mediaLocalRoots?: readonly string[];
|
|
1612
|
+
}): Promise<{ mode: 'base64' | 'chunk'; mimeType?: string; fileName?: string; mediaBase64?: string; path?: string }> {
|
|
1613
|
+
const loaded = await this.api.runtime.media.loadWebMedia(params.mediaUrl, {
|
|
1614
|
+
localRoots: params.mediaLocalRoots,
|
|
1615
|
+
maxBytes: 50 * 1024 * 1024,
|
|
1616
|
+
});
|
|
1617
|
+
|
|
1618
|
+
const size = loaded.buffer.byteLength;
|
|
1619
|
+
const mimeType = loaded.contentType;
|
|
1620
|
+
const fileName = resolveOutboundFileName({
|
|
1621
|
+
mediaUrl: params.mediaUrl,
|
|
1622
|
+
fileName: loaded.fileName,
|
|
1623
|
+
mimeType,
|
|
1624
|
+
});
|
|
1625
|
+
|
|
1626
|
+
if (!FILE_FORCE_CHUNK && size <= FILE_INLINE_THRESHOLD) {
|
|
1627
|
+
return {
|
|
1628
|
+
mode: 'base64',
|
|
1629
|
+
mimeType,
|
|
1630
|
+
fileName,
|
|
1631
|
+
mediaBase64: loaded.buffer.toString('base64'),
|
|
1632
|
+
};
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
const ctx = this.gatewayContext;
|
|
1636
|
+
if (!ctx) throw new Error('gateway context unavailable');
|
|
1637
|
+
|
|
1638
|
+
const connIds = this.resolvePushConnIds(params.accountId);
|
|
1639
|
+
if (!connIds.size) throw new Error('no active bncr client for file chunk transfer');
|
|
1640
|
+
|
|
1641
|
+
const transferId = randomUUID();
|
|
1642
|
+
const chunkSize = 256 * 1024;
|
|
1643
|
+
const totalChunks = Math.ceil(size / chunkSize);
|
|
1644
|
+
const fileSha256 = createHash('sha256').update(loaded.buffer).digest('hex');
|
|
1645
|
+
|
|
1646
|
+
const st: FileSendTransferState = {
|
|
1647
|
+
transferId,
|
|
1648
|
+
accountId: normalizeAccountId(params.accountId),
|
|
1649
|
+
sessionKey: params.sessionKey,
|
|
1650
|
+
route: params.route,
|
|
1651
|
+
fileName,
|
|
1652
|
+
mimeType: mimeType || 'application/octet-stream',
|
|
1653
|
+
fileSize: size,
|
|
1654
|
+
chunkSize,
|
|
1655
|
+
totalChunks,
|
|
1656
|
+
fileSha256,
|
|
1657
|
+
startedAt: now(),
|
|
1658
|
+
status: 'init',
|
|
1659
|
+
ackedChunks: new Set(),
|
|
1660
|
+
failedChunks: new Map(),
|
|
1661
|
+
};
|
|
1662
|
+
this.fileSendTransfers.set(transferId, st);
|
|
1663
|
+
|
|
1664
|
+
ctx.broadcastToConnIds('bncr.file.init', {
|
|
1665
|
+
transferId,
|
|
1666
|
+
direction: 'oc2bncr',
|
|
1667
|
+
sessionKey: params.sessionKey,
|
|
1668
|
+
platform: params.route.platform,
|
|
1669
|
+
groupId: params.route.groupId,
|
|
1670
|
+
userId: params.route.userId,
|
|
1671
|
+
fileName,
|
|
1672
|
+
mimeType,
|
|
1673
|
+
fileSize: size,
|
|
1674
|
+
chunkSize,
|
|
1675
|
+
totalChunks,
|
|
1676
|
+
fileSha256,
|
|
1677
|
+
ts: now(),
|
|
1678
|
+
}, connIds);
|
|
1679
|
+
|
|
1680
|
+
// 逐块发送并等待 ACK
|
|
1681
|
+
for (let idx = 0; idx < totalChunks; idx++) {
|
|
1682
|
+
const start = idx * chunkSize;
|
|
1683
|
+
const end = Math.min(start + chunkSize, size);
|
|
1684
|
+
const slice = loaded.buffer.subarray(start, end);
|
|
1685
|
+
const chunkSha256 = createHash('sha256').update(slice).digest('hex');
|
|
1686
|
+
|
|
1687
|
+
let ok = false;
|
|
1688
|
+
let lastErr: unknown = null;
|
|
1689
|
+
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
1690
|
+
ctx.broadcastToConnIds('bncr.file.chunk', {
|
|
1691
|
+
transferId,
|
|
1692
|
+
chunkIndex: idx,
|
|
1693
|
+
offset: start,
|
|
1694
|
+
size: slice.byteLength,
|
|
1695
|
+
chunkSha256,
|
|
1696
|
+
base64: slice.toString('base64'),
|
|
1697
|
+
ts: now(),
|
|
1698
|
+
}, connIds);
|
|
1699
|
+
|
|
1700
|
+
try {
|
|
1701
|
+
await this.waitChunkAck({ transferId, chunkIndex: idx, timeoutMs: FILE_TRANSFER_ACK_TTL_MS });
|
|
1702
|
+
ok = true;
|
|
1703
|
+
break;
|
|
1704
|
+
} catch (err) {
|
|
1705
|
+
lastErr = err;
|
|
1706
|
+
await this.sleepMs(150 * attempt);
|
|
1707
|
+
}
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
if (!ok) {
|
|
1711
|
+
st.status = 'aborted';
|
|
1712
|
+
st.error = String((lastErr as any)?.message || lastErr || `chunk-${idx}-failed`);
|
|
1713
|
+
this.fileSendTransfers.set(transferId, st);
|
|
1714
|
+
ctx.broadcastToConnIds('bncr.file.abort', {
|
|
1715
|
+
transferId,
|
|
1716
|
+
reason: st.error,
|
|
1717
|
+
ts: now(),
|
|
1718
|
+
}, connIds);
|
|
1719
|
+
throw new Error(st.error);
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
ctx.broadcastToConnIds('bncr.file.complete', {
|
|
1724
|
+
transferId,
|
|
1725
|
+
ts: now(),
|
|
1726
|
+
}, connIds);
|
|
1727
|
+
|
|
1728
|
+
const done = await this.waitCompleteAck({ transferId, timeoutMs: 60_000 });
|
|
1729
|
+
|
|
1730
|
+
return {
|
|
1731
|
+
mode: 'chunk',
|
|
1732
|
+
mimeType,
|
|
1733
|
+
fileName,
|
|
1734
|
+
path: done.path,
|
|
1735
|
+
};
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1114
1738
|
private async enqueueFromReply(params: {
|
|
1115
1739
|
accountId: string;
|
|
1116
1740
|
sessionKey: string;
|
|
@@ -1129,7 +1753,13 @@ class BncrBridgeRuntime {
|
|
|
1129
1753
|
if (mediaList.length > 0) {
|
|
1130
1754
|
let first = true;
|
|
1131
1755
|
for (const mediaUrl of mediaList) {
|
|
1132
|
-
const media = await this.
|
|
1756
|
+
const media = await this.transferMediaToBncrClient({
|
|
1757
|
+
accountId,
|
|
1758
|
+
sessionKey,
|
|
1759
|
+
route,
|
|
1760
|
+
mediaUrl,
|
|
1761
|
+
mediaLocalRoots,
|
|
1762
|
+
});
|
|
1133
1763
|
const messageId = randomUUID();
|
|
1134
1764
|
const mediaMsg = first ? asString(payload.text || '') : '';
|
|
1135
1765
|
const frame = {
|
|
@@ -1141,11 +1771,21 @@ class BncrBridgeRuntime {
|
|
|
1141
1771
|
platform: route.platform,
|
|
1142
1772
|
groupId: route.groupId,
|
|
1143
1773
|
userId: route.userId,
|
|
1144
|
-
type:
|
|
1774
|
+
type: resolveBncrOutboundMessageType({
|
|
1775
|
+
mimeType: media.mimeType,
|
|
1776
|
+
fileName: media.fileName,
|
|
1777
|
+
hasPayload: !!(media.path || media.mediaBase64),
|
|
1778
|
+
}),
|
|
1779
|
+
mimeType: media.mimeType || '',
|
|
1145
1780
|
msg: mediaMsg,
|
|
1146
|
-
path: mediaUrl,
|
|
1147
|
-
base64: media.mediaBase64,
|
|
1148
|
-
fileName:
|
|
1781
|
+
path: media.path || mediaUrl,
|
|
1782
|
+
base64: media.mediaBase64 || '',
|
|
1783
|
+
fileName: resolveOutboundFileName({
|
|
1784
|
+
mediaUrl,
|
|
1785
|
+
fileName: media.fileName,
|
|
1786
|
+
mimeType: media.mimeType,
|
|
1787
|
+
}),
|
|
1788
|
+
transferMode: media.mode,
|
|
1149
1789
|
},
|
|
1150
1790
|
ts: now(),
|
|
1151
1791
|
};
|
|
@@ -1207,6 +1847,7 @@ class BncrBridgeRuntime {
|
|
|
1207
1847
|
this.rememberGatewayContext(context);
|
|
1208
1848
|
this.markSeen(accountId, connId, clientId);
|
|
1209
1849
|
this.markActivity(accountId);
|
|
1850
|
+
this.incrementCounter(this.connectEventsByAccount, accountId);
|
|
1210
1851
|
|
|
1211
1852
|
respond(true, {
|
|
1212
1853
|
channel: CHANNEL_ID,
|
|
@@ -1218,6 +1859,7 @@ class BncrBridgeRuntime {
|
|
|
1218
1859
|
activeConnections: this.activeConnectionCount(accountId),
|
|
1219
1860
|
pending: Array.from(this.outbox.values()).filter((v) => v.accountId === accountId).length,
|
|
1220
1861
|
deadLetter: this.deadLetter.filter((v) => v.accountId === accountId).length,
|
|
1862
|
+
diagnostics: this.buildIntegratedDiagnostics(accountId),
|
|
1221
1863
|
now: now(),
|
|
1222
1864
|
});
|
|
1223
1865
|
|
|
@@ -1231,6 +1873,7 @@ class BncrBridgeRuntime {
|
|
|
1231
1873
|
const clientId = asString((params as any)?.clientId || '').trim() || undefined;
|
|
1232
1874
|
this.rememberGatewayContext(context);
|
|
1233
1875
|
this.markSeen(accountId, connId, clientId);
|
|
1876
|
+
this.incrementCounter(this.ackEventsByAccount, accountId);
|
|
1234
1877
|
|
|
1235
1878
|
const messageId = asString(params?.messageId || '').trim();
|
|
1236
1879
|
if (!messageId) {
|
|
@@ -1280,6 +1923,7 @@ class BncrBridgeRuntime {
|
|
|
1280
1923
|
this.rememberGatewayContext(context);
|
|
1281
1924
|
this.markSeen(accountId, connId, clientId);
|
|
1282
1925
|
this.markActivity(accountId);
|
|
1926
|
+
this.incrementCounter(this.activityEventsByAccount, accountId);
|
|
1283
1927
|
|
|
1284
1928
|
// 轻量活动心跳:仅刷新在线活跃状态,不承担拉取职责。
|
|
1285
1929
|
respond(true, {
|
|
@@ -1293,6 +1937,299 @@ class BncrBridgeRuntime {
|
|
|
1293
1937
|
});
|
|
1294
1938
|
};
|
|
1295
1939
|
|
|
1940
|
+
handleDiagnostics = async ({ params, respond }: GatewayRequestHandlerOptions) => {
|
|
1941
|
+
const accountId = normalizeAccountId(asString(params?.accountId || ''));
|
|
1942
|
+
const runtime = this.getAccountRuntimeSnapshot(accountId);
|
|
1943
|
+
const diagnostics = this.buildIntegratedDiagnostics(accountId);
|
|
1944
|
+
|
|
1945
|
+
respond(true, {
|
|
1946
|
+
channel: CHANNEL_ID,
|
|
1947
|
+
accountId,
|
|
1948
|
+
runtime,
|
|
1949
|
+
diagnostics,
|
|
1950
|
+
now: now(),
|
|
1951
|
+
});
|
|
1952
|
+
};
|
|
1953
|
+
|
|
1954
|
+
handleFileInit = async ({ params, respond, client, context }: GatewayRequestHandlerOptions) => {
|
|
1955
|
+
const accountId = normalizeAccountId(asString(params?.accountId || ''));
|
|
1956
|
+
const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
|
|
1957
|
+
const clientId = asString((params as any)?.clientId || '').trim() || undefined;
|
|
1958
|
+
this.rememberGatewayContext(context);
|
|
1959
|
+
this.markSeen(accountId, connId, clientId);
|
|
1960
|
+
this.markActivity(accountId);
|
|
1961
|
+
|
|
1962
|
+
const transferId = asString(params?.transferId || '').trim();
|
|
1963
|
+
const sessionKey = asString(params?.sessionKey || '').trim();
|
|
1964
|
+
const fileName = asString(params?.fileName || '').trim() || 'file.bin';
|
|
1965
|
+
const mimeType = asString(params?.mimeType || '').trim() || 'application/octet-stream';
|
|
1966
|
+
const fileSize = Number(params?.fileSize || 0);
|
|
1967
|
+
const chunkSize = Number(params?.chunkSize || 256 * 1024);
|
|
1968
|
+
const totalChunks = Number(params?.totalChunks || 0);
|
|
1969
|
+
const fileSha256 = asString(params?.fileSha256 || '').trim();
|
|
1970
|
+
|
|
1971
|
+
if (!transferId || !sessionKey || !fileSize || !chunkSize || !totalChunks) {
|
|
1972
|
+
respond(false, { error: 'transferId/sessionKey/fileSize/chunkSize/totalChunks required' });
|
|
1973
|
+
return;
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1976
|
+
const normalized = normalizeStoredSessionKey(sessionKey);
|
|
1977
|
+
if (!normalized) {
|
|
1978
|
+
respond(false, { error: 'invalid sessionKey' });
|
|
1979
|
+
return;
|
|
1980
|
+
}
|
|
1981
|
+
|
|
1982
|
+
const existing = this.fileRecvTransfers.get(transferId);
|
|
1983
|
+
if (existing) {
|
|
1984
|
+
respond(true, {
|
|
1985
|
+
ok: true,
|
|
1986
|
+
transferId,
|
|
1987
|
+
status: existing.status,
|
|
1988
|
+
duplicated: true,
|
|
1989
|
+
});
|
|
1990
|
+
return;
|
|
1991
|
+
}
|
|
1992
|
+
|
|
1993
|
+
const route = parseRouteLike({
|
|
1994
|
+
platform: asString(params?.platform || normalized.route.platform),
|
|
1995
|
+
groupId: asString(params?.groupId || normalized.route.groupId),
|
|
1996
|
+
userId: asString(params?.userId || normalized.route.userId),
|
|
1997
|
+
}) || normalized.route;
|
|
1998
|
+
|
|
1999
|
+
this.fileRecvTransfers.set(transferId, {
|
|
2000
|
+
transferId,
|
|
2001
|
+
accountId,
|
|
2002
|
+
sessionKey: normalized.sessionKey,
|
|
2003
|
+
route,
|
|
2004
|
+
fileName,
|
|
2005
|
+
mimeType,
|
|
2006
|
+
fileSize,
|
|
2007
|
+
chunkSize,
|
|
2008
|
+
totalChunks,
|
|
2009
|
+
fileSha256,
|
|
2010
|
+
startedAt: now(),
|
|
2011
|
+
status: 'init',
|
|
2012
|
+
bufferByChunk: new Map(),
|
|
2013
|
+
receivedChunks: new Set(),
|
|
2014
|
+
});
|
|
2015
|
+
|
|
2016
|
+
respond(true, {
|
|
2017
|
+
ok: true,
|
|
2018
|
+
transferId,
|
|
2019
|
+
status: 'init',
|
|
2020
|
+
});
|
|
2021
|
+
};
|
|
2022
|
+
|
|
2023
|
+
handleFileChunk = async ({ params, respond, client, context }: GatewayRequestHandlerOptions) => {
|
|
2024
|
+
const accountId = normalizeAccountId(asString(params?.accountId || ''));
|
|
2025
|
+
const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
|
|
2026
|
+
const clientId = asString((params as any)?.clientId || '').trim() || undefined;
|
|
2027
|
+
this.rememberGatewayContext(context);
|
|
2028
|
+
this.markSeen(accountId, connId, clientId);
|
|
2029
|
+
this.markActivity(accountId);
|
|
2030
|
+
|
|
2031
|
+
const transferId = asString(params?.transferId || '').trim();
|
|
2032
|
+
const chunkIndex = Number(params?.chunkIndex ?? -1);
|
|
2033
|
+
const offset = Number(params?.offset ?? 0);
|
|
2034
|
+
const size = Number(params?.size ?? 0);
|
|
2035
|
+
const chunkSha256 = asString(params?.chunkSha256 || '').trim();
|
|
2036
|
+
const base64 = asString(params?.base64 || '');
|
|
2037
|
+
|
|
2038
|
+
if (!transferId || chunkIndex < 0 || !base64) {
|
|
2039
|
+
respond(false, { error: 'transferId/chunkIndex/base64 required' });
|
|
2040
|
+
return;
|
|
2041
|
+
}
|
|
2042
|
+
|
|
2043
|
+
const st = this.fileRecvTransfers.get(transferId);
|
|
2044
|
+
if (!st) {
|
|
2045
|
+
respond(false, { error: 'transfer not found' });
|
|
2046
|
+
return;
|
|
2047
|
+
}
|
|
2048
|
+
|
|
2049
|
+
try {
|
|
2050
|
+
const buf = Buffer.from(base64, 'base64');
|
|
2051
|
+
if (size > 0 && buf.length !== size) {
|
|
2052
|
+
throw new Error(`chunk size mismatch expected=${size} got=${buf.length}`);
|
|
2053
|
+
}
|
|
2054
|
+
if (chunkSha256) {
|
|
2055
|
+
const digest = createHash('sha256').update(buf).digest('hex');
|
|
2056
|
+
if (digest !== chunkSha256) throw new Error('chunk sha256 mismatch');
|
|
2057
|
+
}
|
|
2058
|
+
st.bufferByChunk.set(chunkIndex, buf);
|
|
2059
|
+
st.receivedChunks.add(chunkIndex);
|
|
2060
|
+
st.status = 'transferring';
|
|
2061
|
+
this.fileRecvTransfers.set(transferId, st);
|
|
2062
|
+
|
|
2063
|
+
respond(true, {
|
|
2064
|
+
ok: true,
|
|
2065
|
+
transferId,
|
|
2066
|
+
chunkIndex,
|
|
2067
|
+
offset,
|
|
2068
|
+
received: st.receivedChunks.size,
|
|
2069
|
+
totalChunks: st.totalChunks,
|
|
2070
|
+
});
|
|
2071
|
+
} catch (error) {
|
|
2072
|
+
respond(false, { error: String((error as any)?.message || error || 'chunk invalid') });
|
|
2073
|
+
}
|
|
2074
|
+
};
|
|
2075
|
+
|
|
2076
|
+
handleFileComplete = async ({ params, respond, client, context }: GatewayRequestHandlerOptions) => {
|
|
2077
|
+
const accountId = normalizeAccountId(asString(params?.accountId || ''));
|
|
2078
|
+
const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
|
|
2079
|
+
const clientId = asString((params as any)?.clientId || '').trim() || undefined;
|
|
2080
|
+
this.rememberGatewayContext(context);
|
|
2081
|
+
this.markSeen(accountId, connId, clientId);
|
|
2082
|
+
this.markActivity(accountId);
|
|
2083
|
+
|
|
2084
|
+
const transferId = asString(params?.transferId || '').trim();
|
|
2085
|
+
if (!transferId) {
|
|
2086
|
+
respond(false, { error: 'transferId required' });
|
|
2087
|
+
return;
|
|
2088
|
+
}
|
|
2089
|
+
|
|
2090
|
+
const st = this.fileRecvTransfers.get(transferId);
|
|
2091
|
+
if (!st) {
|
|
2092
|
+
respond(false, { error: 'transfer not found' });
|
|
2093
|
+
return;
|
|
2094
|
+
}
|
|
2095
|
+
|
|
2096
|
+
try {
|
|
2097
|
+
if (st.receivedChunks.size < st.totalChunks) {
|
|
2098
|
+
throw new Error(`chunk not complete received=${st.receivedChunks.size} total=${st.totalChunks}`);
|
|
2099
|
+
}
|
|
2100
|
+
|
|
2101
|
+
const ordered = Array.from(st.bufferByChunk.entries()).sort((a, b) => a[0] - b[0]).map((x) => x[1]);
|
|
2102
|
+
const merged = Buffer.concat(ordered);
|
|
2103
|
+
if (st.fileSize > 0 && merged.length !== st.fileSize) {
|
|
2104
|
+
throw new Error(`file size mismatch expected=${st.fileSize} got=${merged.length}`);
|
|
2105
|
+
}
|
|
2106
|
+
const digest = createHash('sha256').update(merged).digest('hex');
|
|
2107
|
+
if (st.fileSha256 && digest !== st.fileSha256) {
|
|
2108
|
+
throw new Error('file sha256 mismatch');
|
|
2109
|
+
}
|
|
2110
|
+
|
|
2111
|
+
const saved = await this.api.runtime.channel.media.saveMediaBuffer(
|
|
2112
|
+
merged,
|
|
2113
|
+
st.mimeType,
|
|
2114
|
+
'inbound',
|
|
2115
|
+
50 * 1024 * 1024,
|
|
2116
|
+
st.fileName,
|
|
2117
|
+
);
|
|
2118
|
+
st.completedPath = saved.path;
|
|
2119
|
+
st.status = 'completed';
|
|
2120
|
+
this.fileRecvTransfers.set(transferId, st);
|
|
2121
|
+
|
|
2122
|
+
respond(true, {
|
|
2123
|
+
ok: true,
|
|
2124
|
+
transferId,
|
|
2125
|
+
path: saved.path,
|
|
2126
|
+
size: merged.length,
|
|
2127
|
+
fileName: st.fileName,
|
|
2128
|
+
mimeType: st.mimeType,
|
|
2129
|
+
fileSha256: digest,
|
|
2130
|
+
});
|
|
2131
|
+
} catch (error) {
|
|
2132
|
+
st.status = 'aborted';
|
|
2133
|
+
st.error = String((error as any)?.message || error || 'complete failed');
|
|
2134
|
+
this.fileRecvTransfers.set(transferId, st);
|
|
2135
|
+
respond(false, { error: st.error });
|
|
2136
|
+
}
|
|
2137
|
+
};
|
|
2138
|
+
|
|
2139
|
+
handleFileAbort = async ({ params, respond, client, context }: GatewayRequestHandlerOptions) => {
|
|
2140
|
+
const accountId = normalizeAccountId(asString(params?.accountId || ''));
|
|
2141
|
+
const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
|
|
2142
|
+
const clientId = asString((params as any)?.clientId || '').trim() || undefined;
|
|
2143
|
+
this.rememberGatewayContext(context);
|
|
2144
|
+
this.markSeen(accountId, connId, clientId);
|
|
2145
|
+
this.markActivity(accountId);
|
|
2146
|
+
|
|
2147
|
+
const transferId = asString(params?.transferId || '').trim();
|
|
2148
|
+
if (!transferId) {
|
|
2149
|
+
respond(false, { error: 'transferId required' });
|
|
2150
|
+
return;
|
|
2151
|
+
}
|
|
2152
|
+
|
|
2153
|
+
const st = this.fileRecvTransfers.get(transferId);
|
|
2154
|
+
if (!st) {
|
|
2155
|
+
respond(true, { ok: true, transferId, message: 'not-found' });
|
|
2156
|
+
return;
|
|
2157
|
+
}
|
|
2158
|
+
|
|
2159
|
+
st.status = 'aborted';
|
|
2160
|
+
st.error = asString(params?.reason || 'aborted');
|
|
2161
|
+
this.fileRecvTransfers.set(transferId, st);
|
|
2162
|
+
|
|
2163
|
+
respond(true, {
|
|
2164
|
+
ok: true,
|
|
2165
|
+
transferId,
|
|
2166
|
+
status: 'aborted',
|
|
2167
|
+
});
|
|
2168
|
+
};
|
|
2169
|
+
|
|
2170
|
+
handleFileAck = async ({ params, respond, client, context }: GatewayRequestHandlerOptions) => {
|
|
2171
|
+
const accountId = normalizeAccountId(asString(params?.accountId || ''));
|
|
2172
|
+
const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
|
|
2173
|
+
const clientId = asString((params as any)?.clientId || '').trim() || undefined;
|
|
2174
|
+
this.rememberGatewayContext(context);
|
|
2175
|
+
this.markSeen(accountId, connId, clientId);
|
|
2176
|
+
this.markActivity(accountId);
|
|
2177
|
+
|
|
2178
|
+
const transferId = asString(params?.transferId || '').trim();
|
|
2179
|
+
const stage = asString(params?.stage || '').trim();
|
|
2180
|
+
const ok = params?.ok !== false;
|
|
2181
|
+
const chunkIndex = Number(params?.chunkIndex ?? -1);
|
|
2182
|
+
|
|
2183
|
+
if (!transferId || !stage) {
|
|
2184
|
+
respond(false, { error: 'transferId/stage required' });
|
|
2185
|
+
return;
|
|
2186
|
+
}
|
|
2187
|
+
|
|
2188
|
+
const st = this.fileSendTransfers.get(transferId);
|
|
2189
|
+
if (st) {
|
|
2190
|
+
if (!ok) {
|
|
2191
|
+
const code = asString(params?.errorCode || 'ACK_FAILED');
|
|
2192
|
+
const msg = asString(params?.errorMessage || 'ack failed');
|
|
2193
|
+
st.error = `${code}:${msg}`;
|
|
2194
|
+
if (stage === 'chunk' && chunkIndex >= 0) st.failedChunks.set(chunkIndex, st.error);
|
|
2195
|
+
if (stage === 'complete') st.status = 'aborted';
|
|
2196
|
+
} else {
|
|
2197
|
+
if (stage === 'chunk' && chunkIndex >= 0) {
|
|
2198
|
+
st.ackedChunks.add(chunkIndex);
|
|
2199
|
+
st.status = 'transferring';
|
|
2200
|
+
}
|
|
2201
|
+
if (stage === 'complete') {
|
|
2202
|
+
st.status = 'completed';
|
|
2203
|
+
st.completedPath = asString(params?.path || '').trim() || st.completedPath;
|
|
2204
|
+
}
|
|
2205
|
+
}
|
|
2206
|
+
this.fileSendTransfers.set(transferId, st);
|
|
2207
|
+
}
|
|
2208
|
+
|
|
2209
|
+
// 唤醒等待中的 chunk/complete ACK
|
|
2210
|
+
this.resolveFileAck({
|
|
2211
|
+
transferId,
|
|
2212
|
+
stage,
|
|
2213
|
+
chunkIndex: chunkIndex >= 0 ? chunkIndex : undefined,
|
|
2214
|
+
payload: {
|
|
2215
|
+
ok,
|
|
2216
|
+
transferId,
|
|
2217
|
+
stage,
|
|
2218
|
+
path: asString(params?.path || '').trim(),
|
|
2219
|
+
errorCode: asString(params?.errorCode || ''),
|
|
2220
|
+
errorMessage: asString(params?.errorMessage || ''),
|
|
2221
|
+
},
|
|
2222
|
+
ok,
|
|
2223
|
+
});
|
|
2224
|
+
|
|
2225
|
+
respond(true, {
|
|
2226
|
+
ok: true,
|
|
2227
|
+
transferId,
|
|
2228
|
+
stage,
|
|
2229
|
+
state: st?.status || 'late',
|
|
2230
|
+
});
|
|
2231
|
+
};
|
|
2232
|
+
|
|
1296
2233
|
handleInbound = async ({ params, respond, client, context }: GatewayRequestHandlerOptions) => {
|
|
1297
2234
|
const accountId = normalizeAccountId(asString(params?.accountId || ''));
|
|
1298
2235
|
const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
|
|
@@ -1300,6 +2237,7 @@ class BncrBridgeRuntime {
|
|
|
1300
2237
|
this.rememberGatewayContext(context);
|
|
1301
2238
|
this.markSeen(accountId, connId, clientId);
|
|
1302
2239
|
this.markActivity(accountId);
|
|
2240
|
+
this.incrementCounter(this.inboundEventsByAccount, accountId);
|
|
1303
2241
|
|
|
1304
2242
|
const platform = asString(params?.platform || '').trim();
|
|
1305
2243
|
const groupId = asString(params?.groupId || '0').trim() || '0';
|
|
@@ -1320,6 +2258,7 @@ class BncrBridgeRuntime {
|
|
|
1320
2258
|
const text = asString(params?.msg || '');
|
|
1321
2259
|
const msgType = asString(params?.type || 'text') || 'text';
|
|
1322
2260
|
const mediaBase64 = asString(params?.base64 || '');
|
|
2261
|
+
const mediaPathFromTransfer = asString(params?.path || '').trim();
|
|
1323
2262
|
const mimeType = asString(params?.mimeType || '').trim() || undefined;
|
|
1324
2263
|
const fileName = asString(params?.fileName || '').trim() || undefined;
|
|
1325
2264
|
const msgId = asString(params?.msgId || '').trim() || undefined;
|
|
@@ -1396,6 +2335,8 @@ class BncrBridgeRuntime {
|
|
|
1396
2335
|
fileName,
|
|
1397
2336
|
);
|
|
1398
2337
|
mediaPath = saved.path;
|
|
2338
|
+
} else if (mediaPathFromTransfer && fs.existsSync(mediaPathFromTransfer)) {
|
|
2339
|
+
mediaPath = mediaPathFromTransfer;
|
|
1399
2340
|
}
|
|
1400
2341
|
|
|
1401
2342
|
const rawBody = agentText || (msgType === 'text' ? '' : `[${msgType}]`);
|
|
@@ -1412,6 +2353,11 @@ class BncrBridgeRuntime {
|
|
|
1412
2353
|
});
|
|
1413
2354
|
|
|
1414
2355
|
const displayTo = formatDisplayScope(route);
|
|
2356
|
+
// 全场景口径:SenderId 统一上报为 hex(scope)
|
|
2357
|
+
// 作为平台侧稳定身份键,避免退化为裸 userId。
|
|
2358
|
+
const senderIdForContext =
|
|
2359
|
+
parseStrictBncrSessionKey(baseSessionKey)?.scopeHex
|
|
2360
|
+
|| routeScopeToHex(route);
|
|
1415
2361
|
const ctxPayload = this.api.runtime.channel.reply.finalizeInboundContext({
|
|
1416
2362
|
Body: body,
|
|
1417
2363
|
BodyForAgent: rawBody,
|
|
@@ -1425,7 +2371,7 @@ class BncrBridgeRuntime {
|
|
|
1425
2371
|
AccountId: accountId,
|
|
1426
2372
|
ChatType: peer.kind,
|
|
1427
2373
|
ConversationLabel: displayTo,
|
|
1428
|
-
SenderId:
|
|
2374
|
+
SenderId: senderIdForContext,
|
|
1429
2375
|
Provider: CHANNEL_ID,
|
|
1430
2376
|
Surface: CHANNEL_ID,
|
|
1431
2377
|
MessageSid: msgId,
|
|
@@ -1529,6 +2475,25 @@ class BncrBridgeRuntime {
|
|
|
1529
2475
|
const accountId = normalizeAccountId(ctx.accountId);
|
|
1530
2476
|
const to = asString(ctx.to || '').trim();
|
|
1531
2477
|
|
|
2478
|
+
if (BNCR_DEBUG_VERBOSE) {
|
|
2479
|
+
this.api.logger.info?.(
|
|
2480
|
+
`[bncr-send-entry:text] ${JSON.stringify({
|
|
2481
|
+
accountId,
|
|
2482
|
+
to,
|
|
2483
|
+
text: asString(ctx?.text || ''),
|
|
2484
|
+
mediaUrl: asString(ctx?.mediaUrl || ''),
|
|
2485
|
+
sessionKey: asString(ctx?.sessionKey || ''),
|
|
2486
|
+
mirrorSessionKey: asString(ctx?.mirror?.sessionKey || ''),
|
|
2487
|
+
rawCtx: {
|
|
2488
|
+
to: ctx?.to,
|
|
2489
|
+
accountId: ctx?.accountId,
|
|
2490
|
+
threadId: ctx?.threadId,
|
|
2491
|
+
replyToId: ctx?.replyToId,
|
|
2492
|
+
},
|
|
2493
|
+
})}`,
|
|
2494
|
+
);
|
|
2495
|
+
}
|
|
2496
|
+
|
|
1532
2497
|
const verified = this.resolveVerifiedTarget(to, accountId);
|
|
1533
2498
|
|
|
1534
2499
|
this.rememberSessionRoute(verified.sessionKey, accountId, verified.route);
|
|
@@ -1550,6 +2515,26 @@ class BncrBridgeRuntime {
|
|
|
1550
2515
|
const accountId = normalizeAccountId(ctx.accountId);
|
|
1551
2516
|
const to = asString(ctx.to || '').trim();
|
|
1552
2517
|
|
|
2518
|
+
if (BNCR_DEBUG_VERBOSE) {
|
|
2519
|
+
this.api.logger.info?.(
|
|
2520
|
+
`[bncr-send-entry:media] ${JSON.stringify({
|
|
2521
|
+
accountId,
|
|
2522
|
+
to,
|
|
2523
|
+
text: asString(ctx?.text || ''),
|
|
2524
|
+
mediaUrl: asString(ctx?.mediaUrl || ''),
|
|
2525
|
+
mediaUrls: Array.isArray(ctx?.mediaUrls) ? ctx.mediaUrls : undefined,
|
|
2526
|
+
sessionKey: asString(ctx?.sessionKey || ''),
|
|
2527
|
+
mirrorSessionKey: asString(ctx?.mirror?.sessionKey || ''),
|
|
2528
|
+
rawCtx: {
|
|
2529
|
+
to: ctx?.to,
|
|
2530
|
+
accountId: ctx?.accountId,
|
|
2531
|
+
threadId: ctx?.threadId,
|
|
2532
|
+
replyToId: ctx?.replyToId,
|
|
2533
|
+
},
|
|
2534
|
+
})}`,
|
|
2535
|
+
);
|
|
2536
|
+
}
|
|
2537
|
+
|
|
1553
2538
|
const verified = this.resolveVerifiedTarget(to, accountId);
|
|
1554
2539
|
|
|
1555
2540
|
this.rememberSessionRoute(verified.sessionKey, accountId, verified.route);
|
|
@@ -1632,7 +2617,7 @@ export function createBncrChannelPlugin(bridge: BncrBridgeRuntime) {
|
|
|
1632
2617
|
looksLikeId: (raw: string, normalized?: string) => {
|
|
1633
2618
|
return Boolean(asString(normalized || raw).trim());
|
|
1634
2619
|
},
|
|
1635
|
-
hint: '
|
|
2620
|
+
hint: 'Compat(6): agent:main:bncr:direct:<hex>, agent:main:bncr:group:<hex>, bncr:<hex>, bncr:g-<hex>, bncr:<platform>:<group>:<user>, bncr:g-<platform>:<group>:<user>; preferred to=bncr:<platform>:<group>:<user>, canonical sessionKey=agent:main:bncr:direct:<hex>',
|
|
1636
2621
|
},
|
|
1637
2622
|
},
|
|
1638
2623
|
configSchema: BncrConfigSchema,
|
|
@@ -1709,6 +2694,7 @@ export function createBncrChannelPlugin(bridge: BncrBridgeRuntime) {
|
|
|
1709
2694
|
const lastInboundAgo = rt?.lastInboundAgo ?? meta.lastInboundAgo ?? '-';
|
|
1710
2695
|
const lastOutboundAt = rt?.lastOutboundAt ?? meta.lastOutboundAt ?? null;
|
|
1711
2696
|
const lastOutboundAgo = rt?.lastOutboundAgo ?? meta.lastOutboundAgo ?? '-';
|
|
2697
|
+
const diagnostics = rt?.diagnostics ?? meta.diagnostics ?? null;
|
|
1712
2698
|
// 右侧状态字段统一:离线时也显示 Status(避免出现 configured 文案)
|
|
1713
2699
|
const normalizedMode = rt?.mode === 'linked'
|
|
1714
2700
|
? 'linked'
|
|
@@ -1730,6 +2716,7 @@ export function createBncrChannelPlugin(bridge: BncrBridgeRuntime) {
|
|
|
1730
2716
|
mode: normalizedMode,
|
|
1731
2717
|
pending,
|
|
1732
2718
|
deadLetter,
|
|
2719
|
+
healthSummary: bridge.getStatusHeadline(account?.accountId),
|
|
1733
2720
|
lastSessionKey,
|
|
1734
2721
|
lastSessionScope,
|
|
1735
2722
|
lastSessionAt,
|
|
@@ -1740,6 +2727,7 @@ export function createBncrChannelPlugin(bridge: BncrBridgeRuntime) {
|
|
|
1740
2727
|
lastInboundAgo,
|
|
1741
2728
|
lastOutboundAt,
|
|
1742
2729
|
lastOutboundAgo,
|
|
2730
|
+
diagnostics,
|
|
1743
2731
|
};
|
|
1744
2732
|
},
|
|
1745
2733
|
resolveAccountState: ({ enabled, configured, account, cfg, runtime }: any) => {
|
|
@@ -1750,7 +2738,18 @@ export function createBncrChannelPlugin(bridge: BncrBridgeRuntime) {
|
|
|
1750
2738
|
return rt?.connected ? 'linked' : 'configured';
|
|
1751
2739
|
},
|
|
1752
2740
|
},
|
|
1753
|
-
gatewayMethods: [
|
|
2741
|
+
gatewayMethods: [
|
|
2742
|
+
'bncr.connect',
|
|
2743
|
+
'bncr.inbound',
|
|
2744
|
+
'bncr.activity',
|
|
2745
|
+
'bncr.ack',
|
|
2746
|
+
'bncr.diagnostics',
|
|
2747
|
+
'bncr.file.init',
|
|
2748
|
+
'bncr.file.chunk',
|
|
2749
|
+
'bncr.file.complete',
|
|
2750
|
+
'bncr.file.abort',
|
|
2751
|
+
'bncr.file.ack',
|
|
2752
|
+
],
|
|
1754
2753
|
gateway: {
|
|
1755
2754
|
startAccount: bridge.channelStartAccount,
|
|
1756
2755
|
stopAccount: bridge.channelStopAccount,
|