@xmoxmo/bncr 0.0.2 → 0.0.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 +106 -62
- package/index.ts +24 -0
- package/package.json +1 -1
- package/src/channel.ts +1017 -41
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,14 @@ 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 FILE_FORCE_CHUNK = true; // 统一走 WS 分块,保留 base64 仅作兜底
|
|
25
|
+
const FILE_INLINE_THRESHOLD = 5 * 1024 * 1024; // fallback 阈值(仅 FILE_FORCE_CHUNK=false 时生效)
|
|
26
|
+
const FILE_CHUNK_SIZE = 256 * 1024; // 256KB
|
|
27
|
+
const FILE_CHUNK_RETRY = 3;
|
|
28
|
+
const FILE_ACK_TIMEOUT_MS = 30_000;
|
|
29
|
+
const FILE_TRANSFER_ACK_TTL_MS = 30_000;
|
|
30
|
+
const FILE_TRANSFER_KEEP_MS = 6 * 60 * 60 * 1000;
|
|
31
|
+
const BNCR_DEBUG_VERBOSE = true; // 临时调试:打印发送入口完整请求体
|
|
23
32
|
|
|
24
33
|
const BncrConfigSchema = {
|
|
25
34
|
schema: {
|
|
@@ -68,6 +77,44 @@ type OutboxEntry = {
|
|
|
68
77
|
lastError?: string;
|
|
69
78
|
};
|
|
70
79
|
|
|
80
|
+
type FileSendTransferState = {
|
|
81
|
+
transferId: string;
|
|
82
|
+
accountId: string;
|
|
83
|
+
sessionKey: string;
|
|
84
|
+
route: BncrRoute;
|
|
85
|
+
fileName: string;
|
|
86
|
+
mimeType: string;
|
|
87
|
+
fileSize: number;
|
|
88
|
+
chunkSize: number;
|
|
89
|
+
totalChunks: number;
|
|
90
|
+
fileSha256: string;
|
|
91
|
+
startedAt: number;
|
|
92
|
+
status: 'init' | 'transferring' | 'completed' | 'aborted';
|
|
93
|
+
ackedChunks: Set<number>;
|
|
94
|
+
failedChunks: Map<number, string>;
|
|
95
|
+
completedPath?: string;
|
|
96
|
+
error?: string;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
type FileRecvTransferState = {
|
|
100
|
+
transferId: string;
|
|
101
|
+
accountId: string;
|
|
102
|
+
sessionKey: string;
|
|
103
|
+
route: BncrRoute;
|
|
104
|
+
fileName: string;
|
|
105
|
+
mimeType: string;
|
|
106
|
+
fileSize: number;
|
|
107
|
+
chunkSize: number;
|
|
108
|
+
totalChunks: number;
|
|
109
|
+
fileSha256: string;
|
|
110
|
+
startedAt: number;
|
|
111
|
+
status: 'init' | 'transferring' | 'completed' | 'aborted';
|
|
112
|
+
bufferByChunk: Map<number, Buffer>;
|
|
113
|
+
receivedChunks: Set<number>;
|
|
114
|
+
completedPath?: string;
|
|
115
|
+
error?: string;
|
|
116
|
+
};
|
|
117
|
+
|
|
71
118
|
type PersistedState = {
|
|
72
119
|
outbox: OutboxEntry[];
|
|
73
120
|
deadLetter: OutboxEntry[];
|
|
@@ -109,7 +156,11 @@ function asString(v: unknown, fallback = ''): string {
|
|
|
109
156
|
|
|
110
157
|
function normalizeAccountId(accountId?: string | null): string {
|
|
111
158
|
const v = asString(accountId || '').trim();
|
|
112
|
-
|
|
159
|
+
if (!v) return BNCR_DEFAULT_ACCOUNT_ID;
|
|
160
|
+
const lower = v.toLowerCase();
|
|
161
|
+
// 历史兼容:default/primary 统一折叠到 Primary,避免状态尾巴反复出现。
|
|
162
|
+
if (lower === 'default' || lower === 'primary') return BNCR_DEFAULT_ACCOUNT_ID;
|
|
163
|
+
return v;
|
|
113
164
|
}
|
|
114
165
|
|
|
115
166
|
function parseRouteFromScope(scope: string): BncrRoute | null {
|
|
@@ -124,18 +175,45 @@ function parseRouteFromDisplayScope(scope: string): BncrRoute | null {
|
|
|
124
175
|
const raw = asString(scope).trim();
|
|
125
176
|
if (!raw) return null;
|
|
126
177
|
|
|
127
|
-
//
|
|
128
|
-
|
|
129
|
-
|
|
178
|
+
// 终版兼容(6 种):
|
|
179
|
+
// 1) bncr:g-<hex(scope)>
|
|
180
|
+
// 2) bncr:<hex(scope)>
|
|
181
|
+
// 3) bncr:<platform>:<groupId>:<userId>
|
|
182
|
+
// 4) bncr:g-<platform>:<groupId>:<userId>
|
|
183
|
+
|
|
184
|
+
// 1) bncr:g-<hex> 或 bncr:g-<scope>
|
|
185
|
+
const gPayload = raw.match(/^bncr:g-(.+)$/i)?.[1];
|
|
186
|
+
if (gPayload) {
|
|
187
|
+
if (isLowerHex(gPayload)) {
|
|
188
|
+
const route = parseRouteFromHexScope(gPayload);
|
|
189
|
+
if (route) return route;
|
|
190
|
+
}
|
|
191
|
+
return parseRouteFromScope(gPayload);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// 2) / 3) bncr:<hex> or bncr:<scope>
|
|
195
|
+
const bPayload = raw.match(/^bncr:(.+)$/i)?.[1];
|
|
196
|
+
if (bPayload) {
|
|
197
|
+
if (isLowerHex(bPayload)) {
|
|
198
|
+
const route = parseRouteFromHexScope(bPayload);
|
|
199
|
+
if (route) return route;
|
|
200
|
+
}
|
|
201
|
+
return parseRouteFromScope(bPayload);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return null;
|
|
130
205
|
}
|
|
131
206
|
|
|
132
207
|
function formatDisplayScope(route: BncrRoute): string {
|
|
133
|
-
|
|
208
|
+
// 主推荐标签:bncr:<platform>:<groupId>:<userId>
|
|
209
|
+
// 保持原始大小写,不做平台名降级。
|
|
210
|
+
return `bncr:${route.platform}:${route.groupId}:${route.userId}`;
|
|
134
211
|
}
|
|
135
212
|
|
|
136
213
|
function isLowerHex(input: string): boolean {
|
|
137
214
|
const raw = asString(input).trim();
|
|
138
|
-
|
|
215
|
+
// 兼容大小写十六进制,不主动降级大小写
|
|
216
|
+
return !!raw && /^[0-9a-fA-F]+$/.test(raw) && raw.length % 2 === 0;
|
|
139
217
|
}
|
|
140
218
|
|
|
141
219
|
function routeScopeToHex(route: BncrRoute): string {
|
|
@@ -144,7 +222,7 @@ function routeScopeToHex(route: BncrRoute): string {
|
|
|
144
222
|
}
|
|
145
223
|
|
|
146
224
|
function parseRouteFromHexScope(scopeHex: string): BncrRoute | null {
|
|
147
|
-
const rawHex = asString(scopeHex).trim()
|
|
225
|
+
const rawHex = asString(scopeHex).trim();
|
|
148
226
|
if (!isLowerHex(rawHex)) return null;
|
|
149
227
|
|
|
150
228
|
try {
|
|
@@ -277,24 +355,30 @@ function hex2utf8SessionKey(str: string): { sessionKey: string; scope: string }
|
|
|
277
355
|
function parseStrictBncrSessionKey(input: string): { sessionKey: string; scopeHex: string; route: BncrRoute } | null {
|
|
278
356
|
const raw = asString(input).trim();
|
|
279
357
|
if (!raw) return null;
|
|
280
|
-
if (!raw.startsWith(BNCR_SESSION_KEY_PREFIX)) return null;
|
|
281
358
|
|
|
282
|
-
|
|
283
|
-
//
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
359
|
+
// 终版兼容 sessionKey:
|
|
360
|
+
// 1) agent:main:bncr:direct:<hex(scope)>
|
|
361
|
+
// 2) agent:main:bncr:group:<hex(scope)>
|
|
362
|
+
// (统一归一成 direct:<hex(scope)>)
|
|
363
|
+
const m = raw.match(/^agent:main:bncr:(direct|group):(.+)$/);
|
|
364
|
+
if (!m?.[1] || !m?.[2]) return null;
|
|
365
|
+
|
|
366
|
+
const payload = asString(m[2]).trim();
|
|
367
|
+
let route: BncrRoute | null = null;
|
|
368
|
+
let scopeHex = '';
|
|
369
|
+
|
|
370
|
+
if (isLowerHex(payload)) {
|
|
371
|
+
scopeHex = payload;
|
|
372
|
+
route = parseRouteFromHexScope(payload);
|
|
373
|
+
} else {
|
|
374
|
+
route = parseRouteFromScope(payload);
|
|
375
|
+
if (route) scopeHex = routeScopeToHex(route);
|
|
287
376
|
}
|
|
288
377
|
|
|
289
|
-
|
|
290
|
-
if (!isLowerHex(scopeHex)) return null;
|
|
291
|
-
|
|
292
|
-
const decoded = hex2utf8SessionKey(raw);
|
|
293
|
-
const route = parseRouteFromScope(decoded.scope);
|
|
294
|
-
if (!route) return null;
|
|
378
|
+
if (!route || !scopeHex) return null;
|
|
295
379
|
|
|
296
380
|
return {
|
|
297
|
-
sessionKey:
|
|
381
|
+
sessionKey: `${BNCR_SESSION_KEY_PREFIX}${scopeHex}`,
|
|
298
382
|
scopeHex,
|
|
299
383
|
route,
|
|
300
384
|
};
|
|
@@ -352,6 +436,7 @@ function withTaskSessionKey(sessionKey: string, taskKey?: string | null): string
|
|
|
352
436
|
}
|
|
353
437
|
|
|
354
438
|
function buildFallbackSessionKey(route: BncrRoute): string {
|
|
439
|
+
// 新主口径:sessionKey 使用 agent:main:bncr:direct:<hex(scope)>
|
|
355
440
|
return `${BNCR_SESSION_KEY_PREFIX}${routeScopeToHex(route)}`;
|
|
356
441
|
}
|
|
357
442
|
|
|
@@ -383,14 +468,85 @@ function inboundDedupKey(params: {
|
|
|
383
468
|
return `${accountId}|${platform}|${groupId}|${userId}|hash:${digest}`;
|
|
384
469
|
}
|
|
385
470
|
|
|
386
|
-
function resolveChatType(
|
|
387
|
-
|
|
471
|
+
function resolveChatType(_route: BncrRoute): 'direct' | 'group' {
|
|
472
|
+
// 业务口径:无论群聊/私聊,统一按 direct 上报,避免会话层落到 group 显示分支(历史 bncr:g-*)。
|
|
473
|
+
return 'direct';
|
|
388
474
|
}
|
|
389
475
|
|
|
390
476
|
function routeKey(accountId: string, route: BncrRoute): string {
|
|
391
477
|
return `${accountId}:${route.platform}:${route.groupId}:${route.userId}`.toLowerCase();
|
|
392
478
|
}
|
|
393
479
|
|
|
480
|
+
function fileExtFromMime(mimeType?: string): string {
|
|
481
|
+
const mt = asString(mimeType || '').toLowerCase();
|
|
482
|
+
const map: Record<string, string> = {
|
|
483
|
+
'image/jpeg': '.jpg',
|
|
484
|
+
'image/jpg': '.jpg',
|
|
485
|
+
'image/png': '.png',
|
|
486
|
+
'image/webp': '.webp',
|
|
487
|
+
'image/gif': '.gif',
|
|
488
|
+
'video/mp4': '.mp4',
|
|
489
|
+
'video/webm': '.webm',
|
|
490
|
+
'video/quicktime': '.mov',
|
|
491
|
+
'audio/mpeg': '.mp3',
|
|
492
|
+
'audio/mp4': '.m4a',
|
|
493
|
+
'application/pdf': '.pdf',
|
|
494
|
+
'text/plain': '.txt',
|
|
495
|
+
};
|
|
496
|
+
return map[mt] || '';
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function sanitizeFileName(rawName?: string, fallback = 'file.bin'): string {
|
|
500
|
+
const name = asString(rawName || '').trim();
|
|
501
|
+
const base = name || fallback;
|
|
502
|
+
const cleaned = base.replace(/[\\/:*?"<>|\x00-\x1F]+/g, '_').replace(/\s+/g, ' ').trim();
|
|
503
|
+
return cleaned || fallback;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function buildTimestampFileName(mimeType?: string): string {
|
|
507
|
+
const d = new Date();
|
|
508
|
+
const pad = (n: number) => String(n).padStart(2, '0');
|
|
509
|
+
const ts = `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}_${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
|
|
510
|
+
const ext = fileExtFromMime(mimeType) || '.bin';
|
|
511
|
+
return `bncr_${ts}_${Math.random().toString(16).slice(2, 8)}${ext}`;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function resolveOutboundFileName(params: { mediaUrl?: string; fileName?: string; mimeType?: string }): string {
|
|
515
|
+
const mediaUrl = asString(params.mediaUrl || '').trim();
|
|
516
|
+
const mimeType = asString(params.mimeType || '').trim();
|
|
517
|
+
|
|
518
|
+
// 线上下载的文件,统一用时间戳命名(避免超长/无意义文件名)
|
|
519
|
+
if (/^https?:\/\//i.test(mediaUrl)) {
|
|
520
|
+
return buildTimestampFileName(mimeType);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const candidate = sanitizeFileName(params.fileName, 'file.bin');
|
|
524
|
+
if (candidate.length <= 80) return candidate;
|
|
525
|
+
|
|
526
|
+
// 超长文件名做裁剪,尽量保留扩展名
|
|
527
|
+
const ext = path.extname(candidate);
|
|
528
|
+
const stem = candidate.slice(0, Math.max(1, 80 - ext.length));
|
|
529
|
+
return `${stem}${ext}`;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function resolveBncrOutboundMessageType(params: { mimeType?: string; fileName?: string; hintedType?: string }): 'text' | 'image' | 'video' | 'file' {
|
|
533
|
+
const hinted = asString(params.hintedType || '').toLowerCase();
|
|
534
|
+
// 优先使用可确定类型;“file”属于兜底,不覆盖更明确的 mime/扩展判断
|
|
535
|
+
if (hinted === 'text' || hinted === 'image' || hinted === 'video') return hinted as any;
|
|
536
|
+
|
|
537
|
+
const mt = asString(params.mimeType || '').toLowerCase();
|
|
538
|
+
const major = mt.split('/')[0] || '';
|
|
539
|
+
if (major === 'text') return 'text';
|
|
540
|
+
if (major === 'image') return 'image';
|
|
541
|
+
if (major === 'video') return 'video';
|
|
542
|
+
|
|
543
|
+
const fn = asString(params.fileName || '').toLowerCase();
|
|
544
|
+
if (/\.(png|jpe?g|gif|webp|bmp|svg)$/.test(fn)) return 'image';
|
|
545
|
+
if (/\.(mp4|mov|mkv|avi|webm)$/.test(fn)) return 'video';
|
|
546
|
+
|
|
547
|
+
return 'file';
|
|
548
|
+
}
|
|
549
|
+
|
|
394
550
|
class BncrBridgeRuntime {
|
|
395
551
|
private api: OpenClawPluginApi;
|
|
396
552
|
private statePath: string | null = null;
|
|
@@ -409,11 +565,27 @@ class BncrBridgeRuntime {
|
|
|
409
565
|
private lastInboundByAccount = new Map<string, number>();
|
|
410
566
|
private lastOutboundByAccount = new Map<string, number>();
|
|
411
567
|
|
|
568
|
+
// 内置健康/回归计数(替代独立脚本)
|
|
569
|
+
private startedAt = now();
|
|
570
|
+
private connectEventsByAccount = new Map<string, number>();
|
|
571
|
+
private inboundEventsByAccount = new Map<string, number>();
|
|
572
|
+
private activityEventsByAccount = new Map<string, number>();
|
|
573
|
+
private ackEventsByAccount = new Map<string, number>();
|
|
574
|
+
|
|
412
575
|
private saveTimer: NodeJS.Timeout | null = null;
|
|
413
576
|
private pushTimer: NodeJS.Timeout | null = null;
|
|
414
577
|
private waiters = new Map<string, Array<() => void>>();
|
|
415
578
|
private gatewayContext: GatewayRequestHandlerOptions['context'] | null = null;
|
|
416
579
|
|
|
580
|
+
// 文件互传状态(V1:尽力而为,重连不续传)
|
|
581
|
+
private fileSendTransfers = new Map<string, FileSendTransferState>(); // OpenClaw -> Bncr(服务端发起)
|
|
582
|
+
private fileRecvTransfers = new Map<string, FileRecvTransferState>(); // Bncr -> OpenClaw(客户端发起)
|
|
583
|
+
private fileAckWaiters = new Map<string, {
|
|
584
|
+
resolve: (payload: Record<string, unknown>) => void;
|
|
585
|
+
reject: (err: Error) => void;
|
|
586
|
+
timer: NodeJS.Timeout;
|
|
587
|
+
}>();
|
|
588
|
+
|
|
417
589
|
constructor(api: OpenClawPluginApi) {
|
|
418
590
|
this.api = api;
|
|
419
591
|
}
|
|
@@ -421,7 +593,8 @@ class BncrBridgeRuntime {
|
|
|
421
593
|
startService = async (ctx: OpenClawPluginServiceContext) => {
|
|
422
594
|
this.statePath = path.join(ctx.stateDir, 'bncr-bridge-state.json');
|
|
423
595
|
await this.loadState();
|
|
424
|
-
this.
|
|
596
|
+
const bootDiag = this.buildIntegratedDiagnostics(BNCR_DEFAULT_ACCOUNT_ID);
|
|
597
|
+
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
598
|
};
|
|
426
599
|
|
|
427
600
|
stopService = async () => {
|
|
@@ -441,6 +614,96 @@ class BncrBridgeRuntime {
|
|
|
441
614
|
}, 300);
|
|
442
615
|
}
|
|
443
616
|
|
|
617
|
+
private incrementCounter(map: Map<string, number>, accountId: string) {
|
|
618
|
+
const acc = normalizeAccountId(accountId);
|
|
619
|
+
map.set(acc, (map.get(acc) || 0) + 1);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
private getCounter(map: Map<string, number>, accountId: string): number {
|
|
623
|
+
return map.get(normalizeAccountId(accountId)) || 0;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
private countInvalidOutboxSessionKeys(accountId: string): number {
|
|
627
|
+
const acc = normalizeAccountId(accountId);
|
|
628
|
+
let count = 0;
|
|
629
|
+
for (const entry of this.outbox.values()) {
|
|
630
|
+
if (entry.accountId !== acc) continue;
|
|
631
|
+
if (!parseStrictBncrSessionKey(entry.sessionKey)) count += 1;
|
|
632
|
+
}
|
|
633
|
+
return count;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
private countLegacyAccountResidue(accountId: string): number {
|
|
637
|
+
const acc = normalizeAccountId(accountId);
|
|
638
|
+
const mismatched = (raw?: string | null) => asString(raw || '').trim() && normalizeAccountId(raw) !== acc;
|
|
639
|
+
|
|
640
|
+
let count = 0;
|
|
641
|
+
|
|
642
|
+
for (const entry of this.outbox.values()) {
|
|
643
|
+
if (mismatched(entry.accountId)) count += 1;
|
|
644
|
+
}
|
|
645
|
+
for (const entry of this.deadLetter) {
|
|
646
|
+
if (mismatched(entry.accountId)) count += 1;
|
|
647
|
+
}
|
|
648
|
+
for (const info of this.sessionRoutes.values()) {
|
|
649
|
+
if (mismatched(info.accountId)) count += 1;
|
|
650
|
+
}
|
|
651
|
+
for (const key of this.lastSessionByAccount.keys()) {
|
|
652
|
+
if (mismatched(key)) count += 1;
|
|
653
|
+
}
|
|
654
|
+
for (const key of this.lastActivityByAccount.keys()) {
|
|
655
|
+
if (mismatched(key)) count += 1;
|
|
656
|
+
}
|
|
657
|
+
for (const key of this.lastInboundByAccount.keys()) {
|
|
658
|
+
if (mismatched(key)) count += 1;
|
|
659
|
+
}
|
|
660
|
+
for (const key of this.lastOutboundByAccount.keys()) {
|
|
661
|
+
if (mismatched(key)) count += 1;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
return count;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
private buildIntegratedDiagnostics(accountId: string) {
|
|
668
|
+
const acc = normalizeAccountId(accountId);
|
|
669
|
+
const t = now();
|
|
670
|
+
|
|
671
|
+
const pending = Array.from(this.outbox.values()).filter((v) => v.accountId === acc).length;
|
|
672
|
+
const dead = this.deadLetter.filter((v) => v.accountId === acc).length;
|
|
673
|
+
const invalidOutboxSessionKeys = this.countInvalidOutboxSessionKeys(acc);
|
|
674
|
+
const legacyAccountResidue = this.countLegacyAccountResidue(acc);
|
|
675
|
+
|
|
676
|
+
const totalKnownRoutes = Array.from(this.sessionRoutes.values()).filter((v) => v.accountId === acc).length;
|
|
677
|
+
const connected = this.isOnline(acc);
|
|
678
|
+
|
|
679
|
+
const pluginIndexExists = fs.existsSync(path.join(process.cwd(), 'plugins', 'bncr', 'index.ts'));
|
|
680
|
+
const pluginChannelExists = fs.existsSync(path.join(process.cwd(), 'plugins', 'bncr', 'src', 'channel.ts'));
|
|
681
|
+
|
|
682
|
+
const health = {
|
|
683
|
+
connected,
|
|
684
|
+
pending,
|
|
685
|
+
deadLetter: dead,
|
|
686
|
+
activeConnections: this.activeConnectionCount(acc),
|
|
687
|
+
connectEvents: this.getCounter(this.connectEventsByAccount, acc),
|
|
688
|
+
inboundEvents: this.getCounter(this.inboundEventsByAccount, acc),
|
|
689
|
+
activityEvents: this.getCounter(this.activityEventsByAccount, acc),
|
|
690
|
+
ackEvents: this.getCounter(this.ackEventsByAccount, acc),
|
|
691
|
+
uptimeSec: Math.floor((t - this.startedAt) / 1000),
|
|
692
|
+
};
|
|
693
|
+
|
|
694
|
+
const regression = {
|
|
695
|
+
pluginFilesPresent: pluginIndexExists && pluginChannelExists,
|
|
696
|
+
pluginIndexExists,
|
|
697
|
+
pluginChannelExists,
|
|
698
|
+
totalKnownRoutes,
|
|
699
|
+
invalidOutboxSessionKeys,
|
|
700
|
+
legacyAccountResidue,
|
|
701
|
+
ok: invalidOutboxSessionKeys === 0 && legacyAccountResidue === 0,
|
|
702
|
+
};
|
|
703
|
+
|
|
704
|
+
return { health, regression };
|
|
705
|
+
}
|
|
706
|
+
|
|
444
707
|
private async loadState() {
|
|
445
708
|
if (!this.statePath) return;
|
|
446
709
|
const loaded = await readJsonFileWithFallback(this.statePath, {
|
|
@@ -799,6 +1062,18 @@ class BncrBridgeRuntime {
|
|
|
799
1062
|
for (const [key, ts] of this.recentInbound.entries()) {
|
|
800
1063
|
if (t - ts > dedupWindowMs) this.recentInbound.delete(key);
|
|
801
1064
|
}
|
|
1065
|
+
|
|
1066
|
+
this.cleanupFileTransfers();
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
private cleanupFileTransfers() {
|
|
1070
|
+
const t = now();
|
|
1071
|
+
for (const [id, st] of this.fileSendTransfers.entries()) {
|
|
1072
|
+
if (t - st.startedAt > FILE_TRANSFER_KEEP_MS) this.fileSendTransfers.delete(id);
|
|
1073
|
+
}
|
|
1074
|
+
for (const [id, st] of this.fileRecvTransfers.entries()) {
|
|
1075
|
+
if (t - st.startedAt > FILE_TRANSFER_KEEP_MS) this.fileRecvTransfers.delete(id);
|
|
1076
|
+
}
|
|
802
1077
|
}
|
|
803
1078
|
|
|
804
1079
|
private markSeen(accountId: string, connId: string, clientId?: string) {
|
|
@@ -906,10 +1181,10 @@ class BncrBridgeRuntime {
|
|
|
906
1181
|
return alias?.route || parsed.route;
|
|
907
1182
|
}
|
|
908
1183
|
|
|
909
|
-
//
|
|
910
|
-
// 1)
|
|
911
|
-
// 2)
|
|
912
|
-
// 3)
|
|
1184
|
+
// 严谨目标解析(终版兼容模式):
|
|
1185
|
+
// 1) 兼容 6 种输入格式(to/sessionKey)
|
|
1186
|
+
// 2) 统一反查并归一为 sessionKey=agent:main:bncr:direct:<hex(scope)>
|
|
1187
|
+
// 3) 非兼容格式直接失败,并输出标准格式提示日志
|
|
913
1188
|
private resolveVerifiedTarget(rawTarget: string, accountId: string): { sessionKey: string; route: BncrRoute; displayScope: string } {
|
|
914
1189
|
const acc = normalizeAccountId(accountId);
|
|
915
1190
|
const raw = asString(rawTarget).trim();
|
|
@@ -927,8 +1202,10 @@ class BncrBridgeRuntime {
|
|
|
927
1202
|
}
|
|
928
1203
|
|
|
929
1204
|
if (!route) {
|
|
930
|
-
this.api.logger.warn?.(
|
|
931
|
-
|
|
1205
|
+
this.api.logger.warn?.(
|
|
1206
|
+
`[bncr-target-invalid] raw=${raw} accountId=${acc} reason=unparseable-or-unknown standardTo=bncr:<platform>:<groupId>:<userId> standardSessionKey=agent:main:bncr:direct:<hex(scope)>`,
|
|
1207
|
+
);
|
|
1208
|
+
throw new Error(`bncr invalid target(standard: to=bncr:<platform>:<groupId>:<userId>): ${raw}`);
|
|
932
1209
|
}
|
|
933
1210
|
|
|
934
1211
|
const wantedRouteKey = routeKey(acc, route);
|
|
@@ -955,6 +1232,14 @@ class BncrBridgeRuntime {
|
|
|
955
1232
|
throw new Error(`bncr target not found in known sessions: ${raw}`);
|
|
956
1233
|
}
|
|
957
1234
|
|
|
1235
|
+
// 发送链路命中目标时,同步刷新 lastSession,避免状态页显示过期会话。
|
|
1236
|
+
this.lastSessionByAccount.set(acc, {
|
|
1237
|
+
sessionKey: best.sessionKey,
|
|
1238
|
+
scope: formatDisplayScope(best.route),
|
|
1239
|
+
updatedAt: now(),
|
|
1240
|
+
});
|
|
1241
|
+
this.scheduleSave();
|
|
1242
|
+
|
|
958
1243
|
return {
|
|
959
1244
|
sessionKey: best.sessionKey,
|
|
960
1245
|
route: best.route,
|
|
@@ -966,6 +1251,87 @@ class BncrBridgeRuntime {
|
|
|
966
1251
|
this.lastActivityByAccount.set(normalizeAccountId(accountId), at);
|
|
967
1252
|
}
|
|
968
1253
|
|
|
1254
|
+
private fileAckKey(transferId: string, stage: string, chunkIndex?: number): string {
|
|
1255
|
+
const idx = Number.isFinite(Number(chunkIndex)) ? String(Number(chunkIndex)) : '-';
|
|
1256
|
+
return `${transferId}|${stage}|${idx}`;
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
private waitForFileAck(params: { transferId: string; stage: string; chunkIndex?: number; timeoutMs?: number }) {
|
|
1260
|
+
const transferId = asString(params.transferId).trim();
|
|
1261
|
+
const stage = asString(params.stage).trim();
|
|
1262
|
+
const key = this.fileAckKey(transferId, stage, params.chunkIndex);
|
|
1263
|
+
const timeoutMs = Math.max(1_000, Math.min(Number(params.timeoutMs || FILE_ACK_TIMEOUT_MS), 120_000));
|
|
1264
|
+
|
|
1265
|
+
return new Promise<Record<string, unknown>>((resolve, reject) => {
|
|
1266
|
+
const timer = setTimeout(() => {
|
|
1267
|
+
this.fileAckWaiters.delete(key);
|
|
1268
|
+
reject(new Error(`file ack timeout: ${key}`));
|
|
1269
|
+
}, timeoutMs);
|
|
1270
|
+
this.fileAckWaiters.set(key, { resolve, reject, timer });
|
|
1271
|
+
});
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
private resolveFileAck(params: { transferId: string; stage: string; chunkIndex?: number; payload: Record<string, unknown>; ok: boolean }) {
|
|
1275
|
+
const transferId = asString(params.transferId).trim();
|
|
1276
|
+
const stage = asString(params.stage).trim();
|
|
1277
|
+
const key = this.fileAckKey(transferId, stage, params.chunkIndex);
|
|
1278
|
+
const waiter = this.fileAckWaiters.get(key);
|
|
1279
|
+
if (!waiter) return false;
|
|
1280
|
+
this.fileAckWaiters.delete(key);
|
|
1281
|
+
clearTimeout(waiter.timer);
|
|
1282
|
+
if (params.ok) waiter.resolve(params.payload);
|
|
1283
|
+
else waiter.reject(new Error(asString(params.payload?.errorMessage || params.payload?.error || 'file ack failed')));
|
|
1284
|
+
return true;
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
private pushFileEventToAccount(accountId: string, event: string, payload: Record<string, unknown>) {
|
|
1288
|
+
const connIds = this.resolvePushConnIds(accountId);
|
|
1289
|
+
if (!connIds.size || !this.gatewayContext) {
|
|
1290
|
+
throw new Error(`no active bncr connection for account=${accountId}`);
|
|
1291
|
+
}
|
|
1292
|
+
this.gatewayContext.broadcastToConnIds(event, payload, connIds);
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
private resolveInboundFileType(mimeType: string, fileName: string): string {
|
|
1296
|
+
const mt = asString(mimeType).toLowerCase();
|
|
1297
|
+
const fn = asString(fileName).toLowerCase();
|
|
1298
|
+
if (mt.startsWith('image/') || /\.(png|jpe?g|gif|webp|bmp|svg)$/.test(fn)) return 'image';
|
|
1299
|
+
if (mt.startsWith('video/') || /\.(mp4|mov|mkv|avi|webm)$/.test(fn)) return 'video';
|
|
1300
|
+
if (mt.startsWith('audio/') || /\.(mp3|wav|m4a|aac|ogg|flac)$/.test(fn)) return 'audio';
|
|
1301
|
+
return mt || 'file';
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
private resolveInboundFilesDir(): string {
|
|
1305
|
+
const dir = path.join(process.cwd(), '.openclaw', 'media', 'inbound', 'bncr');
|
|
1306
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1307
|
+
return dir;
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
private async materializeRecvTransfer(st: FileRecvTransferState): Promise<{ path: string; fileSha256: string }> {
|
|
1311
|
+
const dir = this.resolveInboundFilesDir();
|
|
1312
|
+
const safeName = asString(st.fileName).trim() || `${st.transferId}.bin`;
|
|
1313
|
+
const finalPath = path.join(dir, safeName);
|
|
1314
|
+
|
|
1315
|
+
const ordered: Buffer[] = [];
|
|
1316
|
+
for (let i = 0; i < st.totalChunks; i++) {
|
|
1317
|
+
const chunk = st.bufferByChunk.get(i);
|
|
1318
|
+
if (!chunk) throw new Error(`missing chunk ${i}`);
|
|
1319
|
+
ordered.push(chunk);
|
|
1320
|
+
}
|
|
1321
|
+
const merged = Buffer.concat(ordered);
|
|
1322
|
+
if (Number(st.fileSize || 0) > 0 && merged.length !== Number(st.fileSize || 0)) {
|
|
1323
|
+
throw new Error(`size mismatch expected=${st.fileSize} got=${merged.length}`);
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
const sha = createHash('sha256').update(merged).digest('hex');
|
|
1327
|
+
if (st.fileSha256 && sha !== st.fileSha256) {
|
|
1328
|
+
throw new Error(`sha256 mismatch expected=${st.fileSha256} got=${sha}`);
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
fs.writeFileSync(finalPath, merged);
|
|
1332
|
+
return { path: finalPath, fileSha256: sha };
|
|
1333
|
+
}
|
|
1334
|
+
|
|
969
1335
|
private fmtAgo(ts?: number | null): string {
|
|
970
1336
|
if (!ts || !Number.isFinite(ts) || ts <= 0) return '-';
|
|
971
1337
|
const diff = Math.max(0, now() - ts);
|
|
@@ -989,6 +1355,7 @@ class BncrBridgeRuntime {
|
|
|
989
1355
|
const lastActivityAgo = this.fmtAgo(lastActAt);
|
|
990
1356
|
const lastInboundAgo = this.fmtAgo(lastInboundAt);
|
|
991
1357
|
const lastOutboundAgo = this.fmtAgo(lastOutboundAt);
|
|
1358
|
+
const diagnostics = this.buildIntegratedDiagnostics(acc);
|
|
992
1359
|
|
|
993
1360
|
return {
|
|
994
1361
|
pending,
|
|
@@ -1003,6 +1370,7 @@ class BncrBridgeRuntime {
|
|
|
1003
1370
|
lastInboundAgo,
|
|
1004
1371
|
lastOutboundAt,
|
|
1005
1372
|
lastOutboundAgo,
|
|
1373
|
+
diagnostics,
|
|
1006
1374
|
};
|
|
1007
1375
|
}
|
|
1008
1376
|
|
|
@@ -1026,21 +1394,49 @@ class BncrBridgeRuntime {
|
|
|
1026
1394
|
};
|
|
1027
1395
|
}
|
|
1028
1396
|
|
|
1397
|
+
private buildStatusHeadline(accountId: string): string {
|
|
1398
|
+
const acc = normalizeAccountId(accountId);
|
|
1399
|
+
const diag = this.buildIntegratedDiagnostics(acc);
|
|
1400
|
+
const h = diag.health;
|
|
1401
|
+
const r = diag.regression;
|
|
1402
|
+
|
|
1403
|
+
const parts = [
|
|
1404
|
+
r.ok ? 'diag:ok' : 'diag:warn',
|
|
1405
|
+
`p:${h.pending}`,
|
|
1406
|
+
`d:${h.deadLetter}`,
|
|
1407
|
+
`c:${h.activeConnections}`,
|
|
1408
|
+
];
|
|
1409
|
+
|
|
1410
|
+
if (!r.ok) {
|
|
1411
|
+
if (r.invalidOutboxSessionKeys > 0) parts.push(`invalid:${r.invalidOutboxSessionKeys}`);
|
|
1412
|
+
if (r.legacyAccountResidue > 0) parts.push(`legacy:${r.legacyAccountResidue}`);
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
return parts.join(' ');
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
getStatusHeadline(accountId: string): string {
|
|
1419
|
+
return this.buildStatusHeadline(accountId);
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1029
1422
|
getChannelSummary(defaultAccountId: string) {
|
|
1030
|
-
const
|
|
1423
|
+
const accountId = normalizeAccountId(defaultAccountId);
|
|
1424
|
+
const runtime = this.getAccountRuntimeSnapshot(accountId);
|
|
1425
|
+
const headline = this.buildStatusHeadline(accountId);
|
|
1426
|
+
|
|
1031
1427
|
if (runtime.connected) {
|
|
1032
|
-
return { linked: true };
|
|
1428
|
+
return { linked: true, self: { e164: headline } };
|
|
1033
1429
|
}
|
|
1034
1430
|
|
|
1035
1431
|
// 顶层汇总不绑定某个 accountId:任一账号在线都应显示 linked
|
|
1036
1432
|
const t = now();
|
|
1037
1433
|
for (const c of this.connections.values()) {
|
|
1038
1434
|
if (t - c.lastSeenAt <= CONNECT_TTL_MS) {
|
|
1039
|
-
return { linked: true };
|
|
1435
|
+
return { linked: true, self: { e164: headline } };
|
|
1040
1436
|
}
|
|
1041
1437
|
}
|
|
1042
1438
|
|
|
1043
|
-
return { linked: false };
|
|
1439
|
+
return { linked: false, self: { e164: headline } };
|
|
1044
1440
|
}
|
|
1045
1441
|
|
|
1046
1442
|
private enqueueOutbound(entry: OutboxEntry) {
|
|
@@ -1111,6 +1507,211 @@ class BncrBridgeRuntime {
|
|
|
1111
1507
|
};
|
|
1112
1508
|
}
|
|
1113
1509
|
|
|
1510
|
+
private async sleepMs(ms: number): Promise<void> {
|
|
1511
|
+
await new Promise<void>((resolve) => setTimeout(resolve, Math.max(0, Number(ms || 0))));
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
private waitChunkAck(params: {
|
|
1515
|
+
transferId: string;
|
|
1516
|
+
chunkIndex: number;
|
|
1517
|
+
timeoutMs?: number;
|
|
1518
|
+
}): Promise<void> {
|
|
1519
|
+
const { transferId, chunkIndex } = params;
|
|
1520
|
+
const timeoutMs = Math.max(1_000, Math.min(Number(params.timeoutMs || FILE_TRANSFER_ACK_TTL_MS), 60_000));
|
|
1521
|
+
const started = now();
|
|
1522
|
+
|
|
1523
|
+
return new Promise<void>((resolve, reject) => {
|
|
1524
|
+
const tick = async () => {
|
|
1525
|
+
const st = this.fileSendTransfers.get(transferId);
|
|
1526
|
+
if (!st) {
|
|
1527
|
+
reject(new Error('transfer state missing'));
|
|
1528
|
+
return;
|
|
1529
|
+
}
|
|
1530
|
+
if (st.failedChunks.has(chunkIndex)) {
|
|
1531
|
+
reject(new Error(st.failedChunks.get(chunkIndex) || `chunk ${chunkIndex} failed`));
|
|
1532
|
+
return;
|
|
1533
|
+
}
|
|
1534
|
+
if (st.ackedChunks.has(chunkIndex)) {
|
|
1535
|
+
resolve();
|
|
1536
|
+
return;
|
|
1537
|
+
}
|
|
1538
|
+
if (now() - started >= timeoutMs) {
|
|
1539
|
+
reject(new Error(`chunk ack timeout index=${chunkIndex}`));
|
|
1540
|
+
return;
|
|
1541
|
+
}
|
|
1542
|
+
await this.sleepMs(120);
|
|
1543
|
+
void tick();
|
|
1544
|
+
};
|
|
1545
|
+
void tick();
|
|
1546
|
+
});
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
private waitCompleteAck(params: {
|
|
1550
|
+
transferId: string;
|
|
1551
|
+
timeoutMs?: number;
|
|
1552
|
+
}): Promise<{ path: string }> {
|
|
1553
|
+
const { transferId } = params;
|
|
1554
|
+
const timeoutMs = Math.max(2_000, Math.min(Number(params.timeoutMs || 60_000), 120_000));
|
|
1555
|
+
const started = now();
|
|
1556
|
+
|
|
1557
|
+
return new Promise<{ path: string }>((resolve, reject) => {
|
|
1558
|
+
const tick = async () => {
|
|
1559
|
+
const st = this.fileSendTransfers.get(transferId);
|
|
1560
|
+
if (!st) {
|
|
1561
|
+
reject(new Error('transfer state missing'));
|
|
1562
|
+
return;
|
|
1563
|
+
}
|
|
1564
|
+
if (st.status === 'aborted') {
|
|
1565
|
+
reject(new Error(st.error || 'transfer aborted'));
|
|
1566
|
+
return;
|
|
1567
|
+
}
|
|
1568
|
+
if (st.status === 'completed' && st.completedPath) {
|
|
1569
|
+
resolve({ path: st.completedPath });
|
|
1570
|
+
return;
|
|
1571
|
+
}
|
|
1572
|
+
if (now() - started >= timeoutMs) {
|
|
1573
|
+
reject(new Error('complete ack timeout'));
|
|
1574
|
+
return;
|
|
1575
|
+
}
|
|
1576
|
+
await this.sleepMs(150);
|
|
1577
|
+
void tick();
|
|
1578
|
+
};
|
|
1579
|
+
void tick();
|
|
1580
|
+
});
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
private async transferMediaToBncrClient(params: {
|
|
1584
|
+
accountId: string;
|
|
1585
|
+
sessionKey: string;
|
|
1586
|
+
route: BncrRoute;
|
|
1587
|
+
mediaUrl: string;
|
|
1588
|
+
mediaLocalRoots?: readonly string[];
|
|
1589
|
+
}): Promise<{ mode: 'base64' | 'chunk'; mimeType?: string; fileName?: string; mediaBase64?: string; path?: string }> {
|
|
1590
|
+
const loaded = await this.api.runtime.media.loadWebMedia(params.mediaUrl, {
|
|
1591
|
+
localRoots: params.mediaLocalRoots,
|
|
1592
|
+
maxBytes: 50 * 1024 * 1024,
|
|
1593
|
+
});
|
|
1594
|
+
|
|
1595
|
+
const size = loaded.buffer.byteLength;
|
|
1596
|
+
const mimeType = loaded.contentType;
|
|
1597
|
+
const fileName = resolveOutboundFileName({
|
|
1598
|
+
mediaUrl: params.mediaUrl,
|
|
1599
|
+
fileName: loaded.fileName,
|
|
1600
|
+
mimeType,
|
|
1601
|
+
});
|
|
1602
|
+
|
|
1603
|
+
if (!FILE_FORCE_CHUNK && size <= FILE_INLINE_THRESHOLD) {
|
|
1604
|
+
return {
|
|
1605
|
+
mode: 'base64',
|
|
1606
|
+
mimeType,
|
|
1607
|
+
fileName,
|
|
1608
|
+
mediaBase64: loaded.buffer.toString('base64'),
|
|
1609
|
+
};
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
const ctx = this.gatewayContext;
|
|
1613
|
+
if (!ctx) throw new Error('gateway context unavailable');
|
|
1614
|
+
|
|
1615
|
+
const connIds = this.resolvePushConnIds(params.accountId);
|
|
1616
|
+
if (!connIds.size) throw new Error('no active bncr client for file chunk transfer');
|
|
1617
|
+
|
|
1618
|
+
const transferId = randomUUID();
|
|
1619
|
+
const chunkSize = 256 * 1024;
|
|
1620
|
+
const totalChunks = Math.ceil(size / chunkSize);
|
|
1621
|
+
const fileSha256 = createHash('sha256').update(loaded.buffer).digest('hex');
|
|
1622
|
+
|
|
1623
|
+
const st: FileSendTransferState = {
|
|
1624
|
+
transferId,
|
|
1625
|
+
accountId: normalizeAccountId(params.accountId),
|
|
1626
|
+
sessionKey: params.sessionKey,
|
|
1627
|
+
route: params.route,
|
|
1628
|
+
fileName,
|
|
1629
|
+
mimeType: mimeType || 'application/octet-stream',
|
|
1630
|
+
fileSize: size,
|
|
1631
|
+
chunkSize,
|
|
1632
|
+
totalChunks,
|
|
1633
|
+
fileSha256,
|
|
1634
|
+
startedAt: now(),
|
|
1635
|
+
status: 'init',
|
|
1636
|
+
ackedChunks: new Set(),
|
|
1637
|
+
failedChunks: new Map(),
|
|
1638
|
+
};
|
|
1639
|
+
this.fileSendTransfers.set(transferId, st);
|
|
1640
|
+
|
|
1641
|
+
ctx.broadcastToConnIds('bncr.file.init', {
|
|
1642
|
+
transferId,
|
|
1643
|
+
direction: 'oc2bncr',
|
|
1644
|
+
sessionKey: params.sessionKey,
|
|
1645
|
+
platform: params.route.platform,
|
|
1646
|
+
groupId: params.route.groupId,
|
|
1647
|
+
userId: params.route.userId,
|
|
1648
|
+
fileName,
|
|
1649
|
+
mimeType,
|
|
1650
|
+
fileSize: size,
|
|
1651
|
+
chunkSize,
|
|
1652
|
+
totalChunks,
|
|
1653
|
+
fileSha256,
|
|
1654
|
+
ts: now(),
|
|
1655
|
+
}, connIds);
|
|
1656
|
+
|
|
1657
|
+
// 逐块发送并等待 ACK
|
|
1658
|
+
for (let idx = 0; idx < totalChunks; idx++) {
|
|
1659
|
+
const start = idx * chunkSize;
|
|
1660
|
+
const end = Math.min(start + chunkSize, size);
|
|
1661
|
+
const slice = loaded.buffer.subarray(start, end);
|
|
1662
|
+
const chunkSha256 = createHash('sha256').update(slice).digest('hex');
|
|
1663
|
+
|
|
1664
|
+
let ok = false;
|
|
1665
|
+
let lastErr: unknown = null;
|
|
1666
|
+
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
1667
|
+
ctx.broadcastToConnIds('bncr.file.chunk', {
|
|
1668
|
+
transferId,
|
|
1669
|
+
chunkIndex: idx,
|
|
1670
|
+
offset: start,
|
|
1671
|
+
size: slice.byteLength,
|
|
1672
|
+
chunkSha256,
|
|
1673
|
+
base64: slice.toString('base64'),
|
|
1674
|
+
ts: now(),
|
|
1675
|
+
}, connIds);
|
|
1676
|
+
|
|
1677
|
+
try {
|
|
1678
|
+
await this.waitChunkAck({ transferId, chunkIndex: idx, timeoutMs: FILE_TRANSFER_ACK_TTL_MS });
|
|
1679
|
+
ok = true;
|
|
1680
|
+
break;
|
|
1681
|
+
} catch (err) {
|
|
1682
|
+
lastErr = err;
|
|
1683
|
+
await this.sleepMs(150 * attempt);
|
|
1684
|
+
}
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
if (!ok) {
|
|
1688
|
+
st.status = 'aborted';
|
|
1689
|
+
st.error = String((lastErr as any)?.message || lastErr || `chunk-${idx}-failed`);
|
|
1690
|
+
this.fileSendTransfers.set(transferId, st);
|
|
1691
|
+
ctx.broadcastToConnIds('bncr.file.abort', {
|
|
1692
|
+
transferId,
|
|
1693
|
+
reason: st.error,
|
|
1694
|
+
ts: now(),
|
|
1695
|
+
}, connIds);
|
|
1696
|
+
throw new Error(st.error);
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
ctx.broadcastToConnIds('bncr.file.complete', {
|
|
1701
|
+
transferId,
|
|
1702
|
+
ts: now(),
|
|
1703
|
+
}, connIds);
|
|
1704
|
+
|
|
1705
|
+
const done = await this.waitCompleteAck({ transferId, timeoutMs: 60_000 });
|
|
1706
|
+
|
|
1707
|
+
return {
|
|
1708
|
+
mode: 'chunk',
|
|
1709
|
+
mimeType,
|
|
1710
|
+
fileName,
|
|
1711
|
+
path: done.path,
|
|
1712
|
+
};
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1114
1715
|
private async enqueueFromReply(params: {
|
|
1115
1716
|
accountId: string;
|
|
1116
1717
|
sessionKey: string;
|
|
@@ -1129,7 +1730,13 @@ class BncrBridgeRuntime {
|
|
|
1129
1730
|
if (mediaList.length > 0) {
|
|
1130
1731
|
let first = true;
|
|
1131
1732
|
for (const mediaUrl of mediaList) {
|
|
1132
|
-
const media = await this.
|
|
1733
|
+
const media = await this.transferMediaToBncrClient({
|
|
1734
|
+
accountId,
|
|
1735
|
+
sessionKey,
|
|
1736
|
+
route,
|
|
1737
|
+
mediaUrl,
|
|
1738
|
+
mediaLocalRoots,
|
|
1739
|
+
});
|
|
1133
1740
|
const messageId = randomUUID();
|
|
1134
1741
|
const mediaMsg = first ? asString(payload.text || '') : '';
|
|
1135
1742
|
const frame = {
|
|
@@ -1141,11 +1748,21 @@ class BncrBridgeRuntime {
|
|
|
1141
1748
|
platform: route.platform,
|
|
1142
1749
|
groupId: route.groupId,
|
|
1143
1750
|
userId: route.userId,
|
|
1144
|
-
type:
|
|
1751
|
+
type: resolveBncrOutboundMessageType({
|
|
1752
|
+
mimeType: media.mimeType,
|
|
1753
|
+
fileName: media.fileName,
|
|
1754
|
+
hintedType: 'file',
|
|
1755
|
+
}),
|
|
1756
|
+
mimeType: media.mimeType || '',
|
|
1145
1757
|
msg: mediaMsg,
|
|
1146
|
-
path: mediaUrl,
|
|
1147
|
-
base64: media.mediaBase64,
|
|
1148
|
-
fileName:
|
|
1758
|
+
path: media.path || mediaUrl,
|
|
1759
|
+
base64: media.mediaBase64 || '',
|
|
1760
|
+
fileName: resolveOutboundFileName({
|
|
1761
|
+
mediaUrl,
|
|
1762
|
+
fileName: media.fileName,
|
|
1763
|
+
mimeType: media.mimeType,
|
|
1764
|
+
}),
|
|
1765
|
+
transferMode: media.mode,
|
|
1149
1766
|
},
|
|
1150
1767
|
ts: now(),
|
|
1151
1768
|
};
|
|
@@ -1207,6 +1824,7 @@ class BncrBridgeRuntime {
|
|
|
1207
1824
|
this.rememberGatewayContext(context);
|
|
1208
1825
|
this.markSeen(accountId, connId, clientId);
|
|
1209
1826
|
this.markActivity(accountId);
|
|
1827
|
+
this.incrementCounter(this.connectEventsByAccount, accountId);
|
|
1210
1828
|
|
|
1211
1829
|
respond(true, {
|
|
1212
1830
|
channel: CHANNEL_ID,
|
|
@@ -1218,6 +1836,7 @@ class BncrBridgeRuntime {
|
|
|
1218
1836
|
activeConnections: this.activeConnectionCount(accountId),
|
|
1219
1837
|
pending: Array.from(this.outbox.values()).filter((v) => v.accountId === accountId).length,
|
|
1220
1838
|
deadLetter: this.deadLetter.filter((v) => v.accountId === accountId).length,
|
|
1839
|
+
diagnostics: this.buildIntegratedDiagnostics(accountId),
|
|
1221
1840
|
now: now(),
|
|
1222
1841
|
});
|
|
1223
1842
|
|
|
@@ -1231,6 +1850,7 @@ class BncrBridgeRuntime {
|
|
|
1231
1850
|
const clientId = asString((params as any)?.clientId || '').trim() || undefined;
|
|
1232
1851
|
this.rememberGatewayContext(context);
|
|
1233
1852
|
this.markSeen(accountId, connId, clientId);
|
|
1853
|
+
this.incrementCounter(this.ackEventsByAccount, accountId);
|
|
1234
1854
|
|
|
1235
1855
|
const messageId = asString(params?.messageId || '').trim();
|
|
1236
1856
|
if (!messageId) {
|
|
@@ -1280,6 +1900,7 @@ class BncrBridgeRuntime {
|
|
|
1280
1900
|
this.rememberGatewayContext(context);
|
|
1281
1901
|
this.markSeen(accountId, connId, clientId);
|
|
1282
1902
|
this.markActivity(accountId);
|
|
1903
|
+
this.incrementCounter(this.activityEventsByAccount, accountId);
|
|
1283
1904
|
|
|
1284
1905
|
// 轻量活动心跳:仅刷新在线活跃状态,不承担拉取职责。
|
|
1285
1906
|
respond(true, {
|
|
@@ -1293,6 +1914,299 @@ class BncrBridgeRuntime {
|
|
|
1293
1914
|
});
|
|
1294
1915
|
};
|
|
1295
1916
|
|
|
1917
|
+
handleDiagnostics = async ({ params, respond }: GatewayRequestHandlerOptions) => {
|
|
1918
|
+
const accountId = normalizeAccountId(asString(params?.accountId || ''));
|
|
1919
|
+
const runtime = this.getAccountRuntimeSnapshot(accountId);
|
|
1920
|
+
const diagnostics = this.buildIntegratedDiagnostics(accountId);
|
|
1921
|
+
|
|
1922
|
+
respond(true, {
|
|
1923
|
+
channel: CHANNEL_ID,
|
|
1924
|
+
accountId,
|
|
1925
|
+
runtime,
|
|
1926
|
+
diagnostics,
|
|
1927
|
+
now: now(),
|
|
1928
|
+
});
|
|
1929
|
+
};
|
|
1930
|
+
|
|
1931
|
+
handleFileInit = async ({ params, respond, client, context }: GatewayRequestHandlerOptions) => {
|
|
1932
|
+
const accountId = normalizeAccountId(asString(params?.accountId || ''));
|
|
1933
|
+
const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
|
|
1934
|
+
const clientId = asString((params as any)?.clientId || '').trim() || undefined;
|
|
1935
|
+
this.rememberGatewayContext(context);
|
|
1936
|
+
this.markSeen(accountId, connId, clientId);
|
|
1937
|
+
this.markActivity(accountId);
|
|
1938
|
+
|
|
1939
|
+
const transferId = asString(params?.transferId || '').trim();
|
|
1940
|
+
const sessionKey = asString(params?.sessionKey || '').trim();
|
|
1941
|
+
const fileName = asString(params?.fileName || '').trim() || 'file.bin';
|
|
1942
|
+
const mimeType = asString(params?.mimeType || '').trim() || 'application/octet-stream';
|
|
1943
|
+
const fileSize = Number(params?.fileSize || 0);
|
|
1944
|
+
const chunkSize = Number(params?.chunkSize || 256 * 1024);
|
|
1945
|
+
const totalChunks = Number(params?.totalChunks || 0);
|
|
1946
|
+
const fileSha256 = asString(params?.fileSha256 || '').trim();
|
|
1947
|
+
|
|
1948
|
+
if (!transferId || !sessionKey || !fileSize || !chunkSize || !totalChunks) {
|
|
1949
|
+
respond(false, { error: 'transferId/sessionKey/fileSize/chunkSize/totalChunks required' });
|
|
1950
|
+
return;
|
|
1951
|
+
}
|
|
1952
|
+
|
|
1953
|
+
const normalized = normalizeStoredSessionKey(sessionKey);
|
|
1954
|
+
if (!normalized) {
|
|
1955
|
+
respond(false, { error: 'invalid sessionKey' });
|
|
1956
|
+
return;
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
const existing = this.fileRecvTransfers.get(transferId);
|
|
1960
|
+
if (existing) {
|
|
1961
|
+
respond(true, {
|
|
1962
|
+
ok: true,
|
|
1963
|
+
transferId,
|
|
1964
|
+
status: existing.status,
|
|
1965
|
+
duplicated: true,
|
|
1966
|
+
});
|
|
1967
|
+
return;
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1970
|
+
const route = parseRouteLike({
|
|
1971
|
+
platform: asString(params?.platform || normalized.route.platform),
|
|
1972
|
+
groupId: asString(params?.groupId || normalized.route.groupId),
|
|
1973
|
+
userId: asString(params?.userId || normalized.route.userId),
|
|
1974
|
+
}) || normalized.route;
|
|
1975
|
+
|
|
1976
|
+
this.fileRecvTransfers.set(transferId, {
|
|
1977
|
+
transferId,
|
|
1978
|
+
accountId,
|
|
1979
|
+
sessionKey: normalized.sessionKey,
|
|
1980
|
+
route,
|
|
1981
|
+
fileName,
|
|
1982
|
+
mimeType,
|
|
1983
|
+
fileSize,
|
|
1984
|
+
chunkSize,
|
|
1985
|
+
totalChunks,
|
|
1986
|
+
fileSha256,
|
|
1987
|
+
startedAt: now(),
|
|
1988
|
+
status: 'init',
|
|
1989
|
+
bufferByChunk: new Map(),
|
|
1990
|
+
receivedChunks: new Set(),
|
|
1991
|
+
});
|
|
1992
|
+
|
|
1993
|
+
respond(true, {
|
|
1994
|
+
ok: true,
|
|
1995
|
+
transferId,
|
|
1996
|
+
status: 'init',
|
|
1997
|
+
});
|
|
1998
|
+
};
|
|
1999
|
+
|
|
2000
|
+
handleFileChunk = async ({ params, respond, client, context }: GatewayRequestHandlerOptions) => {
|
|
2001
|
+
const accountId = normalizeAccountId(asString(params?.accountId || ''));
|
|
2002
|
+
const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
|
|
2003
|
+
const clientId = asString((params as any)?.clientId || '').trim() || undefined;
|
|
2004
|
+
this.rememberGatewayContext(context);
|
|
2005
|
+
this.markSeen(accountId, connId, clientId);
|
|
2006
|
+
this.markActivity(accountId);
|
|
2007
|
+
|
|
2008
|
+
const transferId = asString(params?.transferId || '').trim();
|
|
2009
|
+
const chunkIndex = Number(params?.chunkIndex ?? -1);
|
|
2010
|
+
const offset = Number(params?.offset ?? 0);
|
|
2011
|
+
const size = Number(params?.size ?? 0);
|
|
2012
|
+
const chunkSha256 = asString(params?.chunkSha256 || '').trim();
|
|
2013
|
+
const base64 = asString(params?.base64 || '');
|
|
2014
|
+
|
|
2015
|
+
if (!transferId || chunkIndex < 0 || !base64) {
|
|
2016
|
+
respond(false, { error: 'transferId/chunkIndex/base64 required' });
|
|
2017
|
+
return;
|
|
2018
|
+
}
|
|
2019
|
+
|
|
2020
|
+
const st = this.fileRecvTransfers.get(transferId);
|
|
2021
|
+
if (!st) {
|
|
2022
|
+
respond(false, { error: 'transfer not found' });
|
|
2023
|
+
return;
|
|
2024
|
+
}
|
|
2025
|
+
|
|
2026
|
+
try {
|
|
2027
|
+
const buf = Buffer.from(base64, 'base64');
|
|
2028
|
+
if (size > 0 && buf.length !== size) {
|
|
2029
|
+
throw new Error(`chunk size mismatch expected=${size} got=${buf.length}`);
|
|
2030
|
+
}
|
|
2031
|
+
if (chunkSha256) {
|
|
2032
|
+
const digest = createHash('sha256').update(buf).digest('hex');
|
|
2033
|
+
if (digest !== chunkSha256) throw new Error('chunk sha256 mismatch');
|
|
2034
|
+
}
|
|
2035
|
+
st.bufferByChunk.set(chunkIndex, buf);
|
|
2036
|
+
st.receivedChunks.add(chunkIndex);
|
|
2037
|
+
st.status = 'transferring';
|
|
2038
|
+
this.fileRecvTransfers.set(transferId, st);
|
|
2039
|
+
|
|
2040
|
+
respond(true, {
|
|
2041
|
+
ok: true,
|
|
2042
|
+
transferId,
|
|
2043
|
+
chunkIndex,
|
|
2044
|
+
offset,
|
|
2045
|
+
received: st.receivedChunks.size,
|
|
2046
|
+
totalChunks: st.totalChunks,
|
|
2047
|
+
});
|
|
2048
|
+
} catch (error) {
|
|
2049
|
+
respond(false, { error: String((error as any)?.message || error || 'chunk invalid') });
|
|
2050
|
+
}
|
|
2051
|
+
};
|
|
2052
|
+
|
|
2053
|
+
handleFileComplete = async ({ params, respond, client, context }: GatewayRequestHandlerOptions) => {
|
|
2054
|
+
const accountId = normalizeAccountId(asString(params?.accountId || ''));
|
|
2055
|
+
const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
|
|
2056
|
+
const clientId = asString((params as any)?.clientId || '').trim() || undefined;
|
|
2057
|
+
this.rememberGatewayContext(context);
|
|
2058
|
+
this.markSeen(accountId, connId, clientId);
|
|
2059
|
+
this.markActivity(accountId);
|
|
2060
|
+
|
|
2061
|
+
const transferId = asString(params?.transferId || '').trim();
|
|
2062
|
+
if (!transferId) {
|
|
2063
|
+
respond(false, { error: 'transferId required' });
|
|
2064
|
+
return;
|
|
2065
|
+
}
|
|
2066
|
+
|
|
2067
|
+
const st = this.fileRecvTransfers.get(transferId);
|
|
2068
|
+
if (!st) {
|
|
2069
|
+
respond(false, { error: 'transfer not found' });
|
|
2070
|
+
return;
|
|
2071
|
+
}
|
|
2072
|
+
|
|
2073
|
+
try {
|
|
2074
|
+
if (st.receivedChunks.size < st.totalChunks) {
|
|
2075
|
+
throw new Error(`chunk not complete received=${st.receivedChunks.size} total=${st.totalChunks}`);
|
|
2076
|
+
}
|
|
2077
|
+
|
|
2078
|
+
const ordered = Array.from(st.bufferByChunk.entries()).sort((a, b) => a[0] - b[0]).map((x) => x[1]);
|
|
2079
|
+
const merged = Buffer.concat(ordered);
|
|
2080
|
+
if (st.fileSize > 0 && merged.length !== st.fileSize) {
|
|
2081
|
+
throw new Error(`file size mismatch expected=${st.fileSize} got=${merged.length}`);
|
|
2082
|
+
}
|
|
2083
|
+
const digest = createHash('sha256').update(merged).digest('hex');
|
|
2084
|
+
if (st.fileSha256 && digest !== st.fileSha256) {
|
|
2085
|
+
throw new Error('file sha256 mismatch');
|
|
2086
|
+
}
|
|
2087
|
+
|
|
2088
|
+
const saved = await this.api.runtime.channel.media.saveMediaBuffer(
|
|
2089
|
+
merged,
|
|
2090
|
+
st.mimeType,
|
|
2091
|
+
'inbound',
|
|
2092
|
+
50 * 1024 * 1024,
|
|
2093
|
+
st.fileName,
|
|
2094
|
+
);
|
|
2095
|
+
st.completedPath = saved.path;
|
|
2096
|
+
st.status = 'completed';
|
|
2097
|
+
this.fileRecvTransfers.set(transferId, st);
|
|
2098
|
+
|
|
2099
|
+
respond(true, {
|
|
2100
|
+
ok: true,
|
|
2101
|
+
transferId,
|
|
2102
|
+
path: saved.path,
|
|
2103
|
+
size: merged.length,
|
|
2104
|
+
fileName: st.fileName,
|
|
2105
|
+
mimeType: st.mimeType,
|
|
2106
|
+
fileSha256: digest,
|
|
2107
|
+
});
|
|
2108
|
+
} catch (error) {
|
|
2109
|
+
st.status = 'aborted';
|
|
2110
|
+
st.error = String((error as any)?.message || error || 'complete failed');
|
|
2111
|
+
this.fileRecvTransfers.set(transferId, st);
|
|
2112
|
+
respond(false, { error: st.error });
|
|
2113
|
+
}
|
|
2114
|
+
};
|
|
2115
|
+
|
|
2116
|
+
handleFileAbort = async ({ params, respond, client, context }: GatewayRequestHandlerOptions) => {
|
|
2117
|
+
const accountId = normalizeAccountId(asString(params?.accountId || ''));
|
|
2118
|
+
const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
|
|
2119
|
+
const clientId = asString((params as any)?.clientId || '').trim() || undefined;
|
|
2120
|
+
this.rememberGatewayContext(context);
|
|
2121
|
+
this.markSeen(accountId, connId, clientId);
|
|
2122
|
+
this.markActivity(accountId);
|
|
2123
|
+
|
|
2124
|
+
const transferId = asString(params?.transferId || '').trim();
|
|
2125
|
+
if (!transferId) {
|
|
2126
|
+
respond(false, { error: 'transferId required' });
|
|
2127
|
+
return;
|
|
2128
|
+
}
|
|
2129
|
+
|
|
2130
|
+
const st = this.fileRecvTransfers.get(transferId);
|
|
2131
|
+
if (!st) {
|
|
2132
|
+
respond(true, { ok: true, transferId, message: 'not-found' });
|
|
2133
|
+
return;
|
|
2134
|
+
}
|
|
2135
|
+
|
|
2136
|
+
st.status = 'aborted';
|
|
2137
|
+
st.error = asString(params?.reason || 'aborted');
|
|
2138
|
+
this.fileRecvTransfers.set(transferId, st);
|
|
2139
|
+
|
|
2140
|
+
respond(true, {
|
|
2141
|
+
ok: true,
|
|
2142
|
+
transferId,
|
|
2143
|
+
status: 'aborted',
|
|
2144
|
+
});
|
|
2145
|
+
};
|
|
2146
|
+
|
|
2147
|
+
handleFileAck = async ({ params, respond, client, context }: GatewayRequestHandlerOptions) => {
|
|
2148
|
+
const accountId = normalizeAccountId(asString(params?.accountId || ''));
|
|
2149
|
+
const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
|
|
2150
|
+
const clientId = asString((params as any)?.clientId || '').trim() || undefined;
|
|
2151
|
+
this.rememberGatewayContext(context);
|
|
2152
|
+
this.markSeen(accountId, connId, clientId);
|
|
2153
|
+
this.markActivity(accountId);
|
|
2154
|
+
|
|
2155
|
+
const transferId = asString(params?.transferId || '').trim();
|
|
2156
|
+
const stage = asString(params?.stage || '').trim();
|
|
2157
|
+
const ok = params?.ok !== false;
|
|
2158
|
+
const chunkIndex = Number(params?.chunkIndex ?? -1);
|
|
2159
|
+
|
|
2160
|
+
if (!transferId || !stage) {
|
|
2161
|
+
respond(false, { error: 'transferId/stage required' });
|
|
2162
|
+
return;
|
|
2163
|
+
}
|
|
2164
|
+
|
|
2165
|
+
const st = this.fileSendTransfers.get(transferId);
|
|
2166
|
+
if (st) {
|
|
2167
|
+
if (!ok) {
|
|
2168
|
+
const code = asString(params?.errorCode || 'ACK_FAILED');
|
|
2169
|
+
const msg = asString(params?.errorMessage || 'ack failed');
|
|
2170
|
+
st.error = `${code}:${msg}`;
|
|
2171
|
+
if (stage === 'chunk' && chunkIndex >= 0) st.failedChunks.set(chunkIndex, st.error);
|
|
2172
|
+
if (stage === 'complete') st.status = 'aborted';
|
|
2173
|
+
} else {
|
|
2174
|
+
if (stage === 'chunk' && chunkIndex >= 0) {
|
|
2175
|
+
st.ackedChunks.add(chunkIndex);
|
|
2176
|
+
st.status = 'transferring';
|
|
2177
|
+
}
|
|
2178
|
+
if (stage === 'complete') {
|
|
2179
|
+
st.status = 'completed';
|
|
2180
|
+
st.completedPath = asString(params?.path || '').trim() || st.completedPath;
|
|
2181
|
+
}
|
|
2182
|
+
}
|
|
2183
|
+
this.fileSendTransfers.set(transferId, st);
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2186
|
+
// 唤醒等待中的 chunk/complete ACK
|
|
2187
|
+
this.resolveFileAck({
|
|
2188
|
+
transferId,
|
|
2189
|
+
stage,
|
|
2190
|
+
chunkIndex: chunkIndex >= 0 ? chunkIndex : undefined,
|
|
2191
|
+
payload: {
|
|
2192
|
+
ok,
|
|
2193
|
+
transferId,
|
|
2194
|
+
stage,
|
|
2195
|
+
path: asString(params?.path || '').trim(),
|
|
2196
|
+
errorCode: asString(params?.errorCode || ''),
|
|
2197
|
+
errorMessage: asString(params?.errorMessage || ''),
|
|
2198
|
+
},
|
|
2199
|
+
ok,
|
|
2200
|
+
});
|
|
2201
|
+
|
|
2202
|
+
respond(true, {
|
|
2203
|
+
ok: true,
|
|
2204
|
+
transferId,
|
|
2205
|
+
stage,
|
|
2206
|
+
state: st?.status || 'late',
|
|
2207
|
+
});
|
|
2208
|
+
};
|
|
2209
|
+
|
|
1296
2210
|
handleInbound = async ({ params, respond, client, context }: GatewayRequestHandlerOptions) => {
|
|
1297
2211
|
const accountId = normalizeAccountId(asString(params?.accountId || ''));
|
|
1298
2212
|
const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
|
|
@@ -1300,6 +2214,7 @@ class BncrBridgeRuntime {
|
|
|
1300
2214
|
this.rememberGatewayContext(context);
|
|
1301
2215
|
this.markSeen(accountId, connId, clientId);
|
|
1302
2216
|
this.markActivity(accountId);
|
|
2217
|
+
this.incrementCounter(this.inboundEventsByAccount, accountId);
|
|
1303
2218
|
|
|
1304
2219
|
const platform = asString(params?.platform || '').trim();
|
|
1305
2220
|
const groupId = asString(params?.groupId || '0').trim() || '0';
|
|
@@ -1320,6 +2235,7 @@ class BncrBridgeRuntime {
|
|
|
1320
2235
|
const text = asString(params?.msg || '');
|
|
1321
2236
|
const msgType = asString(params?.type || 'text') || 'text';
|
|
1322
2237
|
const mediaBase64 = asString(params?.base64 || '');
|
|
2238
|
+
const mediaPathFromTransfer = asString(params?.path || '').trim();
|
|
1323
2239
|
const mimeType = asString(params?.mimeType || '').trim() || undefined;
|
|
1324
2240
|
const fileName = asString(params?.fileName || '').trim() || undefined;
|
|
1325
2241
|
const msgId = asString(params?.msgId || '').trim() || undefined;
|
|
@@ -1396,6 +2312,8 @@ class BncrBridgeRuntime {
|
|
|
1396
2312
|
fileName,
|
|
1397
2313
|
);
|
|
1398
2314
|
mediaPath = saved.path;
|
|
2315
|
+
} else if (mediaPathFromTransfer && fs.existsSync(mediaPathFromTransfer)) {
|
|
2316
|
+
mediaPath = mediaPathFromTransfer;
|
|
1399
2317
|
}
|
|
1400
2318
|
|
|
1401
2319
|
const rawBody = agentText || (msgType === 'text' ? '' : `[${msgType}]`);
|
|
@@ -1412,6 +2330,11 @@ class BncrBridgeRuntime {
|
|
|
1412
2330
|
});
|
|
1413
2331
|
|
|
1414
2332
|
const displayTo = formatDisplayScope(route);
|
|
2333
|
+
// 全场景口径:SenderId 统一上报为 hex(scope)
|
|
2334
|
+
// 作为平台侧稳定身份键,避免退化为裸 userId。
|
|
2335
|
+
const senderIdForContext =
|
|
2336
|
+
parseStrictBncrSessionKey(baseSessionKey)?.scopeHex
|
|
2337
|
+
|| routeScopeToHex(route);
|
|
1415
2338
|
const ctxPayload = this.api.runtime.channel.reply.finalizeInboundContext({
|
|
1416
2339
|
Body: body,
|
|
1417
2340
|
BodyForAgent: rawBody,
|
|
@@ -1425,7 +2348,7 @@ class BncrBridgeRuntime {
|
|
|
1425
2348
|
AccountId: accountId,
|
|
1426
2349
|
ChatType: peer.kind,
|
|
1427
2350
|
ConversationLabel: displayTo,
|
|
1428
|
-
SenderId:
|
|
2351
|
+
SenderId: senderIdForContext,
|
|
1429
2352
|
Provider: CHANNEL_ID,
|
|
1430
2353
|
Surface: CHANNEL_ID,
|
|
1431
2354
|
MessageSid: msgId,
|
|
@@ -1529,6 +2452,25 @@ class BncrBridgeRuntime {
|
|
|
1529
2452
|
const accountId = normalizeAccountId(ctx.accountId);
|
|
1530
2453
|
const to = asString(ctx.to || '').trim();
|
|
1531
2454
|
|
|
2455
|
+
if (BNCR_DEBUG_VERBOSE) {
|
|
2456
|
+
this.api.logger.info?.(
|
|
2457
|
+
`[bncr-send-entry:text] ${JSON.stringify({
|
|
2458
|
+
accountId,
|
|
2459
|
+
to,
|
|
2460
|
+
text: asString(ctx?.text || ''),
|
|
2461
|
+
mediaUrl: asString(ctx?.mediaUrl || ''),
|
|
2462
|
+
sessionKey: asString(ctx?.sessionKey || ''),
|
|
2463
|
+
mirrorSessionKey: asString(ctx?.mirror?.sessionKey || ''),
|
|
2464
|
+
rawCtx: {
|
|
2465
|
+
to: ctx?.to,
|
|
2466
|
+
accountId: ctx?.accountId,
|
|
2467
|
+
threadId: ctx?.threadId,
|
|
2468
|
+
replyToId: ctx?.replyToId,
|
|
2469
|
+
},
|
|
2470
|
+
})}`,
|
|
2471
|
+
);
|
|
2472
|
+
}
|
|
2473
|
+
|
|
1532
2474
|
const verified = this.resolveVerifiedTarget(to, accountId);
|
|
1533
2475
|
|
|
1534
2476
|
this.rememberSessionRoute(verified.sessionKey, accountId, verified.route);
|
|
@@ -1550,6 +2492,26 @@ class BncrBridgeRuntime {
|
|
|
1550
2492
|
const accountId = normalizeAccountId(ctx.accountId);
|
|
1551
2493
|
const to = asString(ctx.to || '').trim();
|
|
1552
2494
|
|
|
2495
|
+
if (BNCR_DEBUG_VERBOSE) {
|
|
2496
|
+
this.api.logger.info?.(
|
|
2497
|
+
`[bncr-send-entry:media] ${JSON.stringify({
|
|
2498
|
+
accountId,
|
|
2499
|
+
to,
|
|
2500
|
+
text: asString(ctx?.text || ''),
|
|
2501
|
+
mediaUrl: asString(ctx?.mediaUrl || ''),
|
|
2502
|
+
mediaUrls: Array.isArray(ctx?.mediaUrls) ? ctx.mediaUrls : undefined,
|
|
2503
|
+
sessionKey: asString(ctx?.sessionKey || ''),
|
|
2504
|
+
mirrorSessionKey: asString(ctx?.mirror?.sessionKey || ''),
|
|
2505
|
+
rawCtx: {
|
|
2506
|
+
to: ctx?.to,
|
|
2507
|
+
accountId: ctx?.accountId,
|
|
2508
|
+
threadId: ctx?.threadId,
|
|
2509
|
+
replyToId: ctx?.replyToId,
|
|
2510
|
+
},
|
|
2511
|
+
})}`,
|
|
2512
|
+
);
|
|
2513
|
+
}
|
|
2514
|
+
|
|
1553
2515
|
const verified = this.resolveVerifiedTarget(to, accountId);
|
|
1554
2516
|
|
|
1555
2517
|
this.rememberSessionRoute(verified.sessionKey, accountId, verified.route);
|
|
@@ -1632,7 +2594,7 @@ export function createBncrChannelPlugin(bridge: BncrBridgeRuntime) {
|
|
|
1632
2594
|
looksLikeId: (raw: string, normalized?: string) => {
|
|
1633
2595
|
return Boolean(asString(normalized || raw).trim());
|
|
1634
2596
|
},
|
|
1635
|
-
hint: '
|
|
2597
|
+
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
2598
|
},
|
|
1637
2599
|
},
|
|
1638
2600
|
configSchema: BncrConfigSchema,
|
|
@@ -1709,6 +2671,7 @@ export function createBncrChannelPlugin(bridge: BncrBridgeRuntime) {
|
|
|
1709
2671
|
const lastInboundAgo = rt?.lastInboundAgo ?? meta.lastInboundAgo ?? '-';
|
|
1710
2672
|
const lastOutboundAt = rt?.lastOutboundAt ?? meta.lastOutboundAt ?? null;
|
|
1711
2673
|
const lastOutboundAgo = rt?.lastOutboundAgo ?? meta.lastOutboundAgo ?? '-';
|
|
2674
|
+
const diagnostics = rt?.diagnostics ?? meta.diagnostics ?? null;
|
|
1712
2675
|
// 右侧状态字段统一:离线时也显示 Status(避免出现 configured 文案)
|
|
1713
2676
|
const normalizedMode = rt?.mode === 'linked'
|
|
1714
2677
|
? 'linked'
|
|
@@ -1730,6 +2693,7 @@ export function createBncrChannelPlugin(bridge: BncrBridgeRuntime) {
|
|
|
1730
2693
|
mode: normalizedMode,
|
|
1731
2694
|
pending,
|
|
1732
2695
|
deadLetter,
|
|
2696
|
+
healthSummary: bridge.getStatusHeadline(account?.accountId),
|
|
1733
2697
|
lastSessionKey,
|
|
1734
2698
|
lastSessionScope,
|
|
1735
2699
|
lastSessionAt,
|
|
@@ -1740,6 +2704,7 @@ export function createBncrChannelPlugin(bridge: BncrBridgeRuntime) {
|
|
|
1740
2704
|
lastInboundAgo,
|
|
1741
2705
|
lastOutboundAt,
|
|
1742
2706
|
lastOutboundAgo,
|
|
2707
|
+
diagnostics,
|
|
1743
2708
|
};
|
|
1744
2709
|
},
|
|
1745
2710
|
resolveAccountState: ({ enabled, configured, account, cfg, runtime }: any) => {
|
|
@@ -1750,7 +2715,18 @@ export function createBncrChannelPlugin(bridge: BncrBridgeRuntime) {
|
|
|
1750
2715
|
return rt?.connected ? 'linked' : 'configured';
|
|
1751
2716
|
},
|
|
1752
2717
|
},
|
|
1753
|
-
gatewayMethods: [
|
|
2718
|
+
gatewayMethods: [
|
|
2719
|
+
'bncr.connect',
|
|
2720
|
+
'bncr.inbound',
|
|
2721
|
+
'bncr.activity',
|
|
2722
|
+
'bncr.ack',
|
|
2723
|
+
'bncr.diagnostics',
|
|
2724
|
+
'bncr.file.init',
|
|
2725
|
+
'bncr.file.chunk',
|
|
2726
|
+
'bncr.file.complete',
|
|
2727
|
+
'bncr.file.abort',
|
|
2728
|
+
'bncr.file.ack',
|
|
2729
|
+
],
|
|
1754
2730
|
gateway: {
|
|
1755
2731
|
startAccount: bridge.channelStartAccount,
|
|
1756
2732
|
stopAccount: bridge.channelStopAccount,
|